In organization mode #/ is the cross-organization dashboard (sample 02), #/projects lists every organization's projects with sort, filters and the shared By org / Flat grouping (sample 04), and #/inbox lists decisions across organizations with per-organization decide paths and terminal commands carrying --org and the full id (sample 05). The per-organization board moves to #/board and the organization's own project list to #/chat, which keeps the project surface. Reads go through workspace-data.js; a read an organization could not make says not read, never zero. Polling repaints in place and keeps focus, caret and scroll.
142 lines
10 KiB
JavaScript
142 lines
10 KiB
JavaScript
// Walkthrough defect 1 (Jason, 7350 Quick Setup): an organization conversation showed "Request status unavailable."
|
|
// although the message went through. The organization options built in cli.mjs carried no request read, so
|
|
// /api/orgs/<id>/config never offered requests and the project page counted that as a failed read.
|
|
// This runs the real cli.mjs serve --dev-local-owner against a real organization record. Only the bus
|
|
// transports cli.mjs builds are replaced (loader hook), so no bus or human proof is involved.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
|
|
import { createOrg } from '../../organization/src/org-service.mjs';
|
|
import { orgPaths } from '../../cli/src/config.mjs';
|
|
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
|
|
import { browser } from './browser.mjs';
|
|
|
|
const P = '10000000-0000-4000-8000-0000000000a1', T = '20000000-0000-4000-8000-0000000000a1', S = '30000000-0000-4000-8000-0000000000a1';
|
|
// The correlation id a sent message carries. The org's request intake has no request under it.
|
|
const REQUEST = 'c0ffee00-0000-4000-8000-0000000000a1';
|
|
|
|
async function consoleFor(t) {
|
|
const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test');
|
|
mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir);
|
|
chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700);
|
|
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
|
|
const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack';
|
|
const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc);
|
|
const log = join(root, 'calls.jsonl'); writeFileSync(log, '');
|
|
const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href;
|
|
// Each transport answers from fixed rows and logs (kind, business, socket, verb). Everything else in bus.mjs is real.
|
|
const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs');
|
|
writeFileSync(busShim, `import { appendFileSync } from 'node:fs';
|
|
import { BusReadError } from ${JSON.stringify(busURL)};
|
|
export * from ${JSON.stringify(busURL)};
|
|
const P=${JSON.stringify(P)}, T=${JSON.stringify(T)}, S=${JSON.stringify(S)}, REQUEST=${JSON.stringify(REQUEST)};
|
|
// The bus answers list verbs with bare arrays; the Console wraps them in rows.
|
|
const human = (verb, args) => ({
|
|
plugins: { plugins: { channel: ['cli', 'webui'] } },
|
|
'project.list': [{ id: P, name: 'work', repo: null }],
|
|
'thread.list': args?.shared ? [] : [{ id: T, project: P, name: 'init', instance: 'assistant' }],
|
|
'session.list': [{ id: S, name: 'Main', default: true, state: 'active', revision: 1 }],
|
|
'thread.messages': [
|
|
{ id: '40000000-0000-4000-8000-0000000000a1', from_role: 'human', body: 'hello world!', request: REQUEST, at: '2026-11-08T10:00:00Z' },
|
|
{ id: '40000000-0000-4000-8000-0000000000a2', from_role: 'assistant', body: 'PINEAPPLE', at: '2026-11-08T10:00:05Z' },
|
|
],
|
|
}[verb] ?? []);
|
|
const record = (kind, o, verb, args) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args }) + '\\n');
|
|
export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return human(verb, args); };
|
|
export const consoleCall = o => async (verb, args) => {
|
|
record('console', o, verb, args);
|
|
if (verb === 'request.get.console') throw new BusReadError('request-not-found', 'no request under this id');
|
|
throw new BusReadError('unavailable', 'fixture: ' + verb);
|
|
};
|
|
`);
|
|
writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`);
|
|
writeFileSync(hook, `import { registerHooks } from 'node:module';
|
|
registerHooks({ resolve(specifier, context, next) {
|
|
if (context.parentURL?.endsWith('/webui/src/cli.mjs')) {
|
|
if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true };
|
|
if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true };
|
|
}
|
|
return next(specifier, context);
|
|
} });
|
|
`);
|
|
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` };
|
|
const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--dev-local-owner'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c);
|
|
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
|
|
for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) {
|
|
if (child.exitCode !== null) break;
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.exec(out)?.[1];
|
|
assert.ok(base, `the Console did not start:\n${out}`);
|
|
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
// A failed wait reports the Console output and the last transport calls with the page text.
|
|
const diagnostics = () => `Console output:\n${out.slice(-1500)}\nLast calls:\n${readFileSync(log, 'utf8').trim().split('\n').slice(-12).join('\n')}`;
|
|
return { base, calls, org, diagnostics };
|
|
}
|
|
|
|
async function page(t, base, diagnostics) {
|
|
const b = await browser({ machine: false });
|
|
t.after(() => b.close());
|
|
await b.viewport(1440, 900);
|
|
const wait = async (expression, label = expression) => {
|
|
for (let i = 0; i < 400; i++) {
|
|
try { if (await b.evaluate(`Boolean(${expression})`)) return; } catch { /* navigation */ }
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
throw Error(`Timed out: ${label}\n${await b.evaluate('document.body.innerText.slice(0, 2000)').catch(() => 'page unavailable')}\n${diagnostics()}`);
|
|
};
|
|
return { b, wait };
|
|
}
|
|
|
|
test('an organization offers its request status, and a conversation whose message has no request says nothing is wrong', { timeout: 60000 }, async t => {
|
|
const { base, calls, org, diagnostics } = await consoleFor(t);
|
|
const { b, wait } = await page(t, base, diagnostics);
|
|
await b.navigate(`${base}#/orgs/alpha/projects/threads/${T}`);
|
|
await wait(`document.querySelector('.chat-log')?.textContent.includes('PINEAPPLE')`);
|
|
const config = await b.evaluate(`fetch('/api/orgs/alpha/config').then(r => r.json())`);
|
|
assert.equal(config.requests, true, `the organization config must offer request reads: ${JSON.stringify(config)}`);
|
|
// The page settles when the request read answered: the Tasks tab gets its count, or the status line says it failed.
|
|
const settled = `document.querySelector('.project-tabs a[href$="/tasks"] .project-count') || document.querySelector('[data-conversation-request-status]')?.textContent`;
|
|
await wait(settled, 'request status settled');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`), '');
|
|
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Request status unavailable|Status unavailable/);
|
|
const reads = calls().filter(c => c.kind === 'console');
|
|
assert.ok(reads.length > 0, 'the request read reached the console transport');
|
|
for (const read of reads) {
|
|
assert.equal(read.verb, 'request.get.console');
|
|
assert.deepEqual(read.args, { request: REQUEST });
|
|
assert.equal(read.business, 'alpha', 'the read goes to the selected organization');
|
|
assert.equal(read.socket, orgPaths(org.root).socket, 'the read uses the organization bus socket');
|
|
}
|
|
});
|
|
|
|
test('an organization project overview reports no active request instead of unavailable', { timeout: 60000 }, async t => {
|
|
const { base, diagnostics } = await consoleFor(t);
|
|
const { b, wait } = await page(t, base, diagnostics);
|
|
await b.navigate(`${base}#/orgs/alpha/chat`);
|
|
await wait(`document.querySelector('[data-project-status="${P}"] .project-card-status-line')`);
|
|
assert.match(await b.evaluate(`document.querySelector('[data-project-status="${P}"]').textContent`), /^No active requests · 1 conversation/);
|
|
// The overview renders after its reads settle, so the metrics are final once they exist.
|
|
await b.navigate(`${base}?v=2#/orgs/alpha/projects/${P}`);
|
|
await wait(`document.querySelector('.project-metrics')`, 'project metrics');
|
|
const metrics = await b.evaluate(`[...document.querySelectorAll('.project-metrics a')].slice(0, 2).map(a => a.innerText.replace(/\\s+/g, ' ').trim())`);
|
|
assert.deepEqual(metrics, ['Requests in progress 0 No active requests', 'Needs your input 0 Nothing waiting']);
|
|
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Status unavailable|Request status unavailable|Request tasks unavailable/);
|
|
});
|
|
|
|
test('organization role instances reflow inside the viewport at 390 pixels', { timeout: 60000 }, async t => {
|
|
const { base, diagnostics } = await consoleFor(t);
|
|
const { b, wait } = await page(t, base, diagnostics);
|
|
await b.viewport(390, 844);
|
|
await b.navigate(`${base}#/orgs/alpha/settings/business`);
|
|
await wait(`document.querySelector('.s1-roles tbody tr')`, 'role instances');
|
|
const box = await b.evaluate(`(()=>{const table=document.querySelector('.s1-roles'),wrap=table.closest('.table-wrap'),r=wrap.getBoundingClientRect();return {right:r.right,tableRight:table.getBoundingClientRect().right,document:document.documentElement.scrollWidth,viewport:innerWidth,overflow:getComputedStyle(wrap).overflowX,client:wrap.clientWidth,scroll:wrap.scrollWidth}})()`);
|
|
assert.ok(box.right <= box.viewport && box.document <= box.viewport, JSON.stringify(box));
|
|
assert.ok(box.tableRight <= box.viewport, JSON.stringify(box)); assert.ok(box.scroll <= box.client, JSON.stringify(box));
|
|
});
|