Caret and "chat" label were two hit targets with two hover states and
tooltips. Collapse them into a single <button id="chat-header-nav">
with one tooltip ("back to conversations") — the caret becomes a
visibility-only hint inside the button, shown when there's somewhere
to go back to, hidden on the list itself. The .navigable class
controls whether the button reads as interactive, so clicking the
label while already on the list is a cursor-default no-op.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
172 lines
7.0 KiB
JavaScript
172 lines
7.0 KiB
JavaScript
import { describe, it, expect, click, $, sleep, waitFor, csrf } 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-caret 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 caret = $('#chat-header-back-caret');
|
|
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(caret.classList.contains('hidden')).toBe(false);
|
|
if (listShown) expect(caret.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);
|
|
// Back caret is only for thread view — not needed when pick has
|
|
// its own dedicated back button.
|
|
expect($('#chat-header-back-caret').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');
|
|
// Every entry carries kind + key so the UI can route 1:1 vs. room.
|
|
for (const c of res.conversations) {
|
|
expect(c.kind === 'peer' || c.kind === 'room').toBe(true);
|
|
expect(typeof c.key).toBe('string');
|
|
}
|
|
});
|
|
|
|
it('/api/chat/messages without peer or room_uid 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);
|
|
});
|
|
|
|
it('/api/chat/messages with unknown room returns empty array', async () => {
|
|
const res = await fetch('api/chat/messages?room_uid=__no_such_room__')
|
|
.then(r => r.json());
|
|
expect(Array.isArray(res.messages)).toBe(true);
|
|
expect(res.messages.length).toBe(0);
|
|
});
|
|
|
|
it('/api/chat/rooms returns rooms array', async () => {
|
|
const res = await fetch('api/chat/rooms').then(r => r.json());
|
|
expect(res.ok).toBe(true);
|
|
expect(Array.isArray(res.rooms)).toBe(true);
|
|
});
|
|
|
|
it('/api/chat/thread-settings round-trips disposable flag', async () => {
|
|
const peer = '__test_disposable_flip__';
|
|
const initial = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
|
.then(r => r.json());
|
|
expect(initial.disposable).toBe(0);
|
|
const set = await fetch('api/chat/thread-settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ peer, disposable: true }),
|
|
}).then(r => r.json());
|
|
expect(set.disposable).toBe(1);
|
|
const after = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
|
.then(r => r.json());
|
|
expect(after.disposable).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Chat: group creation UI', () => {
|
|
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'));
|
|
}
|
|
};
|
|
|
|
it('pick view has 1:1 / group mode switch', async () => {
|
|
await ensureFlyoutClosed();
|
|
click('#chat-footer-btn');
|
|
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
|
|
await sleep(80);
|
|
click('#chat-new-btn');
|
|
await sleep(60);
|
|
const mode = document.querySelectorAll('.chat-pick-mode-btn');
|
|
expect(mode.length).toBe(2);
|
|
// Toggling to group reveals the name input and action bar.
|
|
const groupBtn = document.querySelector('.chat-pick-mode-btn[data-mode="group"]');
|
|
click(groupBtn);
|
|
await sleep(30);
|
|
expect($('#chat-group-name').classList.contains('hidden')).toBe(false);
|
|
expect($('#chat-group-actions').classList.contains('hidden')).toBe(false);
|
|
await ensureFlyoutClosed();
|
|
});
|
|
|
|
it('room-info view element exists', () => {
|
|
expect($('#chat-room-info-view')).toExist();
|
|
expect($('#chat-room-info-back')).toExist();
|
|
expect($('#chat-room-info-body')).toExist();
|
|
});
|
|
});
|