Files
stack/packages/webui/tests/org-quick-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

118 lines
11 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import * as service from '../../organization/src/org-service.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
test('setup paths and preset controls are visible', { timeout: 45000 }, async t => {
const admin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).display !== 'none' && e.getClientRects().length > 0})()`);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
await b.evaluate(`document.querySelector('#org-new-id').value='alpha';document.querySelector('#org-new-form [data-write]').click()`);
await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
for (const selector of ['#org-quick-open', '#org-advanced-open', '#org-preset', '#org-thinking']) assert.equal(await shown(selector), true, selector);
await b.evaluate(`document.querySelector('#org-quick-open').click()`);
await wait('!document.querySelector("#org-quick").hidden');
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
});
for (const scenario of ['bus-refused', 'project-refused', 'project-unknown', 'success']) test(`quick setup creates real repositories: ${scenario}`, { timeout: 45000 }, async t => {
const { mkdtempSync, mkdirSync, rmSync, existsSync } = await import('node:fs');
const { tmpdir } = await import('node:os'); const { join } = await import('node:path');
const registry = await import('../../organization/src/org-registry.mjs');
const dir = mkdtempSync(join(tmpdir(), 'quick-'));
const configDir = join(dir, 'config'), dataRoot = join(dir, 'data'); mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
let starts = 0;
const bus = { busStatus: async () => ({ healthy: false }), busStart: async () => { starts++; if (scenario === 'bus-refused') throw Object.assign(Error('bus refused'), { code: 'org-invalid' }); return { healthy: true }; } };
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
const projects = [];
const organizations = {
list: async () => starts ? [{ id: 'alpha', label: 'alpha', busHealthy: true }] : [],
resolve: async id => id === 'alpha' ? { send: async () => ({}), busHealthy: true, chat: {
projects: async () => projects, threads: async () => [],
createProject: async args => { assert.deepEqual(args, { name: 'work', repo: 'work' }); const row = { id: '20000000-0000-4000-8000-000000000002', ...args }; projects.push(row); return row; },
} } : null,
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
// Remove the directory last: hooks run in the order they are registered, and a removal that
// fails while the server still writes there would skip the server and browser closes (P19 #865).
t.after(() => rmSync(dir, { recursive: true, force: true }));
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
await b.evaluate(`document.querySelector('#org-new-id').value='alpha';document.querySelector('#org-new-form [data-write]').click()`);
await wait('document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden');
if (scenario === 'project-refused' || scenario === 'project-unknown') await b.evaluate(`window.__projectPosts=0;const real=window.fetch;window.fetch=async(url,init={})=>{if(String(url)==='/api/orgs/alpha/chat/projects' && init.method==='POST'){window.__projectPosts++;${scenario === 'project-refused' ? "return new Response(JSON.stringify({error:'not-configured',message:'Project refused',sent:false}),{status:503,headers:{'content-type':'application/json'}});" : "await real(url,init);return new Response(JSON.stringify({error:'outcome-unknown',message:'Outcome unknown',sent:'unknown'}),{status:503,headers:{'content-type':'application/json'}});"}}return real(url,init)}`);
await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
const finalStep = scenario === 'bus-refused' ? 3 : scenario === 'success' ? 5 : 4, finalState = scenario === 'success' ? 'Done' : 'Stopped';
await wait(`document.querySelector('[data-quick-step="${finalStep}"] .org-pill')?.textContent === '${finalState}'`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', scenario === 'bus-refused' ? 'Stopped' : 'Done', scenario === 'bus-refused' ? 'Not started' : finalState, scenario === 'success' ? 'Done' : 'Not started']);
if (scenario === 'bus-refused') {
assert.match(await b.evaluate(`document.querySelector('[data-quick-step="3"]').textContent`), /bus refused/);
assert.equal(await b.evaluate('document.activeElement.dataset.quickStep'), '3');
}
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="3"] code').textContent`), 'mosaic bus start alpha --dev-local-owner --detach');
assert.equal(starts, 1);
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/checkout/.git')));
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/work/.git')));
if (scenario === 'success') {
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="5"] code').textContent`), 'mosaic project init alpha work');
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/alpha/work/spec/north-star.md')));
const readback = await b.evaluate(`fetch('/api/orgs/alpha/setup/records/work').then(r=>r.json())`);
assert.equal(readback.state, 'done'); assert.equal(readback.verb, 'mosaic project init alpha work');
}
if (scenario !== 'bus-refused') {
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="4"] code').textContent`), 'mosaic project create work --repo work --business alpha');
if (scenario !== 'success') {
assert.match(await b.evaluate(`document.querySelector('[data-quick-step="4"]').textContent`), scenario === 'project-refused' ? /Project refused/ : /Outcome unknown/i);
assert.equal(await b.evaluate('document.activeElement.dataset.quickStep'), '4');
await b.evaluate(`document.querySelector('#org-quick-run').click()`);
assert.equal(await b.evaluate('window.__projectPosts'), 1, 'a stopped setup does not repeat the write');
}
await wait('!document.querySelector("#org-quick-open-org").hidden');
await b.evaluate(`document.querySelector('#org-quick-open-org').click()`);
await wait(`window.MosaicOrg?.id === 'alpha' && !!document.querySelector('.chat-projects')`);
if (scenario === 'project-refused') assert.match(await b.evaluate(`document.querySelector('.chat-projects').textContent`), /No projects yet/);
else {
await wait(`document.querySelector('.chat-projects')?.textContent.includes('Repository: work')`);
assert.equal(await b.evaluate(`document.querySelectorAll('.chat-projects .project-card').length`), 1);
assert.doesNotMatch(await b.evaluate(`document.querySelector('.chat-projects').textContent`), /No projects yet/);
}
}
});
test('an unreadable success reply stops quick setup and a new draft gets fresh controls', { timeout: 45000 }, async t => {
const admin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
// The create reply is a truncated 201; every POST is recorded so the test can prove nothing follows it.
await b.evaluate(`window.__posts=[];const real=window.fetch;window.fetch=(url,init={})=>{if((init.method||'GET')==='POST'){window.__posts.push(String(url));if(String(url)==='/api/orgs')return Promise.resolve(new Response('{"outcome":',{status:201,headers:{'content-type':'application/json'}}));}return real(url,init)}`);
const draft = async id => {
await b.evaluate(`document.querySelector('#org-new-id').value=${JSON.stringify(id)};document.querySelector('#org-new-form [data-write]').click()`);
await wait(`document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden && !document.querySelector("#org-quick-open").disabled`);
await b.evaluate(`document.querySelector('#org-quick-open').click()`);
await wait('!document.querySelector("#org-quick").hidden');
};
const controls = () => b.evaluate('["#org-preset","#org-thinking","#org-quick-run"].map(s=>document.querySelector(s).disabled)');
await draft('alpha');
await b.evaluate(`document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
await wait(`document.querySelector('[data-quick-step="0"] .org-pill')?.textContent === 'Stopped'`);
assert.match(await b.evaluate(`document.querySelector('[data-quick-step="0"]').textContent`), /outcome is unknown/);
assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs']);
assert.deepEqual(await controls(), [true, true, true]);
await draft('beta');
assert.deepEqual(await controls(), [false, false, false]);
assert.equal(await b.evaluate(`document.querySelector('#org-risk-accept').checked`), false);
});