Files
stack/packages/webui/tests/onboarding-retry-browser.test.mjs
jason.woltje 5937a977f8 webui: F2 review fixes, interview retry, fresh approval capability, primary start
Sol's F2 review: a refused interview start threw on the null
currentTarget after the await and left Start disabled (P2); the panel
cached /api/config for the life of the page, so Approve outlived the
capability (P3). Hold the button before the await; reread config on
each offer. Lead P3: on Quick Setup done, Start onboarding interview
gets the accent fill; Open organization stays secondary.
onboarding-retry-browser is red 0/2 on the old source, 2/0 now.
2026-11-09 13:17:10 -06:00

67 lines
4.6 KiB
JavaScript

// F2 review (Sol, 11-09): the project-page onboarding panel after a refused start and a capability
// change. A throwaway loopback page loads the shipped onboarding.js with synthetic API answers.
// P2: a definite refusal to open the conversation must leave Start enabled for a retry.
// P3: a refresh must reread /api/config, so approval disappears once it is no longer available.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { browser } from './browser.mjs';
import { loadInterviewTemplate, interviewMessage, northStarTemplateText } from '../../project/src/onboarding.mjs';
const SCRIPT = fileURLToPath(new URL('../src/public/onboarding.js', import.meta.url));
const template = loadInterviewTemplate();
const spec = { path: 'spec/north-star.md', commit: 'a'.repeat(40), content: northStarTemplateText(), committed: '2026-11-09T10:00:00Z' };
const draft = { path: template.drafts.northStar, commit: 'b'.repeat(40), content: '# Draft\n', committed: '2026-11-09T11:00:00Z' };
const plan = { ...template, message: interviewMessage(template, { project: 'stack' }), northStarTemplate: northStarTemplateText(), assistant: { instance: 'assistant', name: 'Ada' } };
async function setup(t, { pending = false } = {}) {
const state = { approve: true, configReads: 0 };
const server = createServer((req, res) => {
if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><main><div id="content"></div></main><script src="/onboarding.js"></script>'); }
if (req.url === '/onboarding.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(readFileSync(SCRIPT)); }
res.setHeader('content-type', 'application/json');
if (req.url.startsWith('/api/onboarding/interview?')) return res.end(JSON.stringify(plan));
if (req.url === '/api/config') { state.configReads++; return res.end(JSON.stringify({ northStarApprove: state.approve })); }
if (req.url.startsWith('/api/project-file?')) return res.end(JSON.stringify(draft));
if (req.url === '/api/chat/threads') { res.writeHead(503); return res.end(JSON.stringify({ error: 'capacity-full', sent: false })); }
res.writeHead(404); res.end(JSON.stringify({ error: 'missing' }));
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
t.after(() => new Promise(r => { server.closeAllConnections(); server.close(r); }));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/`);
const args = { project: { id: '20000000-0000-4000-8000-000000000002', repo: 'stack' }, section: 'overview', north: spec, docs: { files: pending ? [{ path: draft.path }] : [] }, canSend: true };
await b.evaluate(`window.errors=[];addEventListener('unhandledrejection',e=>{errors.push(String(e.reason));e.preventDefault()});window.args=${JSON.stringify(args)};window.offer=()=>MosaicOnboarding.offer(document.querySelector('#content'),args);window.offer()`);
return { b, state };
}
async function until(b, expression) {
for (let i = 0; i < 300; i++) {
if (await b.evaluate(expression)) return;
await new Promise(r => setTimeout(r, 20));
}
throw Error(`Timed out: ${expression}`);
}
test('a refused interview start leaves Start enabled for a retry, with no page error', { timeout: 60000 }, async t => {
const { b } = await setup(t);
await until(b, '!!document.querySelector("[data-onboarding-start]")');
await b.evaluate('document.querySelector("[data-onboarding-start]").click()');
await until(b, 'document.querySelector(".onboarding-status").textContent.includes("refused")');
await until(b, 'errors.length>0 || !document.querySelector("[data-onboarding-start]").disabled');
const observed = await b.evaluate('({disabled:document.querySelector("[data-onboarding-start]").disabled,errors})');
assert.deepEqual(observed, { disabled: false, errors: [] });
});
test('a refresh rereads config, so Approve disappears once approval is unavailable', { timeout: 60000 }, async t => {
const { b, state } = await setup(t, { pending: true });
await until(b, '!!document.querySelector("[data-onboarding-approve]")');
state.approve = false;
const reads = state.configReads;
await b.evaluate('offer()');
assert.ok(state.configReads > reads, 'the refresh read /api/config again');
assert.equal(await b.evaluate('!!document.querySelector("[data-onboarding-approve]")'), false);
assert.match(await b.evaluate('document.querySelector("[data-onboarding]").textContent'), /Approving is not available in this view\./);
});