import test from 'node:test'; import assert from 'node:assert/strict'; import { setup, P, T } from './helpers/w15-conversations.mjs'; import { fixture } from './fixture.mjs'; import { browser } from './browser.mjs'; // A periodic paint must not replace controls or messages that a person is using. test('project and conversation stay still over refreshes and retain existing messages', async t => { const { b, wait } = await setup(t); await b.evaluate(`(() => { const original = window.fetch; window.extraMessages = []; window.extraThreads = []; window.fetch = async (url, options) => { const response = await original(url, options); if (String(url).startsWith('/api/chat/threads') && !String(url).includes('/messages') && !String(url).includes('/sessions') && !String(url).includes('/shares') && !String(url).includes('shared=true') && response.ok) { const page = await response.json(); return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraThreads]}), {headers: {'content-type':'application/json'}}); } if (!String(url).includes('/threads/') || !String(url).includes('/messages') || !response.ok) return response; const page = await response.json(); return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraMessages]}), {headers: {'content-type':'application/json'}}); }; })()`); await b.evaluate(`location.hash='#/projects/${P}/conversations'`); await wait(`document.querySelector('[data-conversation-id]')`); await b.evaluate(`(() => { const row = document.querySelector('[data-conversation-id]'); row.focus(); window.savedProject = {node:row, rect:row.getBoundingClientRect().toJSON(), scroll:window.scrollY}; })()`); await new Promise(resolve => setTimeout(resolve, 21500)); const project = await b.evaluate(`({connected:savedProject.node.isConnected, same:document.querySelector('[data-conversation-id]')===savedProject.node, rect:savedProject.node.getBoundingClientRect().toJSON(), before:savedProject.rect, focused:document.activeElement===savedProject.node, scroll:window.scrollY, beforeScroll:savedProject.scroll})`); assert.ok(project.connected && project.same, 'project row survives unchanged refreshes'); assert.deepEqual(project.rect, project.before); assert.equal(project.focused, true); assert.equal(project.scroll, project.beforeScroll); await b.evaluate(`extraThreads.push({id:'20000000-0000-4000-8000-000000000004',project:'${P}',name:'New conversation',instance:'alice'}); document.querySelector('#refresh').click()`); await wait(`document.querySelector('[data-conversation-id="20000000-0000-4000-8000-000000000004"]')`); assert.equal(await b.evaluate('savedProject.node.isConnected && document.activeElement===savedProject.node'), true, 'new conversation keeps the existing row and focus'); await b.evaluate(`location.hash='#/projects/threads/${T}'`); await wait(`document.querySelector('[data-message]') && document.querySelector('#chat-body')`); await b.evaluate(`(() => { const message = document.querySelector('[data-message]'), field = document.querySelector('#chat-body'); field.value='draft'; field.dispatchEvent(new Event('input',{bubbles:true})); field.focus(); field.setSelectionRange(2,2); window.savedChat = {node:message, rect:message.getBoundingClientRect().toJSON(), scroll:document.querySelector('.chat-log').scrollTop, field}; })()`); await new Promise(resolve => setTimeout(resolve, 21500)); const chat = await b.evaluate(`({connected:savedChat.node.isConnected, same:document.querySelector('[data-message]')===savedChat.node, rect:savedChat.node.getBoundingClientRect().toJSON(), before:savedChat.rect, focused:document.activeElement===savedChat.field, caret:savedChat.field.selectionStart, scroll:document.querySelector('.chat-log').scrollTop, beforeScroll:savedChat.scroll})`); assert.ok(chat.connected && chat.same, 'message survives unchanged refreshes'); assert.deepEqual(chat.rect, chat.before); assert.equal(chat.focused, true); assert.equal(chat.caret, 2); assert.equal(chat.scroll, chat.beforeScroll); await b.evaluate(`extraMessages.push({id:'40000000-0000-4000-8000-000000000002',from_role:'human',body:'A new message',at:'2026-11-05T12:01:00Z'}); document.querySelector('#refresh').click()`); await wait(`document.querySelector('[data-message="40000000-0000-4000-8000-000000000002"]')`); assert.equal(await b.evaluate('savedChat.node.isConnected'), true, 'new message does not replace the old message'); assert.equal(await b.evaluate('savedChat.field.isConnected && document.activeElement===savedChat.field && savedChat.field.selectionStart===2'), true, 'new message keeps the composer and caret'); }); test('unchanged board reads keep the session row and focused reply control', async t => { const f = await fixture(); t.after(() => f.close()); const b = await browser(); t.after(() => b.close()); await b.navigate(f.base); const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30));}throw Error('timed out');})()`); await wait(`document.querySelector('[data-open="proj/agent1"]')`); await b.evaluate(`document.querySelector('[data-open="proj/agent1"]').click();document.querySelector('#reply').focus();window.boardRow=document.querySelector('tr[data-row="proj/agent1"]');window.boardAgeRow=document.querySelector('tr[data-row="proj/agent2"]');window.boardReply=document.querySelector('#reply')`); for (let i=0; i<2; i++) { await b.evaluate(`document.querySelector('#refresh').click()`); await wait(`!document.querySelector('#refresh').disabled`); } assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true); await b.evaluate(`(() => { const old=window.fetch; window.boardAge=120; window.fetch=async (url,options) => { const response=await old(url,options); if(String(url)!=='/api/board' || !response.ok) return response; const data=await response.json(); for(const row of data.sessions) row.ageSeconds=window.boardAge; return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}}); }; document.querySelector('#refresh').click(); })()`); await wait(`!document.querySelector('#refresh').disabled`); assert.match(await b.evaluate(`boardAgeRow.textContent`), /2m ago/); assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true); }); test('conversation status has room for a temporary unavailable message', async t => { const { b, render, wait } = await setup(t); await render(`/projects/threads/${T}`); await wait(`document.querySelector('[data-conversation-request-status]')`); const position = await b.evaluate(`(() => { const log=document.querySelector('.chat-log'), line=document.querySelector('[data-conversation-request-status]'); line.textContent=''; const before=log.getBoundingClientRect().top, height=line.getBoundingClientRect().height; line.textContent='Request status unavailable.'; return {before,after:log.getBoundingClientRect().top,height,filledHeight:line.getBoundingClientRect().height}; })()`); assert.equal(position.after, position.before); assert.ok(position.height >= position.filledHeight, 'the empty status line reserves its height'); });