Files
stack/packages/webui/tests/org-quick-browser.test.mjs
jason.woltje c44e4e28fd fix(org): review round for project remote set and check (#1873 P1-6, P1-7)
The needs-credential line no longer promises that a binding will supply
one; it says the check sends none. A recorded check is shown only for
the URL it checked. The Settings section says when the git origin differs
from the recorded repository, and a save repairs it. The URL rule message
names what is refused. Tests cover a document write that fails after
origin is set, the drift line and its repair, and Quick Setup in remote
mode.
2026-11-12 13:39:13 -06:00

159 lines
15 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);
});
// #1873 P1-6: Quick Setup in remote mode sets the project checkout's origin, checks it once (here
// through the test seam, so nothing leaves the host) and shows the outcome on the project step.
test('quick setup in remote mode sets origin and shows the check', { timeout: 45000 }, async t => {
const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const { execFileSync } = await import('node:child_process');
const registry = await import('../../organization/src/org-registry.mjs');
const { repoRemoteTestSeam } = await import('../../organization/src/repo-remote.mjs');
const remote = 'https://git.example.invalid/alpha/work';
const checked = []; repoRemoteTestSeam.useCheck(async url => { checked.push(url); return { result: 'unreachable', message: 'Could not resolve host: git.example.invalid' }; });
t.after(() => repoRemoteTestSeam.useCheck(null));
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++; 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 => { 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, args: ['--disable-extensions'] }); t.after(() => b.close());
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');
await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-project-remote').value=${JSON.stringify(remote)};document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
await wait(`document.querySelector('[data-quick-step="5"] .org-pill')?.textContent === 'Done'`);
assert.equal((await b.evaluate(`document.querySelector('[data-remote-check]')?.textContent ?? ''`)).trim(), `Remote check: unreachable, Could not resolve host: git.example.invalid. Origin: ${remote}.`);
assert.deepEqual(checked, [remote]);
const git = (...args) => execFileSync('git', ['-C', join(dataRoot, 'org-runtime/alpha/projects/work'), ...args], { env: { PATH: process.env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, encoding: 'utf8' }).trim();
assert.equal(git('remote', '-v'), `origin\t${remote} (fetch)\norigin\t${remote} (push)`);
// The organization checkout stays local only.
assert.equal(execFileSync('git', ['-C', join(dataRoot, 'org-runtime/alpha/checkout'), 'remote', '-v'], { env: { PATH: process.env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, encoding: 'utf8' }).trim(), '');
});