The overview's Project direction panel printed north-star.md source as one
paragraph. It now mounts the same DOM-building markdown renderer the
requirements view uses, trims the preview to 500 characters at a word
boundary, and links to the full document in Docs. No raw HTML from the
document reaches the page; the fallback is a text node.
Quick Setup leaving no records was not reproduced on 00ef0bd30: all six
steps report done and the records step writes where the project view
reads. org-quick-live gains a records case that checks the six setup
POSTs, the records repository on disk and the live north-star read with
no button press. The manual records button stays for older projects.
Tests: projects-north-star-browser 1/0 (red before the change),
org-quick-live 4/0 twice on a fresh scratch instance.
92 lines
9.5 KiB
JavaScript
92 lines
9.5 KiB
JavaScript
// Live scratch-only acceptance. Run with HOME set to a bootstrapped scratch
|
|
// instance whose fixture and Console are already running (see BUILD.md).
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { browser } from './browser.mjs';
|
|
|
|
const log = join(homedir(), 'orgdev-qconsole.log');
|
|
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');
|
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
|
const wait = async (expression, timeout = 20000) => { const end = Date.now() + timeout; while (Date.now() < end) { try { if (await b.evaluate(`Boolean(${expression})`)) return; } catch {} await new Promise(r => setTimeout(r, 50)); } throw Error(`Timed out: ${expression}`); };
|
|
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
|
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
|
|
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e&&getComputedStyle(e).display!=='none'&&getComputedStyle(e).visibility!=='hidden'&&e.getClientRects().length>0})()`);
|
|
const existing = await (await fetch(url[1] + 'api/orgs')).json();
|
|
const id = '0123456789abcdefghijklmnopqrstuvwxyz'.split('').map(c => 'q' + c).find(candidate => !existing.some(o => o.id === candidate));
|
|
assert.ok(id, 'a short unused scratch organization ID is available');
|
|
const start = existing.length ? `#/orgs/${encodeURIComponent(existing[0].id)}/settings/organizations` : '#/settings/organizations';
|
|
await b.navigate(url[1] + start);
|
|
await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
|
|
assert.equal(await shown('#org-new-form [data-write]'), true);
|
|
await input('#org-new-id', id); await click('#org-new-form [data-write]');
|
|
await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
|
|
assert.equal(await b.evaluate('document.querySelector("#org-preset").value'), 'fixture');
|
|
for (const selector of ['#org-preset', '#org-thinking', '#org-quick-open', '#org-advanced-open']) assert.equal(await shown(selector), true, selector);
|
|
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' || 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', ['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);
|
|
await click('.project-card');
|
|
await wait('location.hash.match(/\\/projects\\/[0-9a-f-]{36}/i)');
|
|
console.log('project created');
|
|
if (scenario === 'recover') {
|
|
await wait(`document.querySelector('[data-record-init]')`);
|
|
await click('[data-record-init]');
|
|
await wait(`!document.querySelector('[data-record-init]') && document.querySelector('[data-project-content]')?.textContent.includes('North star')`, 30000);
|
|
} else if (scenario === 'cli') {
|
|
const config = readdirSync(join(homedir(), '.config')).find(name => /^mosaic-[a-z][a-z0-9]{1,15}$/.test(name));
|
|
assert.ok(config);
|
|
const uid = process.getuid();
|
|
await promisify(execFile)('/mnt/storage/scratch/mosaic-lead/uidev/org-release/devstack.sh', ['exec', '--', 'scripts/mosaic', 'project', 'init', id, 'work'], { env: {
|
|
PATH: '/mnt/storage/scratch/tmp/q2KNlM/node-v26.12.1-linux-x64/bin:/opt/claude-code/bin:/usr/local/bin:/usr/bin:/bin', HOME: homedir(), TMPDIR: process.env.TMPDIR, LANG: 'C.UTF-8', MOSAIC_CONFIG: join(homedir(), '.config', config, 'config.json'), XDG_RUNTIME_DIR: `/run/user/${uid}`, DBUS_SESSION_BUS_ADDRESS: `unix:path=/run/user/${uid}/bus`,
|
|
} });
|
|
await b.evaluate(`document.querySelector('#refresh').click()`);
|
|
}
|
|
await wait(`document.querySelector('[data-project-content]') && !document.querySelector('[data-project-content]').textContent.includes('Reading')`);
|
|
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-project-content]').textContent`), /no project records|no records yet/i);
|
|
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")');
|
|
await click('.project-new-conversation summary'); await input('#chat-thread-name', 'Trial'); await input('#chat-instance', 'assistant');
|
|
await click('#chat-thread-form button[type=submit]'); await wait('!!document.querySelector("#chat-body")', 30000);
|
|
console.log('conversation created');
|
|
await input('#chat-body', 'Reply with fixture word'); await click('#chat-send-form button[type=submit]');
|
|
await wait('document.querySelector(".chat-log")?.textContent.includes("PINEAPPLE")', 90000);
|
|
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /PINEAPPLE/); console.log('fixture reply received');
|
|
await wait(`document.querySelector('[data-conversation-request-status]') && (document.querySelector('.project-tabs a[href$="/tasks"] .project-count') || document.querySelector('[data-conversation-request-status]').textContent)`, 30000);
|
|
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`), /unavailable/i);
|
|
});
|