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(), ''); });