Merge refactor c9c409ac4 into rename stage B

Conflict in packages/webui/src/cli.mjs: kept the relative-launch re-exec
from #1876 with stage B's organization import paths.
This commit was merged in pull request #1878.
This commit is contained in:
jason.woltje committed 2026-11-09 13:13:13 -06:00
commit fbf2f6fc89
5 files changed
+118 -7

No files matched your search

+16 -1
View File
@@ -20,10 +20,25 @@ import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { selectorScope } from '../../organization/src/selector.mjs';
// The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a
// relative shell argument even though Node's process.argv resolves it. Re-exec the same
// program with its absolute path before opening anything; preserve PID, ancestry and env so
// the existing human and Console proof checks remain unchanged.
if (process.platform === 'linux') {
const raw = readFileSync('/proc/self/cmdline', 'utf8').split('\0');
let at = 1 + process.execArgv.length;
if (raw[at] === '--') at += 1; // Node leaves its option delimiter out of execArgv.
const script = raw[at];
if (script && !isAbsolute(script)) {
if (typeof process.execve !== 'function') throw new Error('relative Console launch requires process.execve; use an absolute script path');
process.execve(process.execPath, [process.execPath, ...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], process.env);
}
}
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
+8 -3
View File
@@ -209,7 +209,7 @@
const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3><p>${n!==null?clean(n):data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p><h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:`<p>${data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p>`}<h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
}
function tasksView(data) {
const refs=taskRefs(data);
@@ -258,7 +258,7 @@
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, section==='requirements' ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
revealTab(header);
@@ -276,7 +276,7 @@
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
const north=container.querySelector('[data-north-star]'); if(north && data.north?.content) north.replaceChildren(window.MosaicMarkdown?.render(data.north.content) || document.createTextNode(data.north.content));
const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); }
container.querySelector('#project-request-select')?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector('#project-request-select')?.focus({preventScroll:true});});
};
draw(selection);
@@ -287,6 +287,11 @@
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
}
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
function previewCut(content,max) {
const hard=content.slice(0,max), soft=hard.replace(/\s+\S*$/, '');
return (/[^\s#>*+\-\d.`]/.test(soft.split('\n').pop()) ? soft : hard)+'…';
}
async function enhanceList(container,projects,threads) {
for(const project of projects.filter(p=>!p.archived)) {
const slot=container.querySelector(`[data-project-status="${project.id}"]`); if(!slot) continue;
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
const root = fileURLToPath(new URL('../../../', import.meta.url));
const script = 'packages/webui/src/cli.mjs';
test('a relative Console launch presents the same-tree absolute parent path to the bus', { timeout: 30000 }, async t => {
const env = { ...process.env, MOSAIC_CONFIG: join(process.env.HOME, '.config/mosaic-reltest/config.json'), MOSAIC_AGENT_RUN: 'synthetic-test-marker' };
const setup = spawnSync(join(root, 'scripts/bootstrap.sh'), ['--instance', 'reltest'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const child = spawn(process.execPath, [script, 'serve', '--dev-local-owner', '--port', '0'], { cwd: root, env });
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) {
const exited = new Promise(resolve => child.once('exit', resolve));
child.kill('SIGTERM');
await exited;
}
});
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk; });
await new Promise((resolve, reject) => {
let out = '';
const timer = setTimeout(() => reject(Error('Console did not start: ' + stderr)), 20000);
child.stdout.on('data', chunk => { out += chunk; if (out.includes('Mosaic Console:')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error(`Console exited ${code}: ${stderr}`)); });
child.once('error', reject);
});
const argv = readFileSync(`/proc/${child.pid}/cmdline`, 'utf8').split('\0');
assert.equal(argv[1], join(root, script));
assert.deepEqual(argv.slice(2, 6), ['serve', '--dev-local-owner', '--port', '0']);
// Re-exec must not erase the ancestry marker that makes an agent fail human proof.
assert.ok(readFileSync(`/proc/${child.pid}/environ`, 'utf8').split('\0').includes('MOSAIC_AGENT_RUN=synthetic-test-marker'));
});
test('Node\'s -- delimiter: an absolute script loads once, a relative one re-execs once', { timeout: 30000 }, () => {
const dir = mkdtempSync(join(tmpdir(), 'rl'));
const count = join(dir, 'loads');
const preload = join(dir, 'count.cjs');
writeFileSync(preload, `require('node:fs').appendFileSync(${JSON.stringify(count)}, 'x');\n`);
const loads = args => {
writeFileSync(count, '');
const r = spawnSync(process.execPath, ['--require', preload, '--', ...args, '--help'], { cwd: root, env: process.env, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
return readFileSync(count, 'utf8').length;
};
assert.equal(loads([join(root, script)]), 1);
assert.equal(loads([script]), 2);
});
+17 -3
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path';
import { browser } from './browser.mjs';
const log = join(homedir(), 'orgdev-qconsole.log');
for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real exchange and records: ${scenario}`, { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
for (const scenario of ['records', 'fresh', 'recover', 'cli']) test(`Quick Setup real exchange and records: ${scenario}`, { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
const url = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(readFileSync(log, 'utf8'));
assert.ok(url, 'scratch Console advertised a URL');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url[2])), 'Console must use an allowed port');
@@ -33,12 +33,22 @@ for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real excha
await click('#org-quick-open');
await wait('document.querySelector("#org-quick") && !document.querySelector("#org-quick").hidden');
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
if (scenario !== 'fresh') await b.evaluate(`(() => { const real=window.fetch; let first=true; window.fetch=(url,opts={}) => { if(first && String(url).includes('/setup/records/') && opts.method==='POST') { first=false; return Promise.resolve(new Response(JSON.stringify({error:'test-stop',message:'Stopped for records recovery test'}),{status:409,headers:{'content-type':'application/json'}})); } return real(url,opts); }; })()`);
if (scenario === 'fresh' || scenario === 'records') await b.evaluate(`(() => {const real=window.fetch;window.__setupPosts=[];window.fetch=async(url,opts={})=>{const r=await real(url,opts);if(opts.method==='POST' && (String(url)==='/api/orgs' || String(url).includes('/setup/') || String(url).endsWith('/chat/projects')))window.__setupPosts.push({url:String(url),request:opts.body,status:r.status,body:await r.clone().json()});return r;};})()`);
if (!['fresh', 'records'].includes(scenario)) await b.evaluate(`(() => { const real=window.fetch; let first=true; window.fetch=(url,opts={}) => { if(first && String(url).includes('/setup/records/') && opts.method==='POST') { first=false; return Promise.resolve(new Response(JSON.stringify({error:'test-stop',message:'Stopped for records recovery test'}),{status:409,headers:{'content-type':'application/json'}})); } return real(url,opts); }; })()`);
await click('#org-risk-accept'); await click('#org-quick-run');
await wait(`document.querySelector('[data-quick-step="5"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
console.log('setup reached a terminal step');
const states = await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)');
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done', scenario === 'fresh' ? 'Done' : 'Stopped'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done', ['fresh', 'records'].includes(scenario) ? 'Done' : 'Stopped'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
if (scenario === 'fresh' || scenario === 'records') {
const calls = await b.evaluate('window.__setupPosts');
assert.deepEqual(calls.map(c => c.url), ['/api/orgs', `/api/orgs/${id}/setup/queue`, `/api/orgs/${id}/setup/project/work`, `/api/orgs/${id}/setup/bus`, `/api/orgs/${id}/chat/projects`, `/api/orgs/${id}/setup/records/work`]);
assert.equal(calls[5].request, '{}'); assert.equal(calls[5].status, 200);
assert.equal(calls[5].body.outcome, 'done'); assert.equal(calls[5].body.step, 'records');
const config = readdirSync(join(homedir(), '.config')).find(name => /^mosaic-[a-z][a-z0-9]{1,15}$/.test(name));
const { dataRoot } = JSON.parse(readFileSync(join(homedir(), '.config', config, 'config.json'), 'utf8'));
assert.ok(existsSync(join(dataRoot, 'org-runtime', id, 'projects', id, 'work', 'spec', 'north-star.md')), 'records repository exists before any recovery action');
}
assert.equal(await shown('#org-quick-open-org'), true, 'the finished organization can be opened');
await click('#org-quick-open-org'); console.log('opening organization');
await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && document.querySelector('.project-card')?.textContent.includes('Repository: work')`, 30000);
@@ -63,6 +73,10 @@ for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real excha
const projectFiles = await b.evaluate(`fetch('/api/orgs/${id}/project-files?project=work').then(async r => ({ status:r.status, body:await r.json() }))`);
assert.equal(projectFiles.status, 200, JSON.stringify(projectFiles));
assert.ok(projectFiles.body.files.some(f => f.path === 'spec/north-star.md'));
const north = await b.evaluate(`fetch('/api/orgs/${id}/project-file?project=work&path=spec%2Fnorth-star.md').then(async r => ({status:r.status,body:await r.json()}))`);
assert.equal(north.status, 200, JSON.stringify(north)); assert.match(north.body.content, /^# North star/);
if (scenario === 'fresh' || scenario === 'records') assert.equal(await b.evaluate(`!!document.querySelector('[data-record-init]')`), false, 'no manual initialization needed');
if (scenario === 'records') return;
const projectRoute = await b.evaluate('location.hash');
await b.evaluate(`location.hash=${JSON.stringify(projectRoute + '/conversations')}`);
await wait('!!document.querySelector("#chat-thread-form")');
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
test('overview renders a safe, bounded north star markdown preview and links to Docs', { timeout: 45000 }, async t => {
const f = await fixture(), b = await browser({ machine: false });
t.after(async () => { await b.close(); await f.close(); });
const markdown = '# A clear direction\n\n<script>window.northInjected=true</script>\n\n' + 'A long goal. '.repeat(200);
const source = stub.replace('A reliable Pomodoro timer that opens locally and works offline.', markdown.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n'));
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
await b.viewport(1440, 900); await b.navigate(f.base + '/#/projects/' + P);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-project-content] .project-metrics'))return;await new Promise(r=>setTimeout(r,30));}throw Error('overview did not load')})()`);
const result = await b.evaluate(`(()=>{const p=document.querySelector('[data-north-star]');return {heading:p?.querySelector('h1')?.textContent, html:p?.querySelector('script')!==null, injected:!!window.northInjected, length:p?.textContent.length, link:document.querySelector('[data-project-content] a[href="#/docs/timer/spec%2Fnorth-star.md"]')?.textContent}})()`);
assert.equal(result.heading, 'A clear direction');
assert.equal(result.html, false); assert.equal(result.injected, false);
assert.ok(result.length < 700, 'preview is bounded'); assert.equal(result.link, 'Open north star in Docs');
await b.evaluate(`{const old=window.fetch;window.fetch=(u,o)=>String(u).startsWith('/api/project-file?')?Promise.resolve(new Response(JSON.stringify({content:'# Updated direction'}),{headers:{'content-type':'application/json'}})):old(u,o);document.querySelector('#refresh').click();}`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-north-star] h1')?.textContent==='Updated direction')return;await new Promise(r=>setTimeout(r,30));}throw Error('north star did not refresh')})()`);
// A heading longer than the preview keeps its text instead of collapsing to the marker.
await b.evaluate(`{const old=window.fetch;window.fetch=(u,o)=>String(u).startsWith('/api/project-file?')?Promise.resolve(new Response(JSON.stringify({content:'# '+'A'.repeat(600)}),{headers:{'content-type':'application/json'}})):old(u,o);document.querySelector('#refresh').click();}`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if((document.querySelector('[data-north-star] h1')?.textContent||'').startsWith('AAAA'))return;await new Promise(r=>setTimeout(r,30));}throw Error('long heading collapsed: '+document.querySelector('[data-north-star]')?.textContent)})()`);
});