Files
stack/packages/webui/tests/onboarding-browser.test.mjs
jason.woltje bad760ce08 Merge refactor fbf2f6fc8 (rename stage B) into F2
Conflict in packages/webui/src/serve.mjs imports: kept F2's
THREAD_DEFINITION and onboarding imports on stage B's organization path.
The F2 assistant test edit followed the move to packages/organization;
onboarding-browser now imports the organization service directly.
2026-11-09 13:17:33 -06:00

211 lines
16 KiB
JavaScript

// F2 (PRD 1.20 draft REQ-SETUP-2): the onboarding interview in the browser. Quick Setup runs for real
// (org service, registry and repositories in a scratch directory) with the Test model preset; the
// organization's chat and bus are fakes that record what the page asked for. The project-page offer
// runs the real interview read, file reads, chat writes and approval route behind the shared Projects stub.
// MOSAIC_UX_SHOTS=<dir> also saves 1440x900 and 390x844 screenshots of each state.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.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 * as registry from '../../organization/src/org-registry.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
import { interviewMessage, loadInterviewTemplate, northStarTemplateText, SPEC_PATH } from '../../project/src/onboarding.mjs';
const TEMPLATE = loadInterviewTemplate();
const DRAFT = TEMPLATE.drafts.northStar;
const CHAT_PROJECT = '20000000-0000-4000-8000-000000000002', THREAD = '60000000-0000-4000-8000-000000000001', MESSAGE = '70000000-0000-4000-8000-000000000001';
const S1 = 'a'.repeat(40), D1 = 'b'.repeat(40), S2 = 'c'.repeat(40);
const TEXT = '# North star\n\nOnboard new organizations in one sitting.\n';
const INSTANCES = { revision: 'f'.repeat(64), definitions: {}, instances: { coder: { definition: 'coder' }, assistant: { definition: 'assistant', vars: { 'persona.name': 'Ada' } } } };
const SIZES = [[1440, 900], [390, 844]];
const shots = process.env.MOSAIC_UX_SHOTS;
// The chat writes the interview makes, recorded; both answer the receipt shape chat-http checks.
function chatFake(rows = []) {
const calls = { createThread: [], send: [] };
return {
calls,
chat: {
projects: async () => rows, threads: async () => [], messages: async () => [],
createThread: async args => { calls.createThread.push(args); return { id: THREAD, instance: args.instance }; },
send: async args => { calls.send.push(args); return { id: MESSAGE }; },
},
};
}
// Polls from Node, so a page reload during the wait is not an error.
async function until(b, expression, what = expression) {
for (let i = 0; i < 300; i++) {
try { if (await b.evaluate(expression)) return; } catch { /* the page is reloading */ }
await new Promise(r => setTimeout(r, 30));
}
throw Error(`Timed out: ${what}`);
}
const shown = (b, selector) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && e.getClientRects().length > 0})()`);
async function capture(b, selector, name) {
if (!shots) return;
for (const [width, height] of SIZES) {
await b.viewport(width, height);
await b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block:'center'})`);
await new Promise(r => setTimeout(r, 150));
await b.screenshot(join(shots, `${name}-${width}.png`));
}
await b.viewport(...SIZES[0]);
}
for (const scenario of ['start', 'skip', 'stopped']) test(`quick setup done state offers the interview: ${scenario}`, { timeout: 60000 }, async t => {
const dir = mkdtempSync(join(tmpdir(), 'f2-'));
const configDir = join(dir, 'config'), dataRoot = join(dir, 'data'); mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
let starts = 0, instanceReads = 0;
const bus = { busStatus: async () => ({ healthy: false }), busStart: async () => { starts++; if (scenario === 'stopped') 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 rows = [], fake = chatFake(rows);
fake.chat.createProject = async args => { const row = { id: CHAT_PROJECT, ...args }; rows.push(row); return row; };
const organizations = {
list: async () => starts ? [{ id: 'alpha', label: 'alpha', busHealthy: true }] : [],
resolve: async id => id === 'alpha' ? { send: async () => ({}), busHealthy: true, chat: fake.chat, bus: { instances: async () => { instanceReads++; return structuredClone(INSTANCES); } } } : 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());
t.after(() => rmSync(dir, { recursive: true, force: true }));
await b.viewport(...SIZES[0]);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
await until(b, '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 until(b, 'document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden');
// The deterministic Test model only: choose it by name, never the first available preset.
await b.evaluate(`(()=>{const s=document.querySelector('#org-preset');s.value='fixture';s.dispatchEvent(new Event('change'))})()`);
assert.deepEqual(await b.evaluate(`(()=>{const s=document.querySelector('#org-preset');return [s.value,s.selectedOptions[0]?.textContent]})()`), ['fixture', 'Test model']);
assert.equal(await shown(b, '#org-quick-interview'), false, 'no offer before a run');
await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
if (scenario === 'stopped') {
await until(b, `document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === 'Stopped'`);
assert.equal(await shown(b, '#org-quick-interview'), false, 'a stopped setup offers no interview');
assert.equal(instanceReads, 0); assert.deepEqual(fake.calls, { createThread: [], send: [] });
return;
}
await until(b, `document.querySelector('[data-quick-step="5"] .org-pill')?.textContent === 'Done'`);
await until(b, '!document.querySelector("#org-quick-interview").hidden && !document.querySelector("#org-quick-open-org").hidden');
assert.equal(await b.evaluate(`document.querySelector('#org-quick-interview').textContent`), 'Start onboarding interview');
assert.equal(await shown(b, '#org-quick-open-org'), true, 'Open organization stays next to it');
// Both controls are in one row of actions, each a 44px target.
for (const id of ['#org-quick-interview', '#org-quick-open-org']) assert.ok(await b.evaluate(`document.querySelector('${id}').getBoundingClientRect().height >= 44`), id);
// The interview is the primary action: it gets the accent fill, Open organization does not.
const fill = id => b.evaluate(`getComputedStyle(document.querySelector('${id}')).backgroundColor`);
assert.notEqual(await fill('#org-quick-interview'), await fill('#org-quick-open-org'), 'the interview button is styled as the primary action');
if (scenario === 'start') await capture(b, '#org-quick-interview', 'quick-setup-done');
if (scenario === 'skip') {
await b.evaluate(`document.querySelector('#org-quick-open-org').click()`);
await until(b, `window.MosaicOrg?.id === 'alpha' && document.querySelector('.chat-projects')?.textContent.includes('Repository: work')`);
// The organization page reads its role instances for its own views; only the chat writes matter here.
assert.deepEqual(fake.calls, { createThread: [], send: [] }, 'skipping opens no conversation and sends nothing');
return;
}
await b.evaluate(`document.querySelector('#org-quick-interview').click()`);
await until(b, `location.hash === '#/orgs/alpha/projects/threads/${THREAD}' && window.MosaicOrg?.id === 'alpha'`, 'the interview conversation opens in alpha');
assert.deepEqual(fake.calls.createThread, [{ project: CHAT_PROJECT, name: TEMPLATE.threadName, instance: 'assistant' }]);
assert.deepEqual(fake.calls.send, [{ thread: THREAD, body: interviewMessage(TEMPLATE, { project: 'work' }) }]);
assert.ok(instanceReads >= 1, 'the interview read named the assistant from the organization\'s role instances');
});
test('the project page offers the interview while the north star is the template, then the draft with Approve', { timeout: 90000 }, async t => {
const state = { spec: { path: SPEC_PATH, head: S1, commit: S1, content: northStarTemplateText(), author: 'jason', at: '2026-11-09T10:00:00Z' }, draft: null };
const saves = [], fake = chatFake();
const server = await startServer({
port: 0, sso: createDevOwner(), send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {}, chat: fake.chat,
bus: { instances: async () => structuredClone(INSTANCES) },
projectFiles: async () => ({ head: state.draft?.commit ?? state.spec.commit, files: [{ path: SPEC_PATH }, ...(state.draft ? [{ path: DRAFT }] : [])] }),
projectFile: async ({ path }) => {
if (path === SPEC_PATH) return structuredClone(state.spec);
if (path === DRAFT && state.draft) return structuredClone(state.draft);
throw new BusReadError('project-read-missing');
},
projectSave: async args => {
saves.push(args);
state.spec = { ...state.spec, head: S2, commit: S2, content: args.content, at: '2026-11-09T12:00:00Z' };
return { path: args.path, commit: S2 };
},
});
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
// The shared stub answers the Projects page; this wrapper sends the reads and writes F2 adds to the server.
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.__serverFetch=window.fetch.bind(window);' });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const stubbed = window.fetch, server = window.__serverFetch; window.serverCalls = [];
window.fetch = async (url, init = {}) => {
const path = String(url), method = init.method || 'GET';
const toServer = method === 'GET' ? /^\\/api\\/(project-files\\?|project-file\\?|onboarding\\/interview\\?)/.test(path)
: /^\\/api\\/(north-star\\/approve|chat\\/threads|chat\\/threads\\/[0-9a-f-]{36}\\/messages)$/.test(path);
if (toServer) { const r = await server(url, init); window.serverCalls.push(method + ' ' + path); return r; }
if (path === '/api/config') {
const [a, b] = await Promise.all([stubbed(url, init).then(r => r.json()), server(url, init).then(r => r.json())]);
return new Response(JSON.stringify({ ...a, ...b }), { headers: { 'content-type': 'application/json' } });
}
return stubbed(url, init);
};
})();` });
await b.viewport(...SIZES[0]);
const base = `http://127.0.0.1:${server.address().port}`;
const open = async n => {
await b.navigate(`${base}/?r=${n}#/projects/${P}`);
await until(b, `[...document.querySelectorAll('.project-panel h2')].some(h => h.textContent === 'Project direction')`, 'the overview shows the project direction');
};
// 1. The north star is still the template: the panel lists the questions and offers the interview.
await open(1);
await until(b, `document.querySelector('[data-onboarding] h2')?.textContent === 'Onboarding interview'`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-onboarding] .onboarding-questions li')].map(e=>e.textContent)`), TEMPLATE.questions.map(q => q.text));
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding-start]').textContent`), 'Start onboarding interview');
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding]').nextElementSibling.textContent.includes('Project direction')`), true, 'the panel sits just before the project content');
await capture(b, '[data-onboarding]', 'project-offer');
for (const [width, height] of SIZES) {
await b.viewport(width, height);
assert.ok(await b.evaluate(`(()=>{const p=document.querySelector('[data-onboarding]'),r=p.getBoundingClientRect();return p.scrollWidth<=p.clientWidth && r.right<=innerWidth+1})()`), `the panel fits at ${width}px`);
assert.ok(await b.evaluate(`document.querySelector('[data-onboarding-start]').getBoundingClientRect().height >= 44`), `a 44px start target at ${width}px`);
}
await b.viewport(...SIZES[0]);
await b.evaluate(`document.querySelector('[data-onboarding-start]').click()`);
await until(b, `location.hash === '#/projects/threads/${THREAD}'`, 'the interview conversation opens');
assert.deepEqual(fake.calls.createThread, [{ project: P, name: TEMPLATE.threadName, instance: 'assistant' }]);
assert.deepEqual(fake.calls.send, [{ thread: THREAD, body: interviewMessage(TEMPLATE, { project: 'timer' }) }]);
// 2. The assistant committed a draft after the north star: the panel shows it with Approve and the CLI verb.
state.draft = { path: DRAFT, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T11:00:00Z' };
await open(2);
await until(b, `document.querySelector('[data-onboarding] h2')?.textContent === 'North star draft ready'`);
assert.match(await b.evaluate(`document.querySelector('[data-onboarding-draft]').textContent`), /Onboard new organizations in one sitting\./);
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding] .onboarding-verb').textContent`), `Same as mosaic project north-star timer --approve ${D1} --base ${S1}`);
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding-start]')`), false, 'no second interview offer beside a draft');
await capture(b, '[data-onboarding]', 'project-draft');
assert.deepEqual(saves, [], 'showing the draft writes nothing');
await b.evaluate(`document.querySelector('[data-onboarding-approve]').click()`);
await until(b, `document.querySelector('[data-onboarding] .onboarding-status')?.textContent.startsWith('Approved.')`);
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding] .onboarding-status').textContent`), `Approved. spec/north-star.md is now commit ${S2.slice(0, 12)}. The product manager and CTO get the spec notice.`);
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding-approve]')`), false);
assert.deepEqual(saves, [{ project: 'timer', path: SPEC_PATH, base: S1, content: TEXT }]);
assert.ok(await b.evaluate(`window.serverCalls.includes('POST /api/north-star/approve')`));
await capture(b, '[data-onboarding]', 'project-approved');
// 3. After a reload the spec holds the draft: nothing is offered.
await open(3);
await until(b, `window.serverCalls.some(c => c.includes(${JSON.stringify(encodeURIComponent(DRAFT))}))`, 'the draft read finished');
await new Promise(r => setTimeout(r, 300));
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding]')`), false, 'no offer once the north star is approved');
// 4. A north star typed by hand after the draft: the older draft is not offered again.
state.spec = { ...state.spec, commit: 'd'.repeat(40), content: '# Typed by hand\n', at: '2026-11-09T13:00:00Z' };
state.draft = { ...state.draft, at: '2026-11-09T11:00:00Z' };
await open(4);
await until(b, `window.serverCalls.some(c => c.includes(${JSON.stringify(encodeURIComponent(DRAFT))}))`, 'the draft read finished');
await new Promise(r => setTimeout(r, 300));
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding]')`), false, 'an older draft is not offered over a newer north star');
});