webui: Console chat area over the bus's project and thread verbs (row 129, #1623)
Adds the Console chat view: project and thread lists, named create forms,
per-thread drafts, explicit Resume, paging and signed-in sends. The one
HTTP-to-bus seam is packages/webui/src/chat.mjs, with fixed routes in
chat-http.mjs: exact HTTPS Origin, same-origin fetch site, JSON content
type, and 403 for each. Every label and message body renders inert,
including bidi controls. CSP unchanged, no inline script.
Owner Rocko. Reviewer Filbert: r1 changes (comment 28933), r2 approve
(comment 28950) on candidate manifest 9a665434...4034, with the four r1
survivors killed by new assertions. Mos decision 123 go on the public/
diff, sha 78574095 (mos-0780). Sage gate on 19fe85c3e: 25 steps, node
2216/0, scripts 422/0, webui 179/179.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -665,3 +665,29 @@ safe integer range are decimal strings in JSON. Settings has the same
|
||||
read-only Token usage section. Counts are a best reading, not proof: the
|
||||
session can edit its own transcript. This command changes no setting and
|
||||
performs no token-limit enforcement.
|
||||
|
||||
### Console Chat (row 129, #1623)
|
||||
|
||||
The Console's **Chat** section uses `#/chat`, `#/chat/projects/UUID` and
|
||||
`#/chat/threads/UUID`. Create and name a project and its threads, then send a
|
||||
message in that thread's conversation. Names are creation-only. The project
|
||||
catalog is business-wide; thread lists and histories belong to the current
|
||||
human. This is separate from the Board's seat reply route.
|
||||
|
||||
`GET/POST /api/chat/projects` lists/creates projects; `GET/POST /api/chat/threads`
|
||||
lists/creates threads (`project=UUID` optionally filters the GET).
|
||||
`GET /api/chat/threads/UUID/messages?limit=100&after=UUID` pages history forward;
|
||||
`POST` on that messages route sends `{body}` as a REQUEST.
|
||||
`POST /api/chat/threads/UUID/open` takes `{}` and explicitly resumes the assistant.
|
||||
GET never writes, opens a thread or marks messages delivered/read. No browser
|
||||
owner, tenant, proof or arbitrary bus verb is accepted. A same-business thread
|
||||
owned by someone else is 403 `thread-not-owned`; foreign/missing ids are 404.
|
||||
|
||||
The four chat POSTs use the existing signed-in `--send` boundary: named Host,
|
||||
one exact proxy user header, exact HTTPS Origin, same-origin Fetch Metadata,
|
||||
strict JSON and 16 KiB. Read-only mode disables them. Bodies are inert text;
|
||||
drafts are memory-only per thread and cleared on sign-out. History starts
|
||||
oldest-first in pages of 100, with a 200-message display window. Sending does
|
||||
not claim an assistant answer; an uncertain receipt is never retried. Read the
|
||||
current list/history before trying again. Activation still needs Mos's separate
|
||||
window; changing these files does not authorize a live Console action.
|
||||
|
||||
@@ -720,3 +720,36 @@ route response shapes, then the page against a temporary broker with
|
||||
a scheduled poll accelerated in the browser test. It proves literal
|
||||
content, late unsolicited arrival, pagination, full unread count and
|
||||
unchanged delivery receipts. No live bus data or service is used.
|
||||
|
||||
## Chat projects and threads (row 129, #1623)
|
||||
|
||||
Open **Chat** in the section navigation or command palette. The business project
|
||||
catalog is shared; each project's thread list includes only the current human's
|
||||
threads. Create a named project, optionally linked to a configured repository
|
||||
key, then create a named thread. Names are set at creation; rename is not shipped.
|
||||
No default project or thread is created implicitly. A thread has its own URL,
|
||||
draft and conversation. This is separate from the Board's seat conversations.
|
||||
|
||||
The routes are `#/chat`, `#/chat/projects/UUID` and `#/chat/threads/UUID`.
|
||||
The fixed `/api/chat/projects`, `/api/chat/threads` and
|
||||
`/api/chat/threads/UUID/messages` endpoints list/create projects, list/create
|
||||
threads and read/send messages. `POST /api/chat/threads/UUID/open` is the explicit
|
||||
**Resume assistant** action. Reading a thread never opens it or acknowledges a
|
||||
message. Sending uses `thread.send` with class REQUEST, as the human CLI does.
|
||||
|
||||
All four POST operations need `--send`, the named preview Host, the exact
|
||||
single authenticated user header, the Console's HTTPS Origin, same-origin Fetch
|
||||
Metadata and a strict JSON object within 16 KiB. The broker binds the business
|
||||
human and enforces ownership. A same-business thread owned by someone else
|
||||
returns 403 `thread-not-owned`; a missing/foreign thread returns 404. No browser
|
||||
owner, business, capability, arbitrary bus verb or filesystem path is accepted.
|
||||
Read-only mode disables creation, Resume and Send. Chat uses the existing human
|
||||
transport; a later channel-module conversion has its own brief (sage-0724).
|
||||
|
||||
History starts at the oldest message, reads forward 100 at a time and retains a
|
||||
bounded 200-message display window. **Read next messages** advances older
|
||||
history; a full page means more may exist. Once caught up, polling uses the last
|
||||
id and de-duplicates replies. An accepted receipt confirms the message only,
|
||||
not that the assistant has answered. An uncertain result never retries: read
|
||||
the list/history before explicitly trying again. Drafts are memory-only and
|
||||
thread-scoped; sign-out clears private chat state. Bodies are inert text.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// HTTP validation and write fence local to chat; existing Console routes keep their own fences.
|
||||
import { BusReadError, sendOutcome } from './bus.mjs';
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const KEY = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/;
|
||||
const id = value => typeof value === 'string' && UUID.test(value);
|
||||
const name = value => typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\r\n\0]/.test(value);
|
||||
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
||||
const json = (res, status, value) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
const STATUS = {
|
||||
'invalid-request': 400, 'unknown-repo': 400, 'message-not-found': 400,
|
||||
'project-not-found': 404, 'thread-not-found': 404, 'thread-not-owned': 403,
|
||||
'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
|
||||
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
|
||||
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
|
||||
};
|
||||
function safeMessage(code) {
|
||||
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
|
||||
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
|
||||
return `Chat operation refused (${code}).`;
|
||||
}
|
||||
async function payload(req) {
|
||||
if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error();
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > 16384) throw Error();
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error();
|
||||
return value;
|
||||
}
|
||||
function query(search, allowed) {
|
||||
const q = new URLSearchParams(search);
|
||||
if ([...q.keys()].some(k => !allowed.includes(k) || q.getAll(k).length !== 1)) throw Error();
|
||||
return q;
|
||||
}
|
||||
const exact = (value, allowed, required = allowed) =>
|
||||
Object.keys(value).every(k => allowed.includes(k)) && required.every(k => Object.hasOwn(value, k));
|
||||
export async function chatHTTP(req, res, { path, search, chat, named, send, preview, log }) {
|
||||
const write = req.method === 'POST';
|
||||
let action, args, thread;
|
||||
const refuse = (status, code, sent = false) => json(res, status, {
|
||||
error: code, message: safeMessage(code), ...(write ? { sent } : {}),
|
||||
});
|
||||
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
|
||||
if (write) {
|
||||
if (!send || !named || !preview) return refuse(403, 'read-only');
|
||||
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
try {
|
||||
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open)$/.exec(path);
|
||||
if (match) {
|
||||
thread = match[1];
|
||||
if (!id(thread)) throw Error();
|
||||
}
|
||||
if (write) {
|
||||
query(search, []);
|
||||
const v = await payload(req);
|
||||
if (path === '/api/chat/projects') {
|
||||
if (!exact(v, ['name', 'repo'], ['name']) || !name(v.name) ||
|
||||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
|
||||
action = 'createProject'; args = v;
|
||||
} else if (path === '/api/chat/threads') {
|
||||
if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
|
||||
action = 'createThread'; args = v;
|
||||
} else if (match?.[2] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
action = 'send'; args = { thread, body: v.body };
|
||||
} else if (match?.[2] === 'open') {
|
||||
if (!exact(v, [])) throw Error();
|
||||
action = 'open'; args = { thread };
|
||||
} else return refuse(404, 'not-found');
|
||||
} else if (path === '/api/chat/projects') {
|
||||
query(search, []); action = 'projects'; args = {};
|
||||
} else if (path === '/api/chat/threads') {
|
||||
const q = query(search, ['project']);
|
||||
if (q.has('project') && !id(q.get('project'))) throw Error();
|
||||
action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {};
|
||||
} else if (match?.[2] === 'messages') {
|
||||
const q = query(search, ['after', 'limit']);
|
||||
if (q.has('after') && !id(q.get('after'))) throw Error();
|
||||
if (q.has('limit') && (!/^[1-9]\d*$/.test(q.get('limit')) || Number(q.get('limit')) > 200)) throw Error();
|
||||
action = 'messages'; args = { thread, limit: q.has('limit') ? Number(q.get('limit')) : 100 };
|
||||
if (q.has('after')) args.after = q.get('after');
|
||||
} else return refuse(404, 'not-found');
|
||||
} catch { return refuse(400, 'invalid-request'); }
|
||||
if (!chat) return refuse(503, 'not-configured');
|
||||
const line = outcome => log(`${new Date().toISOString()} chat action=${action} user=${JSON.stringify(preview?.user ?? null)} ${outcome}`);
|
||||
try {
|
||||
const value = await chat[action](args);
|
||||
if (!write) {
|
||||
if (!Array.isArray(value)) return refuse(502, 'invalid-response');
|
||||
const page = action === 'messages' ? {
|
||||
after: value.at(-1)?.id ?? args.after ?? null, hasMore: value.length === args.limit,
|
||||
} : {};
|
||||
return json(res, 200, { rows: value, ...page, at: new Date().toISOString() });
|
||||
}
|
||||
if (!value || (action === 'open' ? value.opened !== thread : !id(value.id)) ||
|
||||
(action === 'createThread' && (typeof value.instance !== 'string' || !KEY.test(value.instance)))) {
|
||||
line('error=outcome-unknown');
|
||||
return refuse(503, 'outcome-unknown', 'unknown');
|
||||
}
|
||||
line(`id=${action === 'open' ? thread : value.id}`);
|
||||
// Only the fields of this operation's receipt are exposed.
|
||||
const receipt = action === 'open' ? { opened: thread } : { id: value.id };
|
||||
if (action === 'createThread') receipt.instance = value.instance;
|
||||
return json(res, 200, { ...receipt, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const outcome = write ? sendOutcome(err) : { code: err instanceof BusReadError ? err.code : 'invalid-response' };
|
||||
// Unrecognized transport text is never reflected. Known refusal codes have fixed messages.
|
||||
const code = Object.hasOwn(STATUS, outcome.code) ? outcome.code : 'invalid-response';
|
||||
if (write) line(`error=${code}`);
|
||||
return refuse(STATUS[code] ?? 502, code, outcome.sent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Row 129: the single seam between Console chat operations and the current human transport.
|
||||
// No browser-selected bus verb, tenant, owner, capability or message class crosses this seam.
|
||||
export function createChat({ call }) {
|
||||
if (typeof call !== 'function') throw new TypeError('chat needs a human transport');
|
||||
return Object.freeze({
|
||||
projects: () => call('project.list', {}),
|
||||
createProject: ({ name, repo }) => call('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => call('thread.list', project === undefined ? {} : { project }),
|
||||
createThread: ({ project, name }) => call('thread.create', { project, name }),
|
||||
messages: ({ thread, after, limit }) => call('thread.messages', { thread, ...(after ? { after } : {}), limit }),
|
||||
send: ({ thread, body }) => call('thread.send', { thread, body, class: 'REQUEST' }),
|
||||
open: ({ thread }) => call('thread.open', { thread }),
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busCaller } from './bus.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
@@ -51,11 +52,12 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, busLine, system = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
chat = createChat({ call });
|
||||
bus = views({ call });
|
||||
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
||||
projectFiles = args => call('project.files', args);
|
||||
@@ -68,7 +70,7 @@ try {
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat });
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`);
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null, messageCount = null;
|
||||
function sections(route) {
|
||||
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/chat', 'Chat', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
|
||||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
||||
@@ -771,7 +771,7 @@
|
||||
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
|
||||
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
|
||||
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
|
||||
function forget() { last.clear(); inboxCount = null; messageCount = null; }
|
||||
function forget() { window.MosaicChat?.forget(); last.clear(); inboxCount = null; messageCount = null; }
|
||||
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
|
||||
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||||
// Freshness line in the command bar for bus pages.
|
||||
@@ -840,6 +840,12 @@
|
||||
document.body.classList.toggle('on-bus', !board);
|
||||
$('bus-view').hidden = board;
|
||||
sections(path);
|
||||
window.MosaicChat?.leave(path);
|
||||
if (path === '/chat' || path.startsWith('/chat/')) {
|
||||
await window.MosaicChat.render({ path, container: $('bus-view'), canSend, navigated });
|
||||
if (g !== gen || out()) return;
|
||||
shown = route; schedule(); return;
|
||||
}
|
||||
if (board) {
|
||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||||
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
|
||||
@@ -933,7 +939,7 @@
|
||||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||||
// back where it was. It only navigates.
|
||||
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings']];
|
||||
const VIEWS = [['#/', 'Board'], ['#/chat', 'Chat'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings']];
|
||||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||||
function pkItems() {
|
||||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||||
@@ -1031,7 +1037,7 @@
|
||||
if (c?.send !== true) return;
|
||||
canSend = true;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
}).catch(() => {});
|
||||
window.addEventListener('hashchange', () => render(true));
|
||||
$('refresh').addEventListener('click', () => render(false));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/* Row 129: local chat styles, using the Console's existing palette and type. */
|
||||
.chat-area { max-width: 72rem; margin-inline: auto; }
|
||||
.chat-projects { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1rem; }
|
||||
.chat-thread-list { display: grid; gap: .65rem; padding-inline-start: 1.25rem; }
|
||||
.chat-thread-list li { overflow-wrap: anywhere; }
|
||||
.chat-create { display: grid; gap: .75rem; margin-block: 1.5rem; max-width: 34rem; }
|
||||
.chat-create label, #chat-send-form { display: grid; gap: .5rem; }
|
||||
.chat-create input, .chat-create select, #chat-body { width: 100%; min-width: 0; box-sizing: border-box; font: inherit; }
|
||||
.chat-create button, #chat-send-form button { justify-self: start; }
|
||||
.chat-conversation { min-width: 0; }
|
||||
.chat-log { list-style: none; padding: .75rem; margin-block: 1rem; max-height: 56vh; overflow: auto; overflow-anchor: none; border: 1px solid var(--border); border-radius: .5rem; }
|
||||
.chat-log li { padding-block: .8rem; border-bottom: 1px solid var(--border); }
|
||||
.chat-log li:last-child { border-bottom: 0; }
|
||||
.chat-message-meta { font-size: .85rem; opacity: .8; overflow-wrap: anywhere; }
|
||||
.chat-body { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: .4rem; unicode-bidi: plaintext; }
|
||||
#chat-body { resize: vertical; min-height: 6rem; padding: .75rem; }
|
||||
.chat-paging { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
#chat-receipt { min-height: 1.5em; overflow-wrap: anywhere; }
|
||||
@media (max-width: 40rem) { .chat-log { max-height: 48vh; padding: .5rem; } }
|
||||
@@ -0,0 +1,247 @@
|
||||
/* Row 129: business chat, isolated from the Board's seat conversations and role composer. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g,
|
||||
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
const txt = value => esc(inert(value));
|
||||
const states = new Map(), forms = new Map();
|
||||
let current = null, controller = null, generation = 0, signedOut = false;
|
||||
const out = () => signedOut || document.body.classList.contains('signed-out');
|
||||
const state = id => {
|
||||
if (!states.has(id)) states.set(id, { rows: [], after: null, more: false, loaded: false, draft: '', receipt: '', pending: false, follow: true, tailRequested: false, receiptId: null });
|
||||
return states.get(id);
|
||||
};
|
||||
const form = key => {
|
||||
if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
class ChatError extends Error {
|
||||
constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; }
|
||||
}
|
||||
function forget() {
|
||||
generation++; controller?.abort(); states.clear(); forms.clear();
|
||||
if (current) current.container.replaceChildren();
|
||||
current = null;
|
||||
}
|
||||
function signOut() {
|
||||
signedOut = true; forget();
|
||||
document.dispatchEvent(new Event('mosaic-signed-out'));
|
||||
}
|
||||
async function request(path, value, signal) {
|
||||
if (out()) throw new ChatError('signed-out');
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/api/chat/' + path, {
|
||||
cache: 'no-store', redirect: 'manual', signal,
|
||||
...(value === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw err;
|
||||
throw new ChatError(value === undefined ? 'unreachable' : 'outcome-unknown');
|
||||
}
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); throw new ChatError('signed-out'); }
|
||||
let data;
|
||||
try { data = await res.json(); }
|
||||
catch { throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown'); }
|
||||
if (!res.ok) throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent);
|
||||
if (!data || typeof data !== 'object' || (value === undefined && !Array.isArray(data.rows))) {
|
||||
throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const link = (type, id, label) => UUID.test(id) ? `<a href="#/chat/${type}/${id}">${txt(label)}</a>` : txt(label);
|
||||
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
|
||||
const stamp = at => `<time>${txt(at)}</time>`;
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
|
||||
}
|
||||
function threads(rows) {
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
|
||||
}
|
||||
function conversation(t) {
|
||||
const s = state(t.id);
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
|
||||
const receipt = s.receipt;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabled(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabled(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabled(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
}
|
||||
function capture() {
|
||||
if (!current) return null;
|
||||
const active = document.activeElement, log = current.container.querySelector('.chat-log');
|
||||
const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null;
|
||||
return { field, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true };
|
||||
}
|
||||
function paint(navigated = false) {
|
||||
if (!current || out()) return;
|
||||
const keep = capture(), c = current;
|
||||
let html = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
|
||||
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
|
||||
if (c.kind === 'thread') {
|
||||
const t = c.threads.find(t => t.id === c.id);
|
||||
if (t && !c.refused) html += conversation(t);
|
||||
else html += heading('Thread unavailable');
|
||||
} else if (c.kind === 'project') {
|
||||
const p = c.projects.find(p => p.id === c.id);
|
||||
html += heading(p?.name ?? 'Project unavailable');
|
||||
if (p && !c.refused) html += threads(c.threads) + creation('thread', c.id);
|
||||
} else {
|
||||
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
|
||||
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div>' + creation('project', 'projects');
|
||||
}
|
||||
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
c.container.innerHTML = html + '</div>';
|
||||
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||
const log = c.container.querySelector('.chat-log');
|
||||
if (log) {
|
||||
const s = state(c.id);
|
||||
if (navigated || s.tailRequested || s.follow && (keep?.tail ?? true)) log.scrollTop = log.scrollHeight;
|
||||
else if (keep?.top !== undefined) log.scrollTop = keep.top;
|
||||
s.tailRequested = false;
|
||||
}
|
||||
if (!navigated && keep?.field) {
|
||||
const f = document.getElementById(keep.field.id);
|
||||
if (f && !f.disabled) {
|
||||
f.focus({ preventScroll: true });
|
||||
if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
}
|
||||
function readError(err) {
|
||||
return err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
|
||||
err.code === 'thread-not-found' ? 'Thread not found.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
|
||||
}
|
||||
async function render({ path, container, canSend, navigated = false, next = false }) {
|
||||
if (out()) return;
|
||||
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+))?$/.exec(path);
|
||||
controller?.abort(); controller = new AbortController();
|
||||
const signal = controller.signal, g = ++generation;
|
||||
const kind = match?.[1] === 'projects' ? 'project' : match?.[1] === 'threads' ? 'thread' : 'list';
|
||||
const id = match?.[2];
|
||||
if (!match || (id && !UUID.test(id))) {
|
||||
current = { container, kind: 'list', canSend, projects: [], threads: [], repos: [], error: 'Invalid chat route.', refused: true };
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
const after = s?.loaded && (!s.more || next) ? s.after : null;
|
||||
const [projects, listed, page] = await Promise.all([
|
||||
request('projects', undefined, signal),
|
||||
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
|
||||
s ? request(`threads/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal) : null,
|
||||
]);
|
||||
if (g !== generation || out()) return;
|
||||
current.projects = projects.rows; current.threads = listed.rows;
|
||||
if (s) {
|
||||
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after))) throw new ChatError('invalid-response');
|
||||
const rows = after ? [...s.rows, ...page.rows] : page.rows;
|
||||
s.rows = [...new Map(rows.map(m => [m.id, m])).values()].slice(-200);
|
||||
s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
|
||||
if (s.receiptId && s.rows.some(m => m.id === s.receiptId)) {
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
}
|
||||
}
|
||||
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
|
||||
if (kind === 'list') {
|
||||
try {
|
||||
const r = await fetch('/api/business', { cache: 'no-store', redirect: 'manual', signal });
|
||||
if (r.type === 'opaqueredirect' || r.status === 401) { signOut(); return; }
|
||||
if (r.ok) {
|
||||
const business = await r.json();
|
||||
if (g !== generation || out()) return;
|
||||
current.repos = Array.isArray(business.projects) ? business.projects.filter(v => typeof v === 'string') : [];
|
||||
}
|
||||
} catch (err) { if (err.name === 'AbortError') return; }
|
||||
}
|
||||
if (g === generation) paint(navigated);
|
||||
} catch (err) {
|
||||
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
|
||||
current.error = readError(err);
|
||||
if (err.status === 403 || err.status === 404) {
|
||||
states.delete(id); current.projects = []; current.threads = []; current.refused = true;
|
||||
}
|
||||
paint(navigated);
|
||||
}
|
||||
}
|
||||
function refresh(next = false) {
|
||||
if (current) return render({ ...current, path: location.hash.slice(1).split('?')[0], next });
|
||||
}
|
||||
function leave(path) {
|
||||
if (!path.startsWith('/chat')) { generation++; controller?.abort(); current = null; }
|
||||
}
|
||||
document.addEventListener('input', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
const el = event.target;
|
||||
state(current.id).follow = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}
|
||||
}, true);
|
||||
async function write(action, key, value, path) {
|
||||
if (!current || !current.canSend || out()) return;
|
||||
const isThread = action === 'send' || action === 'open';
|
||||
const s = isThread ? state(key) : form(key);
|
||||
if (s.pending) return;
|
||||
if (action === 'send' && (!value.body.trim() || new TextEncoder().encode(JSON.stringify(value)).length > 16384)) {
|
||||
s.receipt = value.body.trim() ? 'Message is too long (16 KiB JSON maximum).' : 'Write a message first.';
|
||||
paint(); return;
|
||||
}
|
||||
const startedRoute = location.hash;
|
||||
s.pending = true; s.receipt = ''; paint();
|
||||
try {
|
||||
const result = await request(path, value);
|
||||
if (out()) return;
|
||||
if (action === 'open' ? result.opened !== key : !UUID.test(result.id)) throw new ChatError('outcome-unknown');
|
||||
if (action === 'send') {
|
||||
s.receipt = `Accepted message ${result.id}. Waiting for the conversation to show it.`;
|
||||
if (s.draft === value.body) s.draft = '';
|
||||
s.follow = true; s.tailRequested = true; s.receiptId = result.id;
|
||||
} else if (action === 'open') s.receipt = 'Assistant resume requested.';
|
||||
else {
|
||||
s.name = ''; s.repo = '';
|
||||
if (location.hash === startedRoute) location.hash = '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id;
|
||||
}
|
||||
} catch (err) {
|
||||
if (out() || err.code === 'signed-out') return;
|
||||
s.receipt = err.sent === false ? `Not sent (${inert(err.code)}). Your draft is kept.` :
|
||||
'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried; your draft is kept.';
|
||||
if (err.status === 403 && err.code === 'thread-not-owned') {
|
||||
states.delete(key);
|
||||
if (current?.id === key) { current.threads = []; current.refused = true; current.error = readError(err); }
|
||||
}
|
||||
} finally {
|
||||
s.pending = false;
|
||||
if (!out() && current && (isThread ? current.id === key : (key === 'projects' ? current.kind === 'list' : current.id === key))) paint();
|
||||
}
|
||||
}
|
||||
document.addEventListener('submit', event => {
|
||||
if (!current || !event.target.id.startsWith('chat-')) return;
|
||||
event.preventDefault();
|
||||
if (event.target.id === 'chat-send-form') {
|
||||
const id = current.id;
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-resume') void write('open', current.id, {}, `threads/${current.id}/open`);
|
||||
if (event.target.id === 'chat-next') void refresh(true);
|
||||
if (event.target.id === 'chat-new') { state(current.id).tailRequested = true; state(current.id).follow = true; paint(); }
|
||||
});
|
||||
document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); });
|
||||
window.MosaicChat = Object.freeze({ render, leave, forget });
|
||||
})();
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/shell.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs';
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -21,8 +22,8 @@ export function boardURL(value) {
|
||||
const root = resolve(import.meta.dirname, 'public');
|
||||
const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
|
||||
@@ -261,7 +262,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
const upstream = boardURL(board);
|
||||
@@ -310,6 +311,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview, log });
|
||||
}
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview, log });
|
||||
}
|
||||
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
|
||||
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||||
|
||||
@@ -18,7 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 4);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 5);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
// Project filtering does not hide another project's waiting requests.
|
||||
await b.evaluate('document.querySelector("[data-project=proj]").click()');
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Fixture-only browser coverage uses the repository's existing CDP test helper.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
const P = '10000000-0000-4000-8000-000000000001';
|
||||
const A = '20000000-0000-4000-8000-000000000002';
|
||||
const B = '30000000-0000-4000-8000-000000000003';
|
||||
const M = '40000000-0000-4000-8000-000000000004';
|
||||
const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
|
||||
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
|
||||
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
const path=String(url), method=init.method||'GET';
|
||||
if(path==='/api/config')return new Response(JSON.stringify({send:!location.search.includes('readonly')}));
|
||||
if(window.__busRefuse&&path.startsWith('/api/bus/'))return new Response(JSON.stringify({error:'human-required'}),{status:403});
|
||||
if(path.startsWith('/api/bus/'))return new Response(JSON.stringify({rows:[],unread:0,at:new Date().toISOString()}));
|
||||
if(path==='/api/business')return new Response(JSON.stringify({projects:['stack']}));
|
||||
if(!path.startsWith('/api/chat'))return real(url,init);
|
||||
const data=value=>new Response(JSON.stringify({...value,at:new Date().toISOString()}),{headers:{'content-type':'application/json'}});
|
||||
if(method==='POST'){
|
||||
window.__writes.push({path,body:JSON.parse(init.body),redirect:init.redirect});
|
||||
const a=window.__answers.shift();if(!a)throw new Error('No write answer');
|
||||
if(a.hold)await new Promise(r=>window.__releaseWrite=r);
|
||||
if(a.reject)throw new TypeError('network');
|
||||
if(a.redirect)return {type:'opaqueredirect',status:0,ok:false};
|
||||
return new Response(JSON.stringify(a.body),{status:a.status||200});
|
||||
}
|
||||
window.__reads.push(path);
|
||||
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
||||
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
|
||||
if(path==='/api/chat/projects')return data({rows:window.__projects});
|
||||
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
return data({rows:[],after:null,hasMore:false});
|
||||
};
|
||||
})();`;
|
||||
async function setup(t, suffix = '') {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
|
||||
const set = (selector, text) => b.evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(text)};el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
||||
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click(),true`);
|
||||
const route = async id => { await b.evaluate(`location.hash='#/chat/threads/${id}',true`); await wait(`document.querySelector('.chat-conversation')?.dataset.thread==='${id}'`); };
|
||||
await wait('document.querySelector(".chat-projects")');
|
||||
const refresh = async () => {
|
||||
await wait('!document.querySelector("#refresh").disabled');
|
||||
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
|
||||
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh');
|
||||
};
|
||||
return { b, wait, set, click, route, refresh };
|
||||
}
|
||||
test('chat browser: list/create named projects and threads, hostile labels inert and no implicit writes', async t => {
|
||||
const { b, wait, set, click } = await setup(t);
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-projects").textContent'), /\\u001b|␛/);
|
||||
const projectText = await b.evaluate('document.querySelector(".chat-projects").textContent');
|
||||
assert.doesNotMatch(projectText, /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
assert.match(projectText, /\\u202e/);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true);
|
||||
await b.key('k', 'KeyK', 2);
|
||||
await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/chat"]')`), true);
|
||||
await b.key('Escape');
|
||||
await set('#chat-project-name', 'new project');
|
||||
await set('#chat-repo', 'stack');
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${P}'}}),true`);
|
||||
await click('#chat-project-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-thread-form")');
|
||||
assert.deepEqual(await b.evaluate('window.__writes[0].body'), { name: 'new project', repo: 'stack' });
|
||||
assert.equal(await b.evaluate('window.__writes[0].path'), '/api/chat/projects');
|
||||
await set('#chat-thread-name', 'named thread');
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
||||
await click('#chat-thread-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-send-form")');
|
||||
assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
test('chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes', async t => {
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
await b.evaluate(`window.__pages.push({rows:[{id:'${M}',body:${JSON.stringify(HOSTILE)},from_role:'human',at:'2026-10-18T00:00:00Z'}],after:'${M}',hasMore:true}),true`);
|
||||
await route(A);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-log img")'), false);
|
||||
const messageText = await b.evaluate('document.querySelector(".chat-log").textContent');
|
||||
assert.doesNotMatch(messageText, /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
assert.match(messageText, /\\u202e/);
|
||||
await set('#chat-body', 'alpha draft');
|
||||
await b.evaluate('(()=>{const e=document.querySelector("#chat-body");e.focus();e.setSelectionRange(2,5);return true})()');
|
||||
await refresh();
|
||||
await wait('document.querySelector("#chat-body")?.value === "alpha draft" && !document.querySelector(".chat-loading")');
|
||||
assert.deepEqual(await b.evaluate('(()=>{const e=document.querySelector("#chat-body");return [document.activeElement===e,e.selectionStart,e.selectionEnd]})()'), [true,2,5]);
|
||||
await route(B); await set('#chat-body','beta draft'); await route(A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'alpha draft');
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
||||
await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt")?.textContent.includes("Accepted")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
||||
await route(B); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'beta draft');
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Accepted/);
|
||||
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.body)'), [{body:'alpha draft'}]);
|
||||
assert.equal(await b.evaluate('window.__writes[0].redirect'), 'manual');
|
||||
assert.equal(await b.evaluate('window.__writes[0].path'), `/api/chat/threads/${A}/messages`);
|
||||
await b.evaluate('window.__answers.push({body:{opened:location.hash.split("/").at(-1)}}),true');
|
||||
await click('#chat-resume'); await wait('document.querySelector("#chat-receipt")?.textContent.includes("requested")');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), `/api/chat/threads/${B}/open`);
|
||||
});
|
||||
test('chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send', async t => {
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
await route(A); await set('#chat-body', 'keep this');
|
||||
await b.evaluate('window.__answers.push({reject:true}),true'); await click('#chat-send-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-receipt")?.textContent.includes("unknown")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep this');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
||||
await b.evaluate('window.__readFailure="no-bus-host";true');
|
||||
await refresh();
|
||||
await wait('document.querySelector(".chat-error")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep this');
|
||||
await wait('!document.querySelector("#refresh").disabled');
|
||||
await b.evaluate('window.__readFailure="thread-not-owned";true');
|
||||
await refresh();
|
||||
await wait('document.querySelector(".chat-error")?.textContent.includes("not available")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-body")'), false);
|
||||
await wait('!document.querySelector("#refresh").disabled');
|
||||
await b.evaluate('window.__readFailure=null;true');
|
||||
await refresh();
|
||||
await wait('document.querySelector("#chat-body")'); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
||||
await b.navigate((await b.evaluate('location.href')).replace('/#/','/?readonly#/'));
|
||||
await wait('document.querySelector("#chat-body")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").disabled'), true);
|
||||
await set('#chat-body','read-only must not submit');
|
||||
await b.evaluate('document.querySelector("#chat-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
});
|
||||
|
||||
test('chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads', async t => {
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
await route(A); await set('#chat-body', 'private alpha');
|
||||
await b.evaluate(`window.__holdRead='${A}/messages';document.querySelector('#refresh').click();true`);
|
||||
await wait('typeof window.__releaseRead === "function"');
|
||||
await route(B); await set('#chat-body', 'private beta');
|
||||
assert.equal(await b.evaluate('window.__heldSignal.aborted'), true);
|
||||
await b.evaluate('(async()=>{window.__holdRead=null;window.__readFailure="thread-not-owned";window.__releaseRead();await new Promise(r=>setTimeout(r,100));window.__readFailure=null;return true})()');
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-error")'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.thread'), B);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'private beta');
|
||||
await b.evaluate('window.__answers.push({hold:true,redirect:true}),true');
|
||||
await click('#chat-send-form button[type=submit]');
|
||||
await wait('typeof window.__releaseWrite === "function"');
|
||||
await b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseWrite();true');
|
||||
await wait('document.body.classList.contains("signed-out")');
|
||||
const count = await b.evaluate('window.__reads.length');
|
||||
await b.evaluate('document.querySelector("#refresh").click();location.hash="#/chat";true');
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
assert.equal(await b.evaluate('window.__reads.length'), count);
|
||||
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /private alpha|private beta/);
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
||||
});
|
||||
|
||||
test('chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate', async t => {
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
const row = { id: M, body: 'first', from_role: 'human', at: '2026-10-18T00:00:00Z' };
|
||||
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[row],after:M,hasMore:true})}),true`);
|
||||
await route(A);
|
||||
const reply = { ...row, id: P, body: 'assistant reply', from_role: 'assistant' };
|
||||
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[row,reply],after:P,hasMore:false})}),true`);
|
||||
await click('#chat-next'); await wait('document.querySelector(".chat-log")?.textContent.includes("assistant reply")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".chat-log li").length'), 2);
|
||||
assert.ok((await b.evaluate('window.__reads')).some(x => x.includes('after=' + M)));
|
||||
await set('#chat-body', '界'.repeat(6000)); await click('#chat-send-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-receipt")?.textContent.includes("too long")');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
await set('#chat-body', 'one send');
|
||||
await b.evaluate(`window.__answers.push({hold:true,body:{id:'${P}'}}),true`);
|
||||
await click('#chat-send-form button[type=submit]'); await wait('typeof window.__releaseWrite === "function"');
|
||||
await b.evaluate('document.querySelector("#chat-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
||||
await b.evaluate('window.__releaseWrite();true');
|
||||
await wait('!document.querySelector("#chat-send-form button[type=submit]").disabled');
|
||||
});
|
||||
|
||||
test('chat browser: scrolled history stays put, New messages and an own send return to the tail', async t => {
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
const rows = Array.from({length:40}, (_,i)=>({id:`90000000-0000-4000-8000-${String(i).padStart(12,'0')}`,body:'history '+i+'\nsecond line',from_role:'assistant',at:'2026-10-18T00:00:00Z'}));
|
||||
await b.evaluate(`window.__pages.push(${JSON.stringify({rows,after:rows.at(-1).id,hasMore:false})}),true`);
|
||||
await route(A);
|
||||
await set('#chat-body','scrolled draft');
|
||||
await b.evaluate('(()=>{const l=document.querySelector(".chat-log");l.scrollTop=100;l.dispatchEvent(new Event("scroll"));return true})()');
|
||||
await refresh();
|
||||
await wait('!document.querySelector("#chat-new").hidden');
|
||||
assert.equal(await b.evaluate('document.querySelector(".chat-log").scrollTop'), 100);
|
||||
await click('#chat-new');
|
||||
assert.equal(await b.evaluate('(()=>{const l=document.querySelector(".chat-log");return l.scrollTop+l.clientHeight>=l.scrollHeight-2})()'), true);
|
||||
await b.evaluate('(()=>{const l=document.querySelector(".chat-log");l.scrollTop=100;l.dispatchEvent(new Event("scroll"));return true})()');
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
||||
await click('#chat-send-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")');
|
||||
assert.equal(await b.evaluate('(()=>{const l=document.querySelector(".chat-log");return l.scrollTop+l.clientHeight>=l.scrollHeight-2})()'), true);
|
||||
});
|
||||
|
||||
test('chat browser: automatic polling follows a reply without a POST or a lost draft', {timeout:20000}, async t => {
|
||||
const { b, wait, set, route } = await setup(t);
|
||||
await route(A); await set('#chat-body','draft during reply');
|
||||
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[{id:M,body:'automatic assistant reply',from_role:'assistant',at:'2026-10-18T00:00:00Z'}],after:M,hasMore:false})}),true`);
|
||||
await b.evaluate('(async()=>{for(let n=0;n<300;n++){if(document.querySelector(".chat-log").textContent.includes("automatic assistant reply"))return;await new Promise(r=>setTimeout(r,50));}throw new Error("poll did not follow reply")})()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'draft during reply');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
});
|
||||
|
||||
test('chat browser: display stays bounded while every forward page remains reachable', async t => {
|
||||
const { b, wait, click, route } = await setup(t);
|
||||
const rows = Array.from({length:300}, (_,i)=>({id:`91000000-0000-4000-8000-${String(i).padStart(12,'0')}`,body:'bounded message '+i,from_role:'assistant',at:'2026-10-18T00:00:00Z'}));
|
||||
const pages = [0,100,200].map(start=>({rows:rows.slice(start,start+100),after:rows[start+99].id,hasMore:start<200}));
|
||||
await b.evaluate(`window.__pages.push(...${JSON.stringify(pages)}),true`);
|
||||
await route(A);
|
||||
await click('#chat-next'); await wait('document.querySelector(".chat-log").textContent.includes("bounded message 199")');
|
||||
await click('#chat-next'); await wait('document.querySelector(".chat-log").textContent.includes("bounded message 299")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".chat-log li").length'), 200);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-log").textContent'), /bounded message 0\n/);
|
||||
});
|
||||
|
||||
test('chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown', async t => {
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
await route(A); await set('#chat-body','sent then visible');
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
||||
await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")');
|
||||
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[{id:M,body:'sent then visible',from_role:'human'}],after:M,hasMore:false})});true`);
|
||||
await refresh();
|
||||
await wait('document.querySelector("#chat-receipt").textContent.includes("shown")');
|
||||
await set('#chat-body','keep malformed');
|
||||
await b.evaluate('window.__answers.push({body:{}}),true');
|
||||
await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt").textContent.includes("unknown")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep malformed');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
|
||||
test('chat browser: a refusal elsewhere forgets private chat drafts', async t => {
|
||||
const { b, wait, set, route } = await setup(t);
|
||||
await route(A); await set('#chat-body','private draft before refusal');
|
||||
await b.evaluate('window.__busRefuse=true;location.hash="#/tasks";true');
|
||||
await wait('document.querySelector("#bus-view")?.textContent.includes("refused")');
|
||||
await b.evaluate('window.__busRefuse=false;true');
|
||||
await route(A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
||||
});
|
||||
|
||||
test('chat browser: a late project creation receipt cannot navigate away from another thread', async t => {
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
await set('#chat-project-name','late project');
|
||||
await b.evaluate(`window.__answers.push({hold:true,body:{id:'${P}'}}),true`);
|
||||
await click('#chat-project-form button[type=submit]');
|
||||
await wait('typeof window.__releaseWrite === "function"');
|
||||
await route(A); await set('#chat-body','stay in alpha');
|
||||
await b.evaluate('(async()=>{window.__releaseWrite();await new Promise(r=>setTimeout(r,100));return true})()');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { createChat } from '../src/chat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const P = '10000000-0000-4000-8000-000000000001';
|
||||
const T = '20000000-0000-4000-8000-000000000002';
|
||||
const M = '30000000-0000-4000-8000-000000000003';
|
||||
const preview = { host: 'chat.example.com', header: 'remote-user', user: 'jason' };
|
||||
const headers = { 'remote-user': 'jason', origin: 'https://chat.example.com', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const posts = [
|
||||
['/api/chat/projects', { name: 'work', repo: 'stack' }, 'project.create', { name: 'work', repo: 'stack' }, { id: P }],
|
||||
['/api/chat/threads', { project: P, name: 'discussion' }, 'thread.create', { project: P, name: 'discussion' }, { id: T, instance: 'assistant' }],
|
||||
[`/api/chat/threads/${T}/messages`, { body: ' hi\nthere ' }, 'thread.send', { thread: T, body: ' hi\nthere ', class: 'REQUEST' }, { id: M }],
|
||||
[`/api/chat/threads/${T}/open`, {}, 'thread.open', { thread: T }, { opened: T }],
|
||||
];
|
||||
function http(port, path, { method = 'GET', value, raw, extra = {}, host = preview.host } = {}) {
|
||||
const data = raw ?? (value === undefined ? undefined : JSON.stringify(value));
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: {
|
||||
host, ...headers, ...(data === undefined ? {} : { 'content-length': Buffer.byteLength(data) }), ...extra,
|
||||
} }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => { text += c; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, data: JSON.parse(text), cache: res.headers['cache-control'] }));
|
||||
});
|
||||
req.on('error', reject); req.end(data);
|
||||
});
|
||||
}
|
||||
async function setup(t, options = {}) {
|
||||
const calls = [], logs = [];
|
||||
const state = { answer: [] };
|
||||
const chat = createChat({ call: async (verb, args) => {
|
||||
calls.push([verb, args]);
|
||||
if (state.answer instanceof Error) throw state.answer;
|
||||
return typeof state.answer === 'function' ? state.answer(verb, args) : state.answer;
|
||||
} });
|
||||
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: l => logs.push(l), ...options });
|
||||
t.after(() => close(server));
|
||||
return { calls, logs, state, get: (path, opt) => http(server.address().port, path, opt), port: server.address().port };
|
||||
}
|
||||
|
||||
test('chat reads map only fixed verbs, strict forward paging and pure GET', async t => {
|
||||
const c = await setup(t);
|
||||
for (const [path, verb, args] of [
|
||||
['/api/chat/projects', 'project.list', {}],
|
||||
['/api/chat/threads', 'thread.list', {}],
|
||||
[`/api/chat/threads?project=${P}`, 'thread.list', { project: P }],
|
||||
[`/api/chat/threads/${T}/messages`, 'thread.messages', { thread: T, limit: 100 }],
|
||||
[`/api/chat/threads/${T}/messages?after=${M}&limit=2`, 'thread.messages', { thread: T, after: M, limit: 2 }],
|
||||
]) {
|
||||
const result = await c.get(path);
|
||||
assert.equal(result.status, 200); assert.deepEqual(result.data.rows, []); assert.equal(result.cache, 'no-store');
|
||||
assert.deepEqual(c.calls.at(-1), [verb, args]);
|
||||
}
|
||||
c.state.answer = [{ id: M }, { id: T }];
|
||||
const page = await c.get(`/api/chat/threads/${T}/messages?limit=2`);
|
||||
assert.equal(page.data.after, T); assert.equal(page.data.hasMore, true);
|
||||
assert.ok(c.calls.every(([v]) => ['project.list', 'thread.list', 'thread.messages'].includes(v)));
|
||||
});
|
||||
|
||||
test('chat refuses malformed and repeated query/id values before transport', async t => {
|
||||
const c = await setup(t);
|
||||
for (const path of [
|
||||
'/api/chat/projects?owner=jason', '/api/chat/threads?project=no',
|
||||
`/api/chat/threads?project=${P}&project=${P}`, `/api/chat/threads/${T}/messages?limit=0`,
|
||||
`/api/chat/threads/${T}/messages?limit=201`, `/api/chat/threads/${T}/messages?limit=1e2`,
|
||||
`/api/chat/threads/${T}/messages?limit=1&limit=2`, `/api/chat/threads/${T}/messages?after=no`,
|
||||
`/api/chat/threads/${T}/messages?owner=jason`, '/api/chat/threads/not-a-uuid/messages',
|
||||
]) assert.equal((await c.get(path)).status, 400, path);
|
||||
assert.deepEqual(c.calls, []);
|
||||
});
|
||||
|
||||
for (const [path, value, verb, args, answer] of posts) {
|
||||
test(`chat ${verb}: one verified write, fixed identity/class and private log`, async t => {
|
||||
const c = await setup(t); c.state.answer = answer;
|
||||
const result = await c.get(path, { method: 'POST', value });
|
||||
assert.equal(result.status, 200); assert.deepEqual(c.calls, [[verb, args]]);
|
||||
for (const [key, v] of Object.entries(answer)) assert.equal(result.data[key], v);
|
||||
assert.equal(result.cache, 'no-store'); assert.equal(c.logs.length, 1);
|
||||
assert.doesNotMatch(c.logs[0], /discussion|there|stack|capability/);
|
||||
});
|
||||
test(`chat ${verb}: each existing Console write fence makes zero bus calls`, async t => {
|
||||
const c = await setup(t);
|
||||
for (const extra of [
|
||||
{ 'remote-user': '' }, { 'remote-user': 'other' }, { 'remote-user': ['jason', 'jason'] },
|
||||
{ origin: '' }, { origin: 'https://evil.example' }, { origin: ['https://chat.example.com', 'https://chat.example.com'] },
|
||||
{ 'sec-fetch-site': '' }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': ['same-origin', 'same-origin'] },
|
||||
{ 'content-type': 'text/plain' },
|
||||
]) assert.ok((await c.get(path, { method: 'POST', value, extra })).status >= 400);
|
||||
for (const raw of ['[]', 'null', '{', JSON.stringify({ ...value, owner: 'other' }), JSON.stringify({ body: 'x'.repeat(17000) })]) {
|
||||
assert.equal((await c.get(path, { method: 'POST', raw })).status, 400);
|
||||
}
|
||||
assert.equal((await c.get(path + '?x=1', { method: 'POST', value })).status, 400);
|
||||
assert.deepEqual(c.calls, []);
|
||||
const ro = await setup(t, { send: null });
|
||||
assert.equal((await ro.get(path, { method: 'POST', value })).status, 403); assert.deepEqual(ro.calls, []);
|
||||
assert.equal((await c.get(path, { method: 'POST', value, host: `127.0.0.1:${c.port}`, extra: { 'remote-user': '', origin: '' } })).status, 403);
|
||||
assert.deepEqual(c.calls, []);
|
||||
});
|
||||
test(`chat ${verb}: refusal or lost/malformed receipt never retries or invents success`, async t => {
|
||||
const c = await setup(t);
|
||||
for (const [answer_, status, error, sent] of [
|
||||
[new BusReadError('thread-not-owned', 'SENTINEL private'), 403, 'thread-not-owned', false],
|
||||
[new BusReadError('thread-not-found'), 404, 'thread-not-found', false],
|
||||
[new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'],
|
||||
[new Error('SENTINEL'), 503, 'outcome-unknown', 'unknown'],
|
||||
[{}, 503, 'outcome-unknown', 'unknown'],
|
||||
]) {
|
||||
c.state.answer = answer_; const before = c.calls.length;
|
||||
const result = await c.get(path, { method: 'POST', value });
|
||||
assert.equal(result.status, status); assert.equal(result.data.error, error); assert.equal(result.data.sent, sent);
|
||||
assert.doesNotMatch(JSON.stringify(result.data), /SENTINEL/); assert.equal(c.calls.length, before + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('chat createThread receipt requires an instance even when its id is valid', async t => {
|
||||
const c = await setup(t);
|
||||
for (const answer of [{ id: T }, { id: T, instance: 'invalid/instance' }, { id: T, instance: 3 }]) {
|
||||
c.state.answer = answer;
|
||||
const before = c.calls.length;
|
||||
const result = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'discussion' } });
|
||||
assert.equal(result.status, 503);
|
||||
assert.equal(result.data.error, 'outcome-unknown');
|
||||
assert.equal(result.data.sent, 'unknown');
|
||||
assert.equal(c.calls.length, before + 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat write receipts expose only the fields belonging to their operation', async t => {
|
||||
for (const [path, value, , , answer] of posts) {
|
||||
const c = await setup(t);
|
||||
c.state.answer = { owner: 'private owner', capability: 'not-a-capability', body: 'private body', ...answer };
|
||||
const result = await c.get(path, { method: 'POST', value });
|
||||
assert.equal(result.status, 200);
|
||||
const { at, ...receipt } = result.data;
|
||||
assert.equal(typeof at, 'string');
|
||||
assert.deepEqual(receipt, answer);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat validates names, body, project and strict empty open payload', async t => {
|
||||
const c = await setup(t);
|
||||
for (const [path, value] of [
|
||||
['/api/chat/projects', { name: '' }], ['/api/chat/projects', { name: 'x'.repeat(201) }],
|
||||
['/api/chat/projects', { name: 'a\nb' }], ['/api/chat/projects', { name: 'a\0b' }],
|
||||
['/api/chat/projects', { name: 'x', repo: '/private/path' }],
|
||||
['/api/chat/threads', { name: 'x', project: 'bad' }], ['/api/chat/threads', { project: P }],
|
||||
[`/api/chat/threads/${T}/messages`, { body: '' }], [`/api/chat/threads/${T}/messages`, { body: ' ' }],
|
||||
[`/api/chat/threads/${T}/messages`, { body: 'x', class: 'INFO' }],
|
||||
[`/api/chat/threads/${T}/open`, { thread: T }],
|
||||
]) assert.equal((await c.get(path, { method: 'POST', value })).status, 400);
|
||||
assert.deepEqual(c.calls, []);
|
||||
});
|
||||
|
||||
test('chat read failures redact ownership and refuse invalid transport arrays', async t => {
|
||||
const c = await setup(t);
|
||||
for (const [answer, status, error] of [
|
||||
[new BusReadError('thread-not-owned', 'private-name'), 403, 'thread-not-owned'],
|
||||
[new BusReadError('thread-not-found'), 404, 'thread-not-found'],
|
||||
[new BusReadError('human-required'), 403, 'human-required'],
|
||||
[new BusReadError('no-bus-host'), 503, 'no-bus-host'], [{ rows: [] }, 502, 'invalid-response'],
|
||||
]) {
|
||||
c.state.answer = answer; const r = await c.get(`/api/chat/threads/${T}/messages`);
|
||||
assert.equal(r.status, status); assert.equal(r.data.error, error); assert.doesNotMatch(JSON.stringify(r.data), /private-name/);
|
||||
}
|
||||
c.state.answer = { rows: [] };
|
||||
for (const path of ['/api/chat/projects', '/api/chat/threads']) {
|
||||
const result = await c.get(path);
|
||||
assert.equal(result.status, 502); assert.equal(result.data.error, 'invalid-response');
|
||||
}
|
||||
});
|
||||
|
||||
test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations', async t => {
|
||||
const { Store } = await import('../../bus/src/store.mjs');
|
||||
const { Broker } = await import('../../bus/src/broker.mjs');
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const root = mkdtempSync(join(tmpdir(), 'chat-broker-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const roles = { assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles, definitions: { assistant: {} }, projects: { stack: {} } } };
|
||||
let b = new Broker({ store, businesses });
|
||||
let cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const calls = [];
|
||||
const chat = createChat({ call: async (verb, args) => {
|
||||
calls.push(verb);
|
||||
try { return structuredClone(await b.request(cap, { verb, args })); }
|
||||
catch (err) { throw new BusReadError(err.code); }
|
||||
} });
|
||||
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const get = (p, opt) => http(server.address().port, p, opt);
|
||||
const write = (p, value) => get(p, { method: 'POST', value });
|
||||
const project = (await write('/api/chat/projects', { name: 'Project one', repo: 'stack' })).data.id;
|
||||
assert.match(project, /^[a-f0-9-]{36}$/);
|
||||
assert.equal((await write('/api/chat/projects', { name: 'Project one' })).status, 409);
|
||||
assert.equal((await write('/api/chat/projects', { name: 'bad repo', repo: 'unknown' })).status, 400);
|
||||
const a = (await write('/api/chat/threads', { project, name: 'alpha' })).data.id;
|
||||
const bb = (await write('/api/chat/threads', { project, name: 'beta' })).data.id;
|
||||
assert.equal((await write('/api/chat/threads', { project, name: 'alpha' })).status, 409);
|
||||
assert.deepEqual((await get(`/api/chat/threads?project=${project}`)).data.rows.map(x => x.id), [a, bb]);
|
||||
assert.deepEqual((await get(`/api/chat/threads?project=${P}`)).data.rows, []);
|
||||
const other = '40000000-0000-4000-8000-000000000004';
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
|
||||
other, new Date().toISOString(), 'demo', project, 'assistant', 'someone-else', 'PRIVATE-NAME');
|
||||
store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
|
||||
M, new Date().toISOString(), 'demo', 'human', 'human-cli', 'assistant', 'INFO', 'PRIVATE-BODY');
|
||||
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', M, other);
|
||||
const foreignProject = '50000000-0000-4000-8000-000000000005';
|
||||
const foreignThread = '60000000-0000-4000-8000-000000000006';
|
||||
store.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)',
|
||||
foreignProject, new Date().toISOString(), 'foreign', 'FOREIGN-PROJECT', null, 'jason');
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
|
||||
foreignThread, new Date().toISOString(), 'foreign', foreignProject, 'assistant', 'jason', 'FOREIGN-THREAD');
|
||||
assert.equal((await get('/api/chat/threads')).data.rows.length, 2);
|
||||
const before = ['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n);
|
||||
for (const id of [other, T, foreignThread]) for (const suffix of ['messages', 'open']) {
|
||||
const methods = suffix === 'messages' ? ['GET', 'POST'] : ['POST'];
|
||||
for (const method of methods) {
|
||||
const r = await get(`/api/chat/threads/${id}/${suffix}`, { method, value: method === 'GET' ? undefined : suffix === 'open' ? {} : { body: 'not allowed' } });
|
||||
assert.equal(r.status, id === other ? 403 : 404);
|
||||
assert.equal(r.data.error, id === other ? 'thread-not-owned' : 'thread-not-found');
|
||||
assert.doesNotMatch(JSON.stringify(r.data), /PRIVATE|FOREIGN|someone-else/);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n), before);
|
||||
const one = (await write(`/api/chat/threads/${a}/messages`, { body: 'only alpha' })).data.id;
|
||||
const two = (await write(`/api/chat/threads/${a}/messages`, { body: 'second alpha' })).data.id;
|
||||
await write(`/api/chat/threads/${bb}/messages`, { body: 'only beta' });
|
||||
const opens = store.get('SELECT count(*) n FROM thread_opens').n;
|
||||
const deliveries = store.get('SELECT count(*) n FROM deliveries').n;
|
||||
assert.deepEqual((await get(`/api/chat/threads/${a}/messages?limit=1`)).data.rows.map(x => x.id), [one]);
|
||||
assert.deepEqual((await get(`/api/chat/threads/${a}/messages?limit=1&after=${one}`)).data.rows.map(x => x.id), [two]);
|
||||
assert.equal((await get(`/api/chat/threads/${bb}/messages?after=${one}`)).status, 400);
|
||||
assert.equal(store.get('SELECT count(*) n FROM deliveries').n, deliveries);
|
||||
assert.equal(store.get('SELECT count(*) n FROM thread_opens').n, opens);
|
||||
assert.equal((await write(`/api/chat/threads/${a}/open`, {})).data.opened, a);
|
||||
assert.equal(store.get('SELECT count(*) n FROM thread_opens').n, opens + 1);
|
||||
for (const [roles_, code] of [[{ chief: roles.chief }, 'thread-instance-missing'], [{ ...roles, second: roles.assistant }, 'thread-instance-ambiguous']]) {
|
||||
b = new Broker({ store, businesses: { demo: { ...businesses.demo, roles: roles_ } } });
|
||||
cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.equal((await write('/api/chat/threads', { project, name: code })).data.error, code);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat repeats every POST boundary even when called behind an admitted request', async () => {
|
||||
const { Readable } = await import('node:stream');
|
||||
const { chatHTTP } = await import('../src/chat-http.mjs');
|
||||
for (const [path, value] of posts) for (const fence of ['send', 'named', 'identity', 'origin', 'site']) {
|
||||
const req = Readable.from([Buffer.from(JSON.stringify(value))]);
|
||||
req.method = 'POST'; req.headers = { ...headers };
|
||||
req.headersDistinct = Object.fromEntries(Object.entries(headers).map(([k,v]) => [k,[v]]));
|
||||
if (fence === 'identity') req.headersDistinct['remote-user'] = ['jason', 'jason'];
|
||||
if (fence === 'origin') req.headersDistinct.origin = [];
|
||||
if (fence === 'site') req.headersDistinct['sec-fetch-site'] = [];
|
||||
let status, result, calls = 0;
|
||||
const chat = createChat({ call: async () => { calls++; return { id: M }; } });
|
||||
const res = { writeHead(code) { status = code; }, end(bytes) { result = JSON.parse(bytes); } };
|
||||
await chatHTTP(req, res, { path, search: '', chat, named: fence !== 'named',
|
||||
send: fence === 'send' ? null : async () => ({}), preview, log: () => {} });
|
||||
assert.equal(status, fence === 'identity' ? 401 : 403, path + ' ' + fence);
|
||||
assert.equal(result.sent, false); assert.equal(calls, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured Console process injects chat through its existing human transport', { timeout: 15000 }, async t => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { tmp, fixture } = await import('../../cli/tests/helpers.mjs');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
|
||||
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
|
||||
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(async () => {
|
||||
if (child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
|
||||
});
|
||||
let output = '', errors = '';
|
||||
child.stderr.on('data', c => { errors += c; });
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
child.stdout.on('data', c => { output += c; const m = output.match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//); if(m) resolve(Number(m[1])); });
|
||||
child.once('exit', code => reject(Error(`Console fixture exited ${code}: ${errors}`)));
|
||||
});
|
||||
// With a private fixture config but no fixture host, reaching the configured human
|
||||
// transport gives no-bus-host; missing adapter injection would give not-configured.
|
||||
for (const [path, options] of [['/api/chat/projects', {}], ['/api/chat/projects', { method:'POST', value:{name:'fixture'} }]]) {
|
||||
const r = await http(port, path, options);
|
||||
assert.equal(r.status, 503); assert.equal(r.data.error, 'no-bus-host');
|
||||
}
|
||||
});
|
||||
|
||||
test('chat assets, GET purity, method and unknown-route refusals are explicit', async t => {
|
||||
const c = await setup(t);
|
||||
for (const name of ['chat.js', 'chat.css']) {
|
||||
const result = await fetch(`http://127.0.0.1:${c.port}/${name}`);
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.headers.get('content-type'), name.endsWith('.js') ? /javascript/ : /css/);
|
||||
}
|
||||
assert.equal((await c.get(`/api/chat/threads/${T}/open`)).status, 404);
|
||||
assert.equal((await c.get('/api/chat/private')).status, 404);
|
||||
assert.equal((await c.get('/api/chat/projects', { method:'DELETE' })).status, 405);
|
||||
assert.deepEqual(c.calls, []);
|
||||
});
|
||||
@@ -263,7 +263,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
|
||||
}
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 9);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 10);
|
||||
assert.deepEqual(await b.evaluate(`(() => { const a = document.querySelector('#sections a[href="#/chat"]'); return [a?.textContent.trim(), a?.querySelector('svg use')?.getAttribute('href')]; })()`), ['Chat', '/icons.svg#i-inbox']);
|
||||
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
|
||||
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
|
||||
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
|
||||
@@ -459,7 +460,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
||||
}
|
||||
|
||||
// After a refusal the palette lists only the views: no kept decision or task.
|
||||
const views = ['#/', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings'];
|
||||
const views = ['#/', '#/chat', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings'];
|
||||
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
|
||||
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
|
||||
|
||||
@@ -56,7 +56,7 @@ async function page(b, base) {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
|
||||
await b.viewport(400, 900);
|
||||
await b.navigate(base);
|
||||
await wait('document.querySelectorAll("#sections a").length===9');
|
||||
await wait(`document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/chat"]')?.textContent.trim()==='Chat'`);
|
||||
return { wait, text, count, flat, refresh, go };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user