// 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= 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'); });