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.
67 lines
4.6 KiB
JavaScript
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\./);
|
|
});
|