Files
stack/packages/webui/tests/assistant-threads-browser.test.mjs
jason.woltje 02df2a3a25 fix(webui): sp6 re-check notes, a held message is never overwritten or held out of sight, and a pane whose organization left the list does not promise it comes back (#1873, lane sp6-threads)
The held draft goes back only into an empty composer and is cleared only once it is in it. A thread
that reads without a composer shows the held message in the pane's line. Only a stopped or
unavailable bus says the message comes back when the pane reads again.
2026-11-15 04:57:37 -06:00

282 lines
22 KiB
JavaScript

// SP6 (#1873): the Assistant is one system page. Up to three chat threads from any organization
// open next to each other, each the chat thread view pinned to its own organization.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const P = { alpha: '10000000-0000-4000-8000-00000000000a', beta: '10000000-0000-4000-8000-00000000000b', home: '10000000-0000-4000-8000-00000000000c' };
const T = {
a1: '20000000-0000-4000-8000-0000000000a1', a2: '20000000-0000-4000-8000-0000000000a2',
b1: '20000000-0000-4000-8000-0000000000b1', h1: '20000000-0000-4000-8000-0000000000c1',
};
const NEW = '20000000-0000-4000-8000-0000000000b9';
// Three organizations. `state.down` names the ones whose bus is stopped; `state.sent` and
// `state.created` record every chat write with the organization whose transport received it.
function orgs(state) {
const ORGS = [
{ id: 'alpha', label: 'Alpha', theme: 'rosewood' },
{ id: 'beta', label: 'Beta', theme: 'cobalt' },
{ id: 'home', label: 'Home', theme: 'grove' },
];
const PROJECTS = { alpha: [{ id: P.alpha, name: 'Spring launch' }], beta: [{ id: P.beta, name: 'API gateway' }], home: [{ id: P.home, name: 'Garden' }] };
const THREADS = {
alpha: [{ id: T.a1, project: P.alpha, name: 'Launch email', instance: 'assistant' }, { id: T.a2, project: P.alpha, name: 'Kickoff notes', instance: 'assistant' }],
beta: [{ id: T.b1, project: P.beta, name: 'Gateway auth', instance: 'assistant' }],
home: [{ id: T.h1, project: P.home, name: 'Seed order', instance: 'assistant' }],
};
const MESSAGES = { [T.a1]: 'Alpha draft is ready.', [T.a2]: 'Kickoff agenda.', [T.b1]: 'Beta tokens rotate nightly.', [T.h1]: 'Seeds arrive Monday.' };
const down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); };
return {
list: async () => ORGS.filter(o => !state.gone?.has(o.id)).map(o => ({ ...o, busHealthy: !state.down.has(o.id), ...(state.down.has(o.id) ? { busStopped: true } : {}) })),
resolve: async id => {
const org = ORGS.find(o => o.id === id); if (!org || state.gone?.has(id)) return null;
if (state.down.has(id)) return { busHealthy: false, reads: null, bus: {}, chat: { projects: down, threads: down, sessions: down, shares: down, messages: down } };
const threads = () => [...THREADS[id], ...state.created.filter(c => c.org === id).map(c => ({ id: c.id, project: c.project, name: c.name, instance: c.instance }))];
return {
busHealthy: true,
send: async () => ({}),
reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) },
bus: {
inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }),
instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {} } } }),
},
chat: {
projects: async () => PROJECTS[id],
threads: async ({ shared } = {}) => (shared ? [] : threads()),
sessions: async ({ thread }) => [{ id: thread, name: 'default', default: true }],
shares: async () => [],
messages: async ({ thread }) => [
...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []),
...state.sent.filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })),
...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })),
],
send: async ({ thread, body }) => {
const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` };
state.sent.push(sent);
return { id: sent.id };
},
createThread: async ({ project, name, instance }) => {
state.created.push({ org: id, project, name, instance, id: NEW });
return { id: NEW, instance };
},
},
};
},
};
}
async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] } } = {}) {
const organizations = orgs(state);
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const base = `http://127.0.0.1:${server.address().port}/`;
await b.navigate(base + hash);
const wait = (expression, tries = 200) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
return { b, base, wait, state };
}
const PANE = sel => `document.querySelector('[data-assist-pane="${sel}"]')`;
const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.dataset.assistPane)`;
const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`;
const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`;
const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`;
const SENT = '/Accepted message|Message shown in this conversation/';
const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/alpha/tasks' });
await wait(`!!document.querySelector('#sections a[href="#/assistant"]')`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#sections a')].map(a => a.textContent.trim())`).then(v => v.includes('Side by side')), false, 'no Side by side item');
await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`);
await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page');
await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`);
await pick(b, `alpha:${T.a1}`);
await pick(b, `beta:${T.b1}`);
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`]);
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`, `beta:${T.b1}`], 'both panes show at 1440');
const head = sel => b.evaluate(`(() => { const p = ${PANE(sel)}; return { title: p.querySelector('[data-assist-title]').textContent, org: p.querySelector('[data-ws-orgchip]').textContent, project: p.querySelector('[data-ws-projchip]')?.textContent ?? null, close: !!p.querySelector('button[data-assist-close]') }; })()`);
assert.deepEqual(await head(`alpha:${T.a1}`), { title: 'Launch email', org: 'Alpha', project: 'Spring launch', close: true });
assert.deepEqual(await head(`beta:${T.b1}`), { title: 'Gateway auth', org: 'Beta', project: 'API gateway', close: true });
assert.match(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Alpha draft is ready\./);
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Beta tokens/);
// One level-1 heading on the page; each pane's own form, field and ids are its own.
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view h1').length`), 1);
const ids = await b.evaluate(`[...document.querySelectorAll('#bus-view [id]')].map(e => e.id)`);
assert.equal(new Set(ids).size, ids.length, 'no duplicate ids across panes');
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view form textarea').length`), 2);
});
test('the open set and its order live in the address, so a reload or a pasted address restores the same panes', async t => {
const hash = `#/assistant?open=beta:${T.b1},alpha:${T.a2}`;
const { b, base, wait } = await open(t, { hash });
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)}`);
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`]);
await pick(b, `alpha:${T.a1}`);
await wait(`${ready(`alpha:${T.a1}`)}`);
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a2},alpha:${T.a1}`);
const address = await b.evaluate('location.hash');
await b.navigate('about:blank');
await b.navigate(base + address);
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)} && ${ready(`alpha:${T.a1}`)}`);
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`, `alpha:${T.a1}`]);
// A hand-edited address is checked before use: bad selectors, repeats and a fourth pane are dropped.
await b.navigate('about:blank');
await b.navigate(base + `#/assistant?open=alpha:${T.a1},alpha:${T.a1},nope,../x:${T.b1},beta:not-a-uuid,beta:${T.b1},alpha:${T.a2},home:${T.h1}`);
await wait(`document.querySelectorAll('[data-assist-pane]').length === 3`);
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
await wait(`${query} === 'alpha:${T.a1},beta:${T.b1},alpha:${T.a2}'`);
// With three open the picker offers no fourth.
assert.equal(await b.evaluate(`document.querySelector('[data-assist-open]').disabled`), true);
});
test('a send in organization B\'s pane reaches B\'s thread while organization A was opened last', async t => {
const state = { down: new Set(['home']), sent: [], created: [] };
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks', state });
await wait(`window.MosaicOrg?.id === 'beta'`);
await b.evaluate(`location.hash = '#/orgs/alpha/tasks'`);
await wait(`window.MosaicOrg?.id === 'alpha'`);
await b.evaluate(`location.hash = '#/assistant?open=beta:${T.b1},alpha:${T.a1}'`);
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a1}`)}`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'alpha', 'alpha is the organization opened last');
const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!${field}.disabled`);
await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`${SENT}.test(${PANE(`beta:${T.b1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message|Message shown in this conversation/, 'the other pane shows no receipt');
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft');
// A send in A's pane reaches A.
const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]);
// Live updates: a refresh shows the sent message in its own pane only.
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/rotate the beta key/.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /rotate the beta key/);
});
test('closing a pane updates the address and leaves the others', async t => {
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('button[data-assist-close]').click()`);
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
assert.equal(await b.evaluate(query), `beta:${T.b1}`);
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('button[data-assist-close]').click()`);
await wait(`document.querySelectorAll('[data-assist-pane]').length === 0`);
assert.equal(await b.evaluate(`location.hash`), '#/assistant');
assert.match(await b.evaluate(`document.getElementById('bus-view').innerText`), /No thread is open/);
});
test('a stopped organization is named at the top, its pane says so, and the other panes keep working', async t => {
const state = { down: new Set(['home']), sent: [], created: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},home:${T.h1},beta:${T.b1}`, state });
await wait(`!!document.querySelector('#bus-view [data-ws-unread]') && ${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
assert.match(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Home/);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Alpha|Beta/);
await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`);
assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home');
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false);
// Beta's bus stops while its pane is open, with an unsent message in it: that pane says so and
// only so, and holds the message; Alpha's still sends.
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!!${betaField} && !${betaField}.disabled`);
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
state.down.add('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`);
await wait(`!${betaField}`);
const betaBody = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
assert.match(betaBody, /Nothing is read for this thread while Beta's bus is stopped\. Your unsent message is kept/);
assert.doesNotMatch(betaBody, /Refresh to try again|Read again|Conversation unavailable/, 'no line that says a refresh would help');
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true, 'the held message counts as a draft');
const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
assert.equal(await b.evaluate(`${field}.disabled`), false);
await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']);
// The picker names the stopped organizations and offers none of their threads.
assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home · bus stopped/);
// Beta's bus starts again: its pane reads again and the held message is back in its composer.
state.down.delete('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`${ready(`beta:${T.b1}`)} && ${betaField}?.value === 'beta draft'`);
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself');
});
test('a pane whose organization leaves the list keeps its unsent message and does not promise it comes back', async t => {
const state = { down: new Set(['home']), gone: new Set(), sent: [], created: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!!${betaField} && !${betaField}.disabled`);
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
state.gone.add('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/not on your organization list/.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && !${betaField}`);
const body = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
assert.match(body, /Your unsent message is kept while this pane stays open\./);
assert.doesNotMatch(body, /comes back/, 'no promise that an organization off the list reads again');
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true);
assert.deepEqual(state.sent, []);
});
test('an open pane shows a new reply on its own, with no refresh', async t => {
const state = { down: new Set(['home']), sent: [], created: [], replies: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
state.replies.push({ org: 'beta', thread: T.b1, body: 'Rotation finished.' });
// The page polls every 10 s; wait up to 15 s without calling refresh.
await wait(`/Rotation finished\./.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`, 500);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /Rotation finished/);
});
test('the old Side by side address lands on the Assistant page', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/alpha/side-by-side?a=alpha/organization&b=beta/organization&w=50&show=a' });
await wait(`location.hash === '#/assistant' && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
assert.equal(await b.evaluate(`!!window.MosaicPanes`), false, 'the Side by side module is gone');
await b.evaluate(`location.hash = '#/side-by-side'`);
await wait(`location.hash === '#/assistant'`);
// The old per-organization Assistant address, the messages page, still opens.
await b.evaluate(`location.hash = '#/orgs/alpha/messages'`);
await wait(`document.querySelector('#bus-view h1')?.textContent === 'Messages'`);
});
test('at 390 one pane shows at a time with a tab strip to switch', async t => {
const { b, base, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`);
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]);
await b.evaluate(`document.querySelector('[data-assist-tabs] [data-assist-show="beta:${T.b1}"]').click()`);
await wait(`${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); // A pasted address names the shown pane however its id is cased.
await b.navigate('about:blank');
await b.navigate(base + `#/assistant?open=alpha:${T.a1},beta:${T.b1}&show=beta:${T.b1.toUpperCase()}`);
await wait(`${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
});
test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => {
const state = { down: new Set(['home']), sent: [], created: [] };
const { b, wait } = await open(t, { hash: '#/assistant', state });
await wait(`!!document.querySelector('[data-assist-open] option[value="new:beta"]')`);
await pick(b, 'new:beta');
await wait(`!!document.querySelector('[data-assist-new] select[name=project] option[value="${P.beta}"]') && !!document.querySelector('[data-assist-new] select[name=instance] option[value=assistant]')`);
await b.evaluate(`(() => { const f = document.querySelector('[data-assist-new]'); f.elements.name.value = 'Rate limits'; f.requestSubmit(); })()`);
await wait(`${ready(`beta:${NEW}`)} || !!${PANE(`beta:${NEW}`)}?.querySelector('form textarea')`);
assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]);
assert.equal(await b.evaluate(query), `beta:${NEW}`);
});