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