Merge branch 'org-quick-project' into ui-org-r2

This commit is contained in:
jason.woltje committed 2026-11-08 15:53:52 -06:00
commit 6ca7f3b656
3 files changed
+45 -16

No files matched your search

+7 -2
View File
@@ -308,6 +308,7 @@
{ title: 'Initialize organization checkout', verb: `mosaic org queue init ${id} --local-only --accept-local-loss-risk` },
{ title: `Initialize project repository: ${project}`, verb: `mosaic org project repo init ${id} ${project} ${remote ? `--remote ${remote}` : '--local-only --accept-local-loss-risk'}` },
{ title: 'Start the bus', verb: `mosaic bus start ${id} --dev-local-owner --detach` },
{ title: `Create project: ${project}`, verb: `mosaic project create ${project} --repo ${project} --business ${id}` },
];
const list = $('org-quick-steps');
list.innerHTML = steps.map((s, i) => `<li class="panel org-step" data-quick-step="${i}" tabindex="-1"><div class="org-step-head"><h4>${txt(s.title)}</h4><span class="org-pill">Not started</span></div><p class="org-verb">Same as <code>${txt(s.verb)}</code></p><p class="org-field-err" role="alert"></p></li>`).join('');
@@ -323,6 +324,7 @@
[`/api/orgs/${enc(id)}/setup/queue`, local, 'queue'],
[`/api/orgs/${enc(id)}/setup/project/${enc(project)}`, remote ? { mode: 'remote', remote } : local, 'project'],
[`/api/orgs/${enc(id)}/setup/bus`, {}, 'bus'],
[`/api/orgs/${enc(id)}/chat/projects`, { name: project, repo: project }, 'chat-project'],
];
for (current = 0; current < requests.length; current++) {
const card = list.children[current]; card.querySelector('.org-pill').textContent = 'In progress';
@@ -330,7 +332,7 @@
const result = await api('POST', url, body);
if (result.status !== 200 && result.status !== 201) throw Error(result.body.message || result.body.errors?.map(e => e.message).join(' ') || `Refused (${result.body.error || result.status}).`);
// A success status alone is not proof: an unreadable or unexpected reply leaves the outcome unknown, so stop here.
if (result.body.outcome !== 'done' || result.body.step !== step) throw Error('The reply could not be read, so this step\'s outcome is unknown.');
if (step === 'chat-project' ? !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.body.id || '') : result.body.outcome !== 'done' || result.body.step !== step) throw Error('The reply could not be read, so this step\'s outcome is unknown.');
card.querySelector('.org-pill').textContent = 'Done'; card.classList.add('org-step-done');
if (result.body.verb) card.querySelector('code').textContent = result.body.verb;
}
@@ -338,7 +340,10 @@
await refreshList();
} catch (e) {
const card = list.children[Math.min(current, steps.length - 1)];
card.querySelector('.org-pill').textContent = 'Stopped'; card.querySelector('.org-field-err').textContent = `${e.message} Open the organization to inspect and continue; do not repeat a step whose outcome is unknown.`;
card.querySelector('.org-pill').textContent = 'Stopped'; card.querySelector('.org-field-err').textContent = current === 4
? `${e.message} Open the organization and check Projects before creating the project; its outcome may be unknown.`
: `${e.message} Open the organization to inspect and continue; do not repeat a step whose outcome is unknown.`;
if (current === 4) { editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; }
card.classList.add('org-step-warn'); card.focus(); status('Setup stopped.');
await refreshList();
}
@@ -23,16 +23,24 @@ test('setup paths and preset controls are visible', { timeout: 45000 }, async t
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
});
for (const shouldFail of [true, false]) test(`quick setup creates real repositories and ${shouldFail ? 'stops at a bus refusal' : 'completes after bus start'}`, { timeout: 45000 }, async t => {
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('../../business/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 (shouldFail) throw Object.assign(Error('bus refused'), { code: 'org-invalid' }); return { healthy: true }; } };
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 server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
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).
@@ -42,11 +50,12 @@ for (const shouldFail of [true, false]) test(`quick setup creates real repositor
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 finalState = shouldFail ? 'Stopped' : 'Done';
await wait(`document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === '${finalState}'`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', finalState]);
if (shouldFail) {
const finalStep = scenario === 'bus-refused' ? 3 : 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]);
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');
}
@@ -54,6 +63,24 @@ for (const shouldFail of [true, false]) test(`quick setup creates real repositor
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 !== '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 => {
+4 -7
View File
@@ -32,17 +32,14 @@ test('Quick Setup starts the real fixture and a browser message gets its canned
await wait('document.querySelector("#org-quick") && !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);
await click('#org-risk-accept'); await click('#org-quick-run');
await wait(`document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
await wait(`document.querySelector('[data-quick-step="4"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
console.log('setup reached a terminal step');
const states = await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)');
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.equal(await shown('#org-quick-open-org'), true, 'the finished organization can be opened');
await click('#org-quick-open-org'); console.log('opening organization');
await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && !!document.querySelector('#chat-repo option[value=work]')`, 30000);
await wait('!!document.querySelector("#chat-repo option[value=work]")');
console.log('repository offered');
await click('.project-create summary'); await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
await click('#chat-project-form button[type=submit]');
await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && document.querySelector('.project-card')?.textContent.includes('Repository: work')`, 30000);
await click('.project-card');
await wait('location.hash.match(/\\/projects\\/[0-9a-f-]{36}/i)');
console.log('project created');
const projectRoute = await b.evaluate('location.hash');