import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { fixture } from '../fixture.mjs'; import { browser } from '../browser.mjs'; export const THREAD = '20000000-0000-4000-8000-000000000002'; export const MESSAGE = '40000000-0000-4000-8000-000000000004'; export const REQUEST = '50000000-0000-4000-8000-000000000005'; export const DECISION = '60000000-0000-4000-8000-000000000006'; export const state = extra => ({ version: 1, request: REQUEST, revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null, ...extra }); export const candidate = { request: REQUEST, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'REQ-1', title: 'Timer', path: 'req.json', blob: 'd'.repeat(40) }] }; export const draft = { candidate, prd: 'A timer that counts down.', requirements: [{ id: 'REQ-1', title: 'Timer', text: 'Count down.' }], snapshot: { id: null, revision: 2 } }; export const evidence = { id: 'delivery-1', request: REQUEST, project: 'factory', snapshot: 'snapshot-1', task_ref: 'vikunja:2/41', commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } }; export const wait = (kind, code, subject) => ({ kind, code, subject }); export const question = { id: 'q-1', text: 'Which platform?', answer: null, options: [] }; export async function setup(t, boot, { forwardSend = null } = {}) { const thread = boot.thread || THREAD, project = boot.project || thread, requestId = boot.request || REQUEST; const f = await fixture(); t.after(() => f.close()); const b = await browser({ machine: false }); t.after(() => b.close()); const css = await readFile(new URL('../../src/public/requests.css', import.meta.url), 'utf8'); const source = `(() => { const RealDate = Date; window.Date = class extends RealDate { constructor(...args) { super(...(args.length ? args : ['2026-11-05T12:00:00Z'])); } }; const realFetch = window.fetch.bind(window); const boot = ${JSON.stringify(boot)}; window.__archiveFailure = boot.archiveFailure; window.__archiveRows = boot.archiveRows || []; window.__liveProjects = []; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = []; document.addEventListener('mosaic-request-state', e => window.__events.push(e.detail)); document.addEventListener('DOMContentLoaded', () => { window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } }; }); document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); }); window.fetch = async (url, init = {}) => { const path = String(url), method = init.method || 'GET'; const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}}); if (method === 'POST' && boot.forwardSend && (path.endsWith('/messages') || path.endsWith('/attach'))) return realFetch(url, init); if (method !== 'GET') { window.__writes.push({path, method}); if (path === '/api/decisions/${DECISION}/confirmation' && boot.allowConfirmation) return json({id:'${DECISION}',action:'answer',kind:'decision',choice:'yes',text:'Approve exact PRD',question:'Approve the exact PRD?',confirmation:'fixture-token'}); return json({error:'fixture-refusal'}, 403); } if (path === '/api/config') return json({send:true, requests:true, requestWrites:true}); if (path === '/api/processing') return json({version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false}); if (path === '/api/bus-status') return json({down:false}); if (path === '/api/bus/inbox') return json({rows:boot.inbox || [], unread:0, at:'2026-11-05T12:00:00Z'}); if (path.startsWith('/api/bus/')) return json({rows:[], unread:0, at:'2026-11-05T12:00:00Z'}); if (path === '/api/business') return json({projects:[]}); if (path === '/api/themes') return realFetch(url, init); // S4: the installed theme modules, read-only, from the fixture's own server if (path === '/api/chat/instances') return json({rows:[{instance:'assistant',name:'assistant'}]}); if (path === '/api/chat/projects') return json({rows:window.__liveProjects}); if (path === '/api/chat/projects/archived') { if (window.__archiveFailure === 'network') throw new TypeError('fixture network failure'); if (window.__archiveFailure === 'malformed') return json({rows: 'invalid'}); if (window.__archiveFailure) return json({error:'unavailable'}, 403); return json({rows:window.__archiveRows}); } if (path.startsWith('/api/chat/threads') && path.endsWith('/shares')) return json({rows:boot.shares || []}); if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${thread}',name:'default',default:true,state:'active',revision:1}]}); if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${thread}',project:'${project}',name:'Project',instance:'assistant'}]}); if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${requestId}'}],after:'${MESSAGE}',hasMore:false}); const m=/^\\/api\\/requests\\/([^/]+)(?:\\/(projects|draft))?$/.exec(path); if (m) { if (m[2] === 'projects') return json(boot.projects || []); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); } window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403); }; })();`; await b.call('Page.addScriptToEvaluateOnNewDocument', { source }); await b.call('Fetch.enable', { patterns: [{urlPattern:'*'}] }); const network = [], consoleErrors = []; b.on('Runtime.exceptionThrown', e => consoleErrors.push(e.exceptionDetails?.text || 'Runtime exception')); b.on('Runtime.consoleAPICalled', e => { if (e.type === 'error') consoleErrors.push(e.args.map(a => a.value || a.description || '').join(' ')); }); b.on('Fetch.requestPaused', e => { const url = e.request.url; if (forwardSend && e.request.method === 'POST' && (url.endsWith('/messages') || url.endsWith('/attach'))) { void forwardSend(e.request).then(result => b.call('Fetch.fulfillRequest', { requestId:e.requestId, responseCode:result.status, responseHeaders:[{name:'content-type',value:'application/json'}], body:Buffer.from(JSON.stringify(result.body)).toString('base64') })).catch(() => b.call('Fetch.failRequest', { requestId:e.requestId, errorReason:'Failed' })); return; } if (url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:')) void b.call('Fetch.continueRequest', {requestId:e.requestId}).catch(() => {}); else { network.push(url); void b.call('Fetch.failRequest', {requestId:e.requestId,errorReason:'BlockedByClient'}).catch(() => {}); } }); const route = `#/projects/threads/${thread}`; const navigate = async width => { await b.viewport(width, 1000); await b.evaluate('window.__beforeNavigate=true;true'); await b.navigate(f.base + '/?width=' + width + route); await b.evaluate(`(async()=>{const end=Date.now()+10000;while(window.__beforeNavigate || !document.querySelector('.request-panel')){if(Date.now()>end)throw Error('request panel missing');await new Promise(r=>setTimeout(r,30))}return true})()`); }; const shot = async (name, width) => { if (!process.env.MOSAIC_UX_SHOTS) return []; await mkdir(process.env.MOSAIC_UX_SHOTS, {recursive:true}); const viewport = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}.png`); const full = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-full.png`); await b.evaluate("document.querySelector('.request-panel').scrollIntoView({block:'start'});window.scrollBy(0,-90);true"); await b.screenshot(viewport); const {cssContentSize} = await b.call('Page.getLayoutMetrics'); const {data} = await b.call('Page.captureScreenshot', {format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(cssContentSize.width),height:Math.ceil(cssContentSize.height),scale:1}}); await writeFile(full, Buffer.from(data,'base64')); const continuation = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-continuation.png`); await b.evaluate("(()=>{let e=document.querySelector('.request-panel').parentElement;while(e && !(e.scrollHeight > e.clientHeight && /(auto|scroll)/.test(getComputedStyle(e).overflowY))) e=e.parentElement;if(e)e.scrollTop=e.scrollHeight;return true})()"); await b.screenshot(continuation); return [viewport,full,continuation]; }; return {f,b,route,navigate,shot,network,consoleErrors}; }