402 lines
32 KiB
JavaScript
402 lines
32 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 trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
|
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');
|
|
// SP7: the page name shows once, as the heading.
|
|
assert.equal(await b.evaluate(trail), null);
|
|
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}`);
|
|
});
|
|
|
|
// SP7: a thread link anywhere opens that thread as a pane here; a typed per-organization address
|
|
// still opens that organization's own chat view.
|
|
const follow = (b, href) => b.evaluate(`(() => { const a = document.createElement('a'); a.href = ${JSON.stringify(href)}; a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
|
const type = (b, sel, text) => b.evaluate(`(() => { const el = ${PANE(sel)}.querySelector('form textarea'); el.value = ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
|
const field = sel => `${PANE(sel)}?.querySelector('form textarea')`;
|
|
|
|
test('SP7: a thread link on a project page opens the thread as a pane on the Assistant page, added to the open set', async t => {
|
|
const { b, wait } = await open(t, { hash: `#/assistant?open=beta:${T.b1}` });
|
|
await wait(ready(`beta:${T.b1}`));
|
|
await b.evaluate(`location.hash = '#/orgs/alpha/projects/${P.alpha}'`);
|
|
await wait(`!!document.querySelector('#bus-view a[href$="/threads/${T.a1}"]')`);
|
|
await b.evaluate(`document.querySelector('#bus-view a[href$="/threads/${T.a1}"]').click()`);
|
|
await wait(`location.hash.startsWith('#/assistant') && ${ready(`alpha:${T.a1}`)}`);
|
|
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a1}`]);
|
|
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a1}`);
|
|
// A per-organization chat address typed or pasted still opens that organization's chat view.
|
|
await b.evaluate(`location.hash = '#/orgs/beta/projects/threads/${T.b1}'`);
|
|
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
|
assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`);
|
|
});
|
|
|
|
test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => {
|
|
const state = { down: 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}`)} && !${field(`alpha:${T.a1}`)}.disabled`);
|
|
await type(b, `alpha:${T.a1}`, 'alpha draft');
|
|
// On the Assistant page a thread link adds a pane in place: the typed message stays.
|
|
await follow(b, `#/orgs/alpha/projects/threads/${T.a2}`);
|
|
await wait(ready(`alpha:${T.a2}`));
|
|
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
|
|
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
|
|
// A fourth: Alpha's first pane holds a message, so Beta's pane, the first without one, gives way.
|
|
await follow(b, `#/orgs/home/projects/threads/${T.h1}`);
|
|
await wait(ready(`home:${T.h1}`));
|
|
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]);
|
|
assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`);
|
|
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
|
|
// All three hold a message: nothing is replaced and the page says why.
|
|
await wait(`!${field(`home:${T.h1}`)}.disabled && !${field(`alpha:${T.a2}`)}.disabled`);
|
|
await type(b, `home:${T.h1}`, 'home draft');
|
|
await type(b, `alpha:${T.a2}`, 'kickoff draft');
|
|
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
|
await wait(`/not opened/.test(document.querySelector('[data-assist-note]').textContent)`);
|
|
assert.match(await b.evaluate(`document.querySelector('[data-assist-note]').textContent`), /all 3 panes hold an unsent message/);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), true);
|
|
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]);
|
|
assert.deepEqual(await b.evaluate(`[${field(`alpha:${T.a1}`)}.value, ${field(`home:${T.h1}`)}.value, ${field(`alpha:${T.a2}`)}.value]`), ['alpha draft', 'home draft', 'kickoff draft']);
|
|
assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`);
|
|
// Closing a pane clears the reason; the thread then opens.
|
|
await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`);
|
|
await wait(`!/not opened/.test(document.querySelector('[data-assist-note]').textContent)`);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), false);
|
|
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
|
await wait(ready(`beta:${T.b1}`));
|
|
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]);
|
|
assert.deepEqual(state.sent, []);
|
|
});
|
|
|
|
test('SP7: each pane names its own organization\'s Messages page, and the bar no longer names the organization opened last', async t => {
|
|
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks' });
|
|
await wait(`window.MosaicOrg?.id === 'beta'`);
|
|
await b.evaluate(`location.hash = '#/assistant?open=alpha:${T.a1},beta:${T.b1}'`);
|
|
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
|
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-messages]'); return a && [a.getAttribute('href'), a.getAttribute('aria-label'), a.textContent.trim()]; })`),
|
|
[['#/orgs/alpha/messages', 'Messages in Alpha', 'Messages'], ['#/orgs/beta/messages', 'Messages in Beta', 'Messages']]);
|
|
assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/);
|
|
});
|
|
|
|
// SP7 review: a pane has no Request details, sessions or sharing, so each pane links its thread's full
|
|
// view, and a link marked data-full-view is not turned into a pane.
|
|
test('SP7: each pane links its thread\'s full view, and one plain click reaches it', 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}`)}`);
|
|
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-full]'); return a && [a.getAttribute('href'), a.textContent.trim(), a.hasAttribute('data-full-view')]; })`),
|
|
[[`#/orgs/alpha/projects/threads/${T.a1}`, 'Full view', true], [`#/orgs/beta/projects/threads/${T.b1}`, 'Full view', true]]);
|
|
// Each pane's Full view names its thread and organization, so a screen reader can tell three apart.
|
|
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane-full]')].map(a => a.getAttribute('aria-label'))`),
|
|
['Full view of Launch email, Alpha', 'Full view of Gateway auth, Beta']);
|
|
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('a[data-assist-pane-full]').click()`);
|
|
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
|
assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`);
|
|
});
|
|
|
|
test('SP7: a link marked as the full view stays in the organization\'s own view, from any page', async t => {
|
|
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1}` });
|
|
await wait(ready(`alpha:${T.a1}`));
|
|
// On the Assistant page itself: no pane is added, the browser follows the link.
|
|
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/beta/projects/threads/${T.b1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
|
await wait(`location.hash === '#/orgs/beta/projects/threads/${T.b1}'`);
|
|
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
|
// From another page: the same.
|
|
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/alpha/projects/threads/${T.a1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
|
await wait(`location.hash === '#/orgs/alpha/projects/threads/${T.a1}' && !document.querySelector('[data-assistant]')`);
|
|
});
|
|
|
|
// SP7 review shots: the title and the pane's tools (Full view, Messages, close) share the header's
|
|
// first row, the organization and project chips sit under it, and nothing spills out of the pane.
|
|
const headerRows = `[...document.querySelectorAll('[data-assist-pane]')].map(p => {
|
|
const h = p.querySelector('.assist-pane-h').getBoundingClientRect();
|
|
const r = s => p.querySelector(s).getBoundingClientRect();
|
|
const mid = q => Math.round(q.top + q.height / 2);
|
|
const row = ['[data-assist-title]', '[data-assist-pane-full]', '[data-assist-pane-messages]', '[data-assist-close]'].map(s => mid(r(s)));
|
|
const inside = [...p.querySelectorAll('.assist-pane-h > *, .assist-pane-h a, .assist-pane-h button')].every(e => { const q = e.getBoundingClientRect(); return q.left >= h.left - 1 && q.right <= h.right + 1; });
|
|
return { oneRow: Math.max(...row) - Math.min(...row) <= 4, chipsBelow: r('[data-assist-chips]').top >= r('[data-assist-title]').bottom - 1, inside };
|
|
})`;
|
|
// At 800 three panes are too narrow for one row: the tools wrap, and none of them leaves the pane.
|
|
const THREE = [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`];
|
|
for (const [width, height, keys, oneRow] of [[1440, 900, THREE, true], [800, 900, THREE, null], [761, 900, THREE, null], [390, 844, [`beta:${T.b1}`], true]]) {
|
|
test(`SP7: a pane header keeps its tools inside the pane${oneRow ? ', title and tools on one row with the chips below' : ''}, at ${width}`, async t => {
|
|
const { b, wait } = await open(t, { width, height, hash: `#/assistant?open=${keys.join(',')}` });
|
|
await wait(keys.map(k => ready(k)).join(' && '));
|
|
await wait(`[...document.querySelectorAll('[data-assist-pane] [data-ws-orgchip]')].length === ${keys.length}`);
|
|
const rows = await b.evaluate(headerRows);
|
|
assert.deepEqual(rows.map(r => (oneRow ? r : { ...r, oneRow: null })), keys.map(() => ({ oneRow, chipsBelow: true, inside: true })));
|
|
});
|
|
}
|