Files
stack/packages/webui/tests/org-request-status-browser.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

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/projects`);
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));
});