feat(webui): onboarding interview after Quick Setup and on the project page (F2)
- Quick Setup's done state offers Start onboarding interview next to Open organization. It opens a conversation with the assistant and sends the template's first message; Open organization skips it. - The project overview offers the interview while the north star is the init template, then shows the assistant's draft with Approve as north star and the equivalent CLI command. - GET /api/onboarding/interview serves the template and the assistant instance. POST /api/north-star/approve runs approveNorthStarDraft with the save's boundary (header user, exact Origin, same-origin Fetch Metadata, strict JSON). In an organization it is the one project write the fence lets through; the tester and granted users never reach it. /api/config says northStarApprove where the route can pass. - GET /api/project-file adds `committed`, the file's last commit time; `at` stays the read time.
This commit is contained in:
1 parent
00ae2c718c
commit
7fe301a172
10 files changed
+711
-12
No files matched your search
@@ -24,6 +24,8 @@ baseline CLI.
|
||||
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
|
||||
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
|
||||
| Send a thread message | `mosaic talk --thread <thread> <text> --org <id>` | Planned enabled |
|
||||
| Start the onboarding interview (F2) | `mosaic thread create <chat-project> "Onboarding interview" --instance <assistant> --org <id>`, then `mosaic talk --thread <thread> "$(mosaic project interview <project>)" --org <id>` | Enabled through the thread create and send routes above |
|
||||
| Approve the north-star draft (F2) | `mosaic project north-star <project> --org <id> --approve <draft-commit> --base <spec-commit>` | Enabled: copies the reviewed draft into `spec/north-star.md` through the human save (`project.file.save`) |
|
||||
| Archive or unarchive a chat project | `mosaic project archive` / `mosaic project unarchive` | Unavailable pending parity |
|
||||
| Open a room or send a room message | No CLI equivalent identified | Unavailable |
|
||||
| Share or unshare a thread | No CLI equivalent identified | Unavailable |
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* F2: the onboarding interview offer and the north-star draft on the project page. */
|
||||
.onboarding-panel { max-width:100%; }
|
||||
.onboarding-panel p { max-width:72ch; }
|
||||
.onboarding-questions { margin:8px 0 12px; padding-left:22px; }
|
||||
.onboarding-questions li { margin:4px 0; }
|
||||
.onboarding-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0 4px; }
|
||||
.onboarding-actions .btn { display:inline-flex; align-items:center; min-height:44px; text-decoration:none; }
|
||||
.onboarding-draft { max-height:320px; overflow:auto; border:1px solid var(--line); border-radius:8px; padding:12px 16px; margin:8px 0; overflow-wrap:anywhere; }
|
||||
.onboarding-draft:focus-visible { outline:2px solid var(--action, currentColor); outline-offset:2px; }
|
||||
.onboarding-verb, .onboarding-status { color:var(--muted); font-size:13px; overflow-wrap:anywhere; }
|
||||
.onboarding-status:empty { display:none; }
|
||||
@media (max-width:699px) { .onboarding-actions .btn { width:100%; justify-content:center; } }
|
||||
@@ -0,0 +1,133 @@
|
||||
/* F2 (REQ-SETUP-2): the onboarding interview after Quick Setup, and the north-star draft on the project page.
|
||||
The page holds no authority of its own. The interview opens through the chat routes the Projects page
|
||||
already uses; the assistant writes its draft with its own record grant; Approve posts the draft and spec
|
||||
commits the user saw to /api/north-star/approve, which saves through the human save verb. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/, UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, COMMIT = /^[0-9a-f]{40}$/;
|
||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const normal = text => String(text ?? '').replace(/\r\n?/g, '\n').trim();
|
||||
// An explicit organization is addressed directly, so a page shown inside another organization still
|
||||
// reaches the one Quick Setup just made. Without one, display.js scopes /api/ to the current organization.
|
||||
const base = org => org ? `/api/orgs/${encodeURIComponent(org)}/` : '/api/';
|
||||
async function call(url, body) {
|
||||
const init = body === undefined ? { cache: 'no-store', redirect: 'manual' }
|
||||
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), redirect: 'manual' };
|
||||
let response;
|
||||
try { response = await fetch(url, init); } catch { return { status: 0, body: { error: 'network', sent: body === undefined ? undefined : 'unknown' } }; }
|
||||
const value = await response.json().catch(() => null);
|
||||
return { status: response.status, body: value && typeof value === 'object' ? value : { error: 'unreadable', ...(body === undefined ? {} : { sent: 'unknown' }) } };
|
||||
}
|
||||
const interviews = new Map();
|
||||
function interview(org, repo) {
|
||||
const key = `${org || ''}\0${repo}`;
|
||||
if (!interviews.has(key)) interviews.set(key, call(`${base(org)}onboarding/interview?project=${encodeURIComponent(repo)}`).then(r => {
|
||||
if (r.status !== 200) { interviews.delete(key); throw Object.assign(Error(r.body.message || `The interview could not be read (${r.body.error || r.status}).`), { code: r.body.error }); }
|
||||
return r.body;
|
||||
}));
|
||||
return interviews.get(key);
|
||||
}
|
||||
const refusal = (r, what) => r.body.sent === 'unknown' || r.status === 0
|
||||
? `${what}: the outcome is unknown. Check Conversations before you try again.`
|
||||
: `${what} was refused (${r.body.message || r.body.error || r.status}).`;
|
||||
|
||||
// Opens a conversation with the assistant in the chat project and sends the interview's first message.
|
||||
// `chatProject` is the chat project id, `repo` the records project the drafts go to.
|
||||
async function start({ org = null, chatProject, repo, status = () => {} }) {
|
||||
if (!UUID.test(chatProject || '') || !ID.test(repo || '')) { status('This project cannot start an interview.'); return null; }
|
||||
let plan;
|
||||
try { plan = await interview(org, repo); } catch (e) { status(e.message); return null; }
|
||||
if (!plan.assistant || typeof plan.assistant.instance !== 'string') { status('No assistant is configured for this organization, so the interview cannot start.'); return null; }
|
||||
status('Opening the interview conversation…');
|
||||
let created;
|
||||
// A second interview in one project gets a numbered name; the first one stays where it was.
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
created = await call(`${base(org)}chat/threads`, { project: chatProject, name: n === 1 ? plan.threadName : `${plan.threadName} ${n}`, instance: plan.assistant.instance });
|
||||
if (created.status !== 409 || created.body.error !== 'thread-exists') break;
|
||||
}
|
||||
if (created.status !== 200 && created.status !== 201) { status(refusal(created, 'Opening the conversation')); return null; }
|
||||
if (!UUID.test(created.body.id || '')) { status('Opening the conversation: the reply could not be read, so its outcome is unknown. Check Conversations before you try again.'); return null; }
|
||||
const thread = created.body.id;
|
||||
const sent = await call(`${base(org)}chat/threads/${thread}/messages`, { body: plan.message });
|
||||
const ok = (sent.status === 200 || sent.status === 201) && UUID.test(sent.body.id || '');
|
||||
go(org, thread);
|
||||
if (!ok) status(`${refusal(sent, 'The first interview message')} The conversation is open; send the interview message from there.`);
|
||||
else status(`Interview started with ${plan.assistant.name}.`);
|
||||
return { thread, sent: ok };
|
||||
}
|
||||
function go(org, thread) {
|
||||
const current = window.MosaicOrg?.id ?? null;
|
||||
if (!org || org === current) { location.hash = `#/projects/threads/${thread}`; return; }
|
||||
history.replaceState(null, '', `#/orgs/${encodeURIComponent(org)}/projects/threads/${thread}`);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// The project page's panel. It offers the interview while the north star is still the template
|
||||
// project init wrote, and shows the assistant's draft with Approve when the draft is newer than the
|
||||
// north star and says something else.
|
||||
const runs = new WeakMap(), notes = new Map(), drafts = new Map();
|
||||
let config = null;
|
||||
async function offer(container, { project, section, north, docs, threads = [], canSend = false }) {
|
||||
const run = (runs.get(container) || 0) + 1; runs.set(container, run);
|
||||
const slot = () => container.parentElement?.querySelector(':scope > [data-onboarding]');
|
||||
const clear = () => slot()?.remove();
|
||||
if (!['overview', 'requirements'].includes(section) || project?.archived || !ID.test(project?.repo || '') || typeof north?.content !== 'string' || !COMMIT.test(north.commit || '')) return clear();
|
||||
let plan;
|
||||
try { plan = await interview(null, project.repo); } catch { return clear(); }
|
||||
const listed = Array.isArray(docs?.files) && docs.files.some(f => f?.path === plan.drafts.northStar);
|
||||
let draft = null;
|
||||
if (listed) {
|
||||
const key = `${project.repo}\0${north.commit}`;
|
||||
const r = await call(`/api/project-file?project=${encodeURIComponent(project.repo)}&path=${encodeURIComponent(plan.drafts.northStar)}`);
|
||||
if (r.status === 200 && typeof r.body.content === 'string' && COMMIT.test(r.body.commit || '')) { draft = r.body; drafts.set(key, draft); }
|
||||
else draft = drafts.get(key) ?? null;
|
||||
}
|
||||
if (!config) config = await call('/api/config').then(r => r.status === 200 ? r.body : null);
|
||||
if (runs.get(container) !== run || !container.isConnected) return;
|
||||
const template = normal(north.content) === normal(plan.northStarTemplate);
|
||||
// `committed` is each file's last commit time; `at` on a Console read is only when it was read.
|
||||
const pending = draft && draft.content.trim() && normal(draft.content) !== normal(north.content) && Date.parse(draft.committed) >= Date.parse(north.committed);
|
||||
const note = notes.get(project.repo);
|
||||
if (!template && !pending && !note) return clear();
|
||||
const existing = threads.filter(t => !t.shared && t.project === project.id && (t.name === plan.threadName || t.name.startsWith(plan.threadName + ' ')));
|
||||
const approve = config?.northStarApprove === true;
|
||||
const verb = `mosaic project north-star ${project.repo}${window.MosaicOrg ? ` --business ${window.MosaicOrg.id}` : ''}`;
|
||||
const signature = JSON.stringify([template, pending ? [draft.commit, north.commit] : null, existing.map(t => t.id), approve, canSend, notes.get(project.repo)]);
|
||||
let panel = slot();
|
||||
if (panel?.dataset.signature === signature) return;
|
||||
if (!panel) { panel = document.createElement('section'); panel.dataset.onboarding = ''; panel.className = 'project-panel onboarding-panel'; panel.setAttribute('aria-labelledby', 'onboarding-title'); container.before(panel); }
|
||||
panel.dataset.signature = signature;
|
||||
// After an approval the spec holds the draft, so neither offer applies; the receipt stays until reload.
|
||||
if (!template && !pending) { panel.innerHTML = `<h2 id="onboarding-title">North star approved</h2><p class="onboarding-status" role="status">${esc(note)}</p>`; return; }
|
||||
panel.innerHTML = `<h2 id="onboarding-title">${pending ? 'North star draft ready' : 'Onboarding interview'}</h2>`
|
||||
+ (pending
|
||||
? `<p>The assistant drafted a north star in <code>${esc(plan.drafts.northStar)}</code> (commit <code>${esc(draft.commit.slice(0, 12))}</code>). Read it below. Approving copies this exact draft into <code>spec/north-star.md</code> as your commit.</p><div class="onboarding-draft" data-onboarding-draft tabindex="0" aria-label="North star draft"></div>`
|
||||
+ (approve ? `<div class="onboarding-actions"><button type="button" class="btn primary" data-onboarding-approve>Approve as north star</button></div>` : '<p>Approving is not available in this view.</p>')
|
||||
+ `<p class="onboarding-verb">Same as <code>${esc(verb)} --approve ${esc(draft.commit)} --base ${esc(north.commit)}</code></p>`
|
||||
: `<p>The north star is still the template. The assistant can interview you, one question at a time, and draft the north star and a first PRD for you to approve.</p>`
|
||||
+ `<ol class="onboarding-questions">${plan.questions.map(q => `<li>${esc(q.text)}</li>`).join('')}</ol>`)
|
||||
+ `<div class="onboarding-actions">${existing.map(t => `<a class="btn" href="#/projects/threads/${esc(t.id)}">Open ${esc(t.name)}</a>`).join('')}`
|
||||
+ (!pending && canSend && plan.assistant ? `<button type="button" class="btn${existing.length ? '' : ' primary'}" data-onboarding-start>${existing.length ? 'Start a new interview' : 'Start onboarding interview'}</button>` : '')
|
||||
+ `</div>${!pending && !plan.assistant ? '<p>No assistant is configured, so the interview cannot start.</p>' : ''}<p class="onboarding-status" role="status">${esc(note || '')}</p>`;
|
||||
const view = panel.querySelector('[data-onboarding-draft]');
|
||||
if (view) view.replaceChildren(window.MosaicMarkdown?.render(draft.content) || document.createTextNode(draft.content));
|
||||
const status = text => { const el = panel.querySelector('.onboarding-status'); if (el) el.textContent = text; };
|
||||
panel.querySelector('[data-onboarding-start]')?.addEventListener('click', async e => {
|
||||
e.currentTarget.disabled = true;
|
||||
const done = await start({ chatProject: project.id, repo: project.repo, status });
|
||||
if (!done) e.currentTarget.disabled = false;
|
||||
});
|
||||
panel.querySelector('[data-onboarding-approve]')?.addEventListener('click', async e => {
|
||||
const button = e.currentTarget; button.disabled = true; status('Approving…');
|
||||
const r = await call('/api/north-star/approve', { project: project.repo, draft: draft.commit, base: north.commit });
|
||||
if (r.status === 200 && COMMIT.test(r.body.commit || '')) {
|
||||
notes.set(project.repo, `Approved. spec/north-star.md is now commit ${r.body.commit.slice(0, 12)}. The product manager and CTO get the spec notice.`);
|
||||
status(notes.get(project.repo)); button.remove();
|
||||
return;
|
||||
}
|
||||
status(r.body.sent === 'unknown' || r.status === 0 ? 'The approval may have been saved. Reload the project before you approve again.' : (r.body.message || `Not approved (${r.body.error || r.status}).`));
|
||||
if (r.body.sent === false) button.disabled = false;
|
||||
});
|
||||
}
|
||||
window.MosaicOnboarding = Object.freeze({ start, offer });
|
||||
})();
|
||||
@@ -38,7 +38,7 @@
|
||||
<label class="org-quick-risk"><input id="org-risk-accept" type="checkbox"> I understand local-only repositories have no copy elsewhere and can be lost.</label>
|
||||
<div class="settings-actions"><button type="button" id="org-quick-run">Create and set up organization</button></div>
|
||||
<ol id="org-quick-steps" class="org-steps" aria-live="polite"></ol>
|
||||
<div class="settings-actions"><button type="button" id="org-quick-open-org" hidden>Open organization</button></div>
|
||||
<div class="settings-actions"><button type="button" id="org-quick-interview" hidden>Start onboarding interview</button><button type="button" class="org-secondary" id="org-quick-open-org" hidden>Open organization</button></div>
|
||||
</section>
|
||||
<section id="org-editor" class="org-editor" hidden aria-labelledby="org-editor-title">
|
||||
<div class="org-editor-head"><h3 id="org-editor-title">New organization</h3><p id="org-editor-verb" class="org-verb"></p></div>
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
function mount() {
|
||||
const root = $('org-admin'); if (!root) return () => {};
|
||||
let alive = true, canWrite = false, editorDirty = false, presets = [];
|
||||
let alive = true, canWrite = false, editorDirty = false, presets = [], quickDone = null;
|
||||
const setupDirty = new Set();
|
||||
window.MosaicOrgCreate.hasDraft = () => editorDirty || setupDirty.size > 0 || !!$('org-new-id')?.value.trim();
|
||||
// mode: 'create' | 'edit'; doc: the one canonical (browser) document.
|
||||
@@ -272,7 +272,7 @@
|
||||
$('org-preset').value = presets.find(p => p.available.ok)?.name || '';
|
||||
updatePreset();
|
||||
$('org-paths').hidden = false; $('org-quick').hidden = true; $('org-editor').hidden = true; $('org-receipt').hidden = true; $('org-setup').hidden = true; $('org-delete').hidden = true; $('org-delete-success').hidden = true; $('org-archive').hidden = true; $('org-archived').hidden = true; resetArchive();
|
||||
$('org-quick-steps').innerHTML = ''; $('org-quick-open-org').hidden = true; banner(''); setQuery(null);
|
||||
$('org-quick-steps').innerHTML = ''; $('org-quick-open-org').hidden = true; $('org-quick-interview').hidden = true; quickDone = null; banner(''); setQuery(null);
|
||||
$('org-preset').focus();
|
||||
}
|
||||
function updatePreset() {
|
||||
@@ -335,10 +335,11 @@
|
||||
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 (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.');
|
||||
if (step === 'chat-project') quickDone = { org: id, chatProject: result.body.id, repo: project };
|
||||
card.querySelector('.org-pill').textContent = 'Done'; card.classList.add('org-step-done');
|
||||
if (result.body.verb) card.querySelector('code').textContent = result.body.verb;
|
||||
}
|
||||
editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; status('Organization setup complete.');
|
||||
editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; $('org-quick-interview').hidden = !window.MosaicOnboarding; status('Organization setup complete.');
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
const card = list.children[Math.min(current, steps.length - 1)];
|
||||
@@ -603,6 +604,8 @@
|
||||
[$('org-quick-open'), 'click', openQuick],
|
||||
[$('org-advanced-open'), 'click', openAdvanced],
|
||||
[$('org-quick-run'), 'click', quickRun],
|
||||
// F2: the interview opens a conversation with the assistant; Open organization skips it.
|
||||
[$('org-quick-interview'), 'click', async () => { const b = $('org-quick-interview'); b.disabled = true; if (!await window.MosaicOnboarding?.start({ ...quickDone, status })) b.disabled = false; }],
|
||||
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/projects`); location.reload(); }],
|
||||
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
|
||||
[$('org-form'), 'submit', save],
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
// Retained controls (the request selector) draw from the newest accepted read, even when
|
||||
// this read changes nothing visible and the DOM is kept.
|
||||
contentModels.set(container,{data,project,section,canSend,roomReceipt});
|
||||
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if (contentPaints.get(container) === signature) return;
|
||||
contentPaints.set(container, signature);
|
||||
// Capture at commit time: the user may have interacted while the read was pending.
|
||||
|
||||
@@ -9,7 +9,8 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { THREAD_DEFINITION, THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { OnboardingError, approveNorthStarDraft, interviewMessage, loadInterviewTemplate, northStarTemplateText } from '../../project/src/onboarding.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { projectArchiveHTTP } from './project-archive-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
@@ -54,12 +55,12 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
['/files.css', ['files.css', 'text/css; charset=utf-8']],
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
|
||||
@@ -638,12 +639,82 @@ async function projectFileRead(res, read, path, search) {
|
||||
? typeof result?.content === 'string' && COMMIT.test(result.commit) && COMMIT.test(result.head) && typeof result.path === 'string'
|
||||
: COMMIT.test(result?.head) && Array.isArray(result.files) && result.files.every(f => typeof f?.path === 'string');
|
||||
if (!valid) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
// `at` is the read time, as on every Console read. F2: a file read also says when the file's last
|
||||
// commit was made (`committed`), so the onboarding panel can tell a draft newer than the north star.
|
||||
const committed = file && typeof result.at === 'string' && Number.isFinite(Date.parse(result.at)) ? { committed: new Date(result.at).toISOString() } : {};
|
||||
return json(res, 200, { ...result, ...committed, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// F2 (REQ-SETUP-2): the onboarding interview after Quick Setup. The read gives the page the first
|
||||
// message, the questions, the draft paths and the assistant instance the conversation opens with. The
|
||||
// approval has the save's boundary (header user, exact Origin, same-origin Fetch Metadata, JSON, strict
|
||||
// keys) around approveNorthStarDraft, which reads the draft and writes only through the human save verb.
|
||||
const RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const APPROVE_KEYS = ['project', 'draft', 'base'];
|
||||
async function onboardingRead(res, { bus, search }) {
|
||||
const query = new URLSearchParams(search);
|
||||
if ([...query.keys()].some(k => k !== 'project') || query.getAll('project').length !== 1 || !RECORD_ID.test(query.get('project')))
|
||||
return json(res, 400, { error: 'invalid-request', message: 'an interview read takes a project' });
|
||||
let template, message;
|
||||
try { template = loadInterviewTemplate(); message = interviewMessage(template, { project: query.get('project') }); }
|
||||
catch (err) { return json(res, 503, { error: 'onboarding-template-invalid', message: err.message }); }
|
||||
if (!bus?.instances) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
let assistant = null;
|
||||
try {
|
||||
const value = await bus.instances();
|
||||
if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances)) throw new BusReadError('invalid-response');
|
||||
// The first assistant by instance key; a choice between several is a follow-up.
|
||||
const key = Object.keys(value.instances).sort().find(k => value.instances[k]?.definition === THREAD_DEFINITION);
|
||||
if (key !== undefined) {
|
||||
const name = value.instances[key].vars?.['persona.name'] || key;
|
||||
if (typeof name !== 'string') throw new BusReadError('invalid-response');
|
||||
assistant = { instance: key, name };
|
||||
}
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code });
|
||||
}
|
||||
return json(res, 200, { id: template.id, threadName: template.threadName, message, questions: template.questions,
|
||||
drafts: template.drafts, northStarTemplate: northStarTemplateText(), assistant, at: new Date().toISOString() });
|
||||
}
|
||||
async function northStarApprove(req, res, { projectFile, projectSave, preview, log }) {
|
||||
let target = null;
|
||||
// Values are quoted and cut short so a crafted one can't forge a log line. No content is logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} north-star-approve user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} draft=${quoted(target?.draft)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'an approval needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'an approval needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== APPROVE_KEYS.length || !APPROVE_KEYS.every(k => typeof value[k] === 'string'))
|
||||
return refuse(400, 'invalid-request', 'an approval takes project, draft and base, all text, nothing else');
|
||||
target = value;
|
||||
let template;
|
||||
try { template = loadInterviewTemplate(); } catch (err) { return refuse(503, 'onboarding-template-invalid', err.message); }
|
||||
let result;
|
||||
try {
|
||||
result = await approveNorthStarDraft({ read: projectFile, save: projectSave, project: value.project, draft: value.draft, base: value.base, drafts: template.drafts });
|
||||
} catch (err) {
|
||||
if (!(err instanceof OnboardingError) || (err.stage === 'save' && !err.cause))
|
||||
return refuse(503, 'outcome-unknown', 'the approval may have been saved; nothing was retried. Reload the project before approving again', 'unknown');
|
||||
if (err.stage === 'check') return refuse(400, err.code, err.message);
|
||||
if (err.stage === 'read') {
|
||||
if (['draft-stale', 'draft-empty'].includes(err.code)) return refuse(409, err.code, `not approved: ${err.message}`);
|
||||
const code = err.cause instanceof BusReadError ? err.cause.code : 'invalid-response';
|
||||
return refuse(busStatus(code), code, `not approved: the draft could not be read (${code}).`);
|
||||
}
|
||||
const { code, sent } = sendOutcome(err.cause);
|
||||
return sent === false
|
||||
? refuse(busStatus(code), code, code === 'stale-base' ? 'not approved: the north star changed since you opened it. Reload, then review again.' : `not approved: the bus refused it (${code}).`)
|
||||
: refuse(503, 'outcome-unknown', 'the approval may have been saved; nothing was retried. Reload the project before approving again', 'unknown');
|
||||
}
|
||||
line(`commit=${result.commit}`);
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// Row 225 (C2-10b, design 7.4): the chat's library status line and a write notice's Revert. The status
|
||||
// is a read like the file list. The revert is her write (C2-02's `library.revert`), with the save's
|
||||
// boundary: header user, exact Origin, same-origin Fetch Metadata, JSON, strict keys. Until the verb
|
||||
@@ -871,7 +942,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
|
||||
}
|
||||
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
|
||||
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
|
||||
return json(res, 403, { error: 'unavailable', sent: false });
|
||||
}
|
||||
if (hook) {
|
||||
@@ -964,6 +1035,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview: signer, log });
|
||||
}
|
||||
// F2: in an organization this is the one project write past the fence above; the human save verb
|
||||
// still decides the author, the base and the path. A granted user and the tester never reach it.
|
||||
if (path === '/api/north-star/approve') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (tester || granted || !send || !projectSave || !projectFile || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return northStarApprove(req, res, { projectFile, projectSave, preview: signer, log });
|
||||
}
|
||||
if (path === '/api/onboarding/interview') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return onboardingRead(res, { bus, search });
|
||||
}
|
||||
// Message sending has its own bounded payload and repeats the identity check.
|
||||
if (send && path === '/api/bus/message') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -1056,7 +1138,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// 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 '../../business/src/org-service.mjs';
|
||||
import * as registry from '../../business/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);
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
// F2 (PRD 1.20 draft REQ-SETUP-2): the onboarding interview read and the north-star approval route.
|
||||
// The approval has the project save's boundary and writes only through the human save verb; in an
|
||||
// organization it is the one project write past the org fence. Every transport here is a fake.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { startServer, userRoute } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { createSsoSession } from './helpers/sso-session.mjs';
|
||||
import { testerFixture } from './tester-fixture.mjs';
|
||||
import { TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
import { interviewMessage, loadInterviewTemplate, northStarTemplateText, SPEC_PATH } from '../../project/src/onboarding.mjs';
|
||||
|
||||
const TEMPLATE = loadInterviewTemplate();
|
||||
const DRAFT = TEMPLATE.drafts.northStar;
|
||||
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 payload = { project: 'stack', draft: D1, base: S1 };
|
||||
const INSTANCES = { revision: 'f'.repeat(64), definitions: {}, instances: {
|
||||
coder: { definition: 'coder' }, helper: { definition: 'assistant' }, assistant: { definition: 'assistant', vars: { 'persona.name': 'Ada' } },
|
||||
} };
|
||||
|
||||
function transports({ read, save, instances } = {}) {
|
||||
const calls = { file: [], save: [], instances: 0 };
|
||||
return {
|
||||
calls,
|
||||
projectFile: async args => {
|
||||
calls.file.push(args);
|
||||
if (read) return read(args);
|
||||
return args.path === DRAFT ? { path: DRAFT, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T00:00:00Z' }
|
||||
: { path: args.path, head: D1, commit: S1, content: northStarTemplateText(), author: 'jason', at: '2026-11-08T00:00:00Z' };
|
||||
},
|
||||
projectSave: async args => { calls.save.push(args); return save ? save(args) : { path: args.path, commit: S2 }; },
|
||||
bus: { instances: async () => { calls.instances++; return instances ? instances() : structuredClone(INSTANCES); } },
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { enabled = true, file = true, bus = true, ...fakes } = {}) {
|
||||
const lines = [], fake = transports(fakes);
|
||||
const auth = await createSsoSession(t, { host: 'console.example' });
|
||||
const server = await startServer({
|
||||
port: 0, sso: auth.sso, log: l => lines.push(l),
|
||||
send: enabled ? async () => ({}) : null,
|
||||
projectFile: file ? fake.projectFile : null,
|
||||
projectSave: enabled ? fake.projectSave : null,
|
||||
bus: bus ? fake.bus : null,
|
||||
});
|
||||
t.after(() => close(server));
|
||||
const session = await auth.signIn(server.address().port);
|
||||
return { calls: fake.calls, lines, port: server.address().port, headers: auth.writeHeaders(session), read: auth.readHeaders(session) };
|
||||
}
|
||||
function call(s, { method = 'POST', path = '/api/north-star/approve', hs = s.headers, data = payload } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
||||
let text = '';
|
||||
r.on('data', c => text += c);
|
||||
r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: text.startsWith('{') ? JSON.parse(text) : null }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
}
|
||||
const get = (s, path) => call(s, { method: 'GET', path, hs: s.read, data: null });
|
||||
|
||||
test('the interview read gives the first message, the questions, the drafts and the first assistant instance', async t => {
|
||||
const s = await setup(t);
|
||||
const r = await get(s, '/api/onboarding/interview?project=stack');
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(r.body.message, interviewMessage(TEMPLATE, { project: 'stack' }));
|
||||
assert.equal(r.body.threadName, 'Onboarding interview');
|
||||
assert.deepEqual(r.body.questions, TEMPLATE.questions);
|
||||
assert.deepEqual(r.body.drafts, TEMPLATE.drafts);
|
||||
assert.equal(r.body.northStarTemplate, northStarTemplateText());
|
||||
// The first assistant by instance key, named by its persona when it has one.
|
||||
assert.deepEqual(r.body.assistant, { instance: 'assistant', name: 'Ada' });
|
||||
assert.equal(s.calls.instances, 1);
|
||||
assert.deepEqual(s.calls.file, [], 'the read asks the bus for nothing but the instances');
|
||||
});
|
||||
|
||||
test('the interview read takes one project id, answers 405 to a write and reports a missing or failing bus', async t => {
|
||||
const s = await setup(t);
|
||||
for (const path of ['/api/onboarding/interview', '/api/onboarding/interview?project=a&project=b', '/api/onboarding/interview?project=stack&x=1',
|
||||
'/api/onboarding/interview?project=..%2Fx', '/api/onboarding/interview?project=%20x', '/api/onboarding/interview?project='])
|
||||
assert.equal((await get(s, path)).status, 400, path);
|
||||
assert.equal(s.calls.instances, 0);
|
||||
assert.equal((await call(s, { path: '/api/onboarding/interview?project=stack', data: {} })).status, 405);
|
||||
const none = await setup(t, { bus: false });
|
||||
const missing = await get(none, '/api/onboarding/interview?project=stack');
|
||||
assert.equal(missing.status, 503); assert.equal(missing.body.error, 'not-configured');
|
||||
const only = await setup(t, { instances: () => ({ ...INSTANCES, instances: { coder: { definition: 'coder' } } }) });
|
||||
const noAssistant = await get(only, '/api/onboarding/interview?project=stack');
|
||||
assert.equal(noAssistant.status, 200); assert.equal(noAssistant.body.assistant, null);
|
||||
const plain = await setup(t, { instances: () => ({ ...INSTANCES, instances: { b: { definition: 'assistant' } } }) });
|
||||
assert.deepEqual((await get(plain, '/api/onboarding/interview?project=stack')).body.assistant, { instance: 'b', name: 'b' });
|
||||
for (const [instances, status, error] of [
|
||||
[() => { throw new BusReadError('human-required'); }, 403, 'human-required'],
|
||||
[() => { throw new BusReadError('outcome-unknown'); }, 503, 'outcome-unknown'],
|
||||
[() => ({ instances: [] }), 502, 'invalid-response'],
|
||||
[() => ({ instances: { a: { definition: 'assistant', vars: { 'persona.name': 5 } } } }), 502, 'invalid-response'],
|
||||
]) {
|
||||
const bad = await setup(t, { instances });
|
||||
const r = await get(bad, '/api/onboarding/interview?project=stack');
|
||||
assert.equal(r.status, status, error); assert.equal(r.body.error, error);
|
||||
}
|
||||
});
|
||||
|
||||
test('an approval copies the exact reviewed draft into the spec through one human save', async t => {
|
||||
const s = await setup(t), r = await call(s);
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(r.body.path, SPEC_PATH); assert.equal(r.body.commit, S2);
|
||||
assert.deepEqual(r.body.draft, { path: DRAFT, commit: D1 });
|
||||
assert.deepEqual(s.calls.file, [{ project: 'stack', path: DRAFT }]);
|
||||
assert.deepEqual(s.calls.save, [{ project: 'stack', path: SPEC_PATH, base: S1, content: TEXT }]);
|
||||
assert.ok(s.lines.some(l => l.includes('north-star-approve') && l.includes(`commit=${S2}`) && l.includes('project="stack"') && l.includes(`draft="${D1}"`)), s.lines.join('\n'));
|
||||
assert.equal(s.lines.some(l => l.includes('Onboard new organizations')), false, 'the content is never logged');
|
||||
});
|
||||
|
||||
test('an approval needs send, the save and read verbs, the named Host, a session, the exact Origin and same-origin Fetch Metadata', async t => {
|
||||
for (const options of [{ enabled: false }, { file: false }]) {
|
||||
const off = await setup(t, options), refused = await call(off);
|
||||
assert.equal(refused.status, 403, JSON.stringify(options)); assert.equal(refused.body.sent, false);
|
||||
assert.deepEqual(off.calls.file, []); assert.deepEqual(off.calls.save, []);
|
||||
}
|
||||
const s = await setup(t);
|
||||
for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403],
|
||||
['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['cookie', undefined, 401]]) {
|
||||
const hs = { ...s.headers };
|
||||
if (value === undefined) delete hs[key]; else hs[key] = value;
|
||||
const r = await call(s, { hs });
|
||||
assert.equal(r.status, status, `${key}=${value}`);
|
||||
assert.notEqual(r.body?.sent, true);
|
||||
}
|
||||
assert.equal((await call(s, { method: 'GET', hs: s.read, data: null })).status, 405);
|
||||
assert.deepEqual(s.calls.file, []); assert.deepEqual(s.calls.save, []);
|
||||
});
|
||||
|
||||
test('an approval validates JSON, strict keys, text values, the project id and both commits before any read', async t => {
|
||||
const s = await setup(t);
|
||||
for (const data of [{ ...payload, extra: 1 }, { project: 'stack', draft: D1 }, { ...payload, base: 5 }, { ...payload, draft: null }, [], 'null', '5', 'bad', '{',
|
||||
{ ...payload, project: '../x' }, { ...payload, draft: 'abc' }, { ...payload, base: S1.toUpperCase() }]) {
|
||||
const r = await call(s, { data });
|
||||
assert.equal(r.status, 400, JSON.stringify(data)); assert.equal(r.body.sent, false);
|
||||
}
|
||||
assert.equal((await call(s, { hs: { ...s.headers, 'content-type': 'text/plain' } })).status, 400);
|
||||
assert.deepEqual(s.calls.file, []); assert.deepEqual(s.calls.save, []);
|
||||
// A crafted value is quoted in the log, so it can't start a line of its own.
|
||||
await call(s, { data: { ...payload, project: 'x\nforged' } });
|
||||
assert.ok(s.lines.length > 0);
|
||||
assert.equal(s.lines.some(l => l.includes('\n')), false, s.lines.join('|'));
|
||||
});
|
||||
|
||||
test('a draft that changed, is empty or cannot be read is not approved and nothing is saved', async t => {
|
||||
const draftFile = over => args => ({ path: args.path, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T00:00:00Z', ...over });
|
||||
for (const [read, status, error] of [
|
||||
[draftFile({ commit: S2 }), 409, 'draft-stale'],
|
||||
[draftFile({ content: ' \n' }), 409, 'draft-empty'],
|
||||
[() => { throw new BusReadError('project-read-missing'); }, 404, 'project-read-missing'],
|
||||
[() => { throw new BusReadError('human-required'); }, 403, 'human-required'],
|
||||
[draftFile({ commit: undefined }), 502, 'invalid-response'],
|
||||
[() => { throw new Error('socket closed'); }, 502, 'invalid-response'],
|
||||
]) {
|
||||
const s = await setup(t, { read }), r = await call(s);
|
||||
assert.equal(r.status, status, error); assert.equal(r.body.error, error); assert.equal(r.body.sent, false);
|
||||
assert.match(r.body.message, /^not approved: /);
|
||||
assert.deepEqual(s.calls.save, [], error);
|
||||
}
|
||||
});
|
||||
|
||||
test('a save refusal keeps its status; a lost or unusable answer is reported unknown and never retried', async t => {
|
||||
for (const [code, status] of [['stale-base', 409], ['human-required', 403], ['project-busy', 409], ['project-not-configured', 503]]) {
|
||||
const s = await setup(t, { save: () => { throw new BusReadError(code); } }), r = await call(s);
|
||||
assert.equal(r.status, status, code); assert.equal(r.body.error, code); assert.equal(r.body.sent, false);
|
||||
if (code === 'stale-base') assert.equal(r.body.message, 'not approved: the north star changed since you opened it. Reload, then review again.');
|
||||
else assert.equal(r.body.message, `not approved: the bus refused it (${code}).`);
|
||||
assert.equal(s.calls.save.length, 1);
|
||||
}
|
||||
for (const save of [() => { throw new BusReadError('outcome-unknown'); }, () => { throw new Error('socket closed'); }, () => ({ path: SPEC_PATH }),
|
||||
() => ({ path: 'docs/x.md', commit: S2 }), () => ({ path: SPEC_PATH, commit: 'nope' })]) {
|
||||
const s = await setup(t, { save }), r = await call(s);
|
||||
assert.equal(r.status, 503); assert.equal(r.body.error, 'outcome-unknown'); assert.equal(r.body.sent, 'unknown');
|
||||
assert.match(r.body.message, /may have been saved; nothing was retried/);
|
||||
assert.equal(s.calls.save.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('config offers the approval only where the route can pass; granted users and the tester never reach it', async t => {
|
||||
const config = async options => (await get(await setup(t, options), '/api/config')).body;
|
||||
assert.equal((await config({})).northStarApprove, true);
|
||||
assert.equal((await config({ enabled: false })).northStarApprove, false);
|
||||
assert.equal((await config({ file: false })).northStarApprove, false);
|
||||
// A granted user stops at the owner gate before either route.
|
||||
assert.equal(userRoute('/api/north-star/approve', 'POST'), false);
|
||||
assert.equal(userRoute('/api/onboarding/interview', 'GET'), false);
|
||||
// The tester listener refuses projectSave at start, so its approval has no save to reach.
|
||||
const tester = await testerFixture(t);
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
const req = request({ socketPath: tester.web.address(), path: '/api/north-star/approve', method: 'POST', headers: {
|
||||
host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => { r.resume(); r.on('end', () => resolve(r.statusCode)); });
|
||||
req.on('error', reject); req.end(JSON.stringify(payload));
|
||||
});
|
||||
assert.equal(status, 403); assert.deepEqual(tester.calls, []);
|
||||
});
|
||||
|
||||
test('in an organization the approval and the interview read reach only the selected organization; the plain save stays fenced', async t => {
|
||||
const fakes = { alpha: transports(), beta: transports() };
|
||||
const organizations = {
|
||||
list: async () => [],
|
||||
resolve: async id => {
|
||||
if (!(id in fakes)) return null;
|
||||
const { projectFile, projectSave, bus } = fakes[id];
|
||||
return { busHealthy: id === 'alpha', send: async () => ({}), projectFile, projectSave, bus };
|
||||
},
|
||||
};
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const headers = { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const post = async (path, body, extra = {}) => { const r = await fetch(base + path, { method: 'POST', headers: { ...headers, ...extra }, body: JSON.stringify(body) }); return [r.status, await r.json()]; };
|
||||
const get = async path => { const r = await fetch(base + path); return [r.status, await r.json()]; };
|
||||
const [status, body] = await post('/api/orgs/alpha/north-star/approve', payload);
|
||||
assert.equal(status, 200, JSON.stringify(body)); assert.equal(body.commit, S2);
|
||||
assert.equal(fakes.alpha.calls.save.length, 1); assert.equal(fakes.beta.calls.save.length, 0);
|
||||
assert.equal((await post('/api/orgs/alpha/north-star/approve', payload, { origin: 'http://evil.example' }))[0], 403);
|
||||
assert.deepEqual(await post('/api/north-star/approve', payload), [404, { error: 'org-unavailable' }]);
|
||||
assert.deepEqual(await post('/api/orgs/missing/north-star/approve', payload), [404, { error: 'org-unavailable' }]);
|
||||
assert.deepEqual(await post('/api/orgs/alpha/project-file', { project: 'stack', path: SPEC_PATH, base: S1, content: '# x\n' }), [403, { error: 'unavailable', sent: false }]);
|
||||
assert.equal(fakes.alpha.calls.save.length, 1);
|
||||
const alpha = (await get('/api/orgs/alpha/config'))[1];
|
||||
assert.equal(alpha.northStarApprove, true); assert.equal(alpha.edit, false);
|
||||
assert.equal((await get('/api/orgs/beta/config'))[1].northStarApprove, false, 'an offline organization does not offer the approval');
|
||||
const [read, interview] = await get('/api/orgs/alpha/onboarding/interview?project=stack');
|
||||
assert.equal(read, 200); assert.equal(interview.assistant.instance, 'assistant');
|
||||
assert.equal(fakes.alpha.calls.instances, 1); assert.equal(fakes.beta.calls.instances, 0);
|
||||
});
|
||||
|
||||
test('a file read keeps the read time in at and adds the last commit time as committed; a list read does not', async t => {
|
||||
const s = await setup(t);
|
||||
const before = Date.now();
|
||||
const spec = await get(s, `/api/project-file?project=stack&path=${encodeURIComponent(SPEC_PATH)}`);
|
||||
const draft = await get(s, `/api/project-file?project=stack&path=${encodeURIComponent(DRAFT)}`);
|
||||
assert.equal(spec.status, 200, spec.text); assert.equal(draft.status, 200, draft.text);
|
||||
assert.equal(spec.body.committed, '2026-11-08T00:00:00.000Z'); assert.equal(draft.body.committed, '2026-11-09T00:00:00.000Z');
|
||||
for (const r of [spec, draft]) assert.ok(Date.parse(r.body.at) >= before - 1000, 'at stays the read time');
|
||||
const odd = await setup(t, { read: args => ({ path: args.path, head: D1, commit: S1, content: 'x', author: 'jason', at: 'not a time' }) });
|
||||
const plain = await get(odd, `/api/project-file?project=stack&path=${encodeURIComponent(SPEC_PATH)}`);
|
||||
assert.equal(plain.status, 200); assert.equal('committed' in plain.body, false, 'an unreadable commit time is left out');
|
||||
});
|
||||
|
||||
test('the page loads the onboarding script and stylesheet', async t => {
|
||||
const s = await setup(t);
|
||||
const js = await get(s, '/onboarding.js'), css = await get(s, '/onboarding.css'), page = await get(s, '/');
|
||||
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/); assert.match(js.text, /MosaicOnboarding/);
|
||||
assert.equal(css.status, 200); assert.match(css.type, /^text\/css/); assert.match(css.text, /\.onboarding-panel/);
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(page.text, /<script src="\/onboarding\.js"/); assert.match(page.text, /<link [^>]*href="\/onboarding\.css"/);
|
||||
});
|
||||
Reference in new issue
Block a user