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.
159 lines
15 KiB
JavaScript
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(), '');
|
|
});
|