Files
stack/packages/webui/tests/org-live-updates-browser.test.mjs

96 lines
7.3 KiB
JavaScript

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');
});