Files
stack/packages/webui/tests/w15-fixture.mjs
jason.woltje 7fdb488270 Themes as installed plugins, chosen per organization (S4, plan C5)
Add a reviewed `theme` plugin kind (interfaceVersion 1, export `theme`, switch
"list"). A module returns data only: a displayName and a flat map of colour
custom properties for light, dim and dark. checkTheme validates names and
#rrggbb colours strictly and checks each text/background pair for contrast; a
bad module is refused and logged, never served.

The ten shipped palettes become the first ten modules under
packages/plugins/theme/<name>/. Modules are found by directory scan
(loader.discoverModules, loader.loadInstalled), not by a list in the Console.
loadModule and createLoader are unchanged.

The organization file takes `appearance: { theme, mode }`, validated in
packages/business/src/appearance.mjs; a missing appearance is valid.
GET /api/themes (themes-http.mjs) serves the installed, valid themes.

Browser: the Settings picker lists /api/themes. Precedence is a per-organization
browser override, then the organization's saved theme, then the built-in default
(Harbor, dark). Clearing the override restores the organization's choice. An
uninstalled or invalid saved theme renders the default with a visible warning
and keeps the saved value. A legacy global browser preference is not copied to
every organization. The organization is named in text, so colour is never the
only cue. A plain load writes no appearance record. The default look is
unchanged: computed styles match the base commit element for element.
2026-11-07 12:18:43 -06:00

67 lines
5.9 KiB
JavaScript

import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
export const ids = Object.freeze({ project: '10000000-0000-4000-8000-000000000001', empty: '10000000-0000-4000-8000-000000000002', archived: '10000000-0000-4000-8000-000000000003', repository: 'fixture-repository', thread: '20000000-0000-4000-8000-000000000001', session: '30000000-0000-4000-8000-000000000001', observed: '2026-11-05T12:00:00.000Z' });
// All API responses are explicit, and all network destinations outside the fixture
// origin are refused. Tests may override a response by assigning window.__scenario.
export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
const f = await fixture(); t.after(() => f.close());
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = []; const networkErrors = [];
b.on('Network.responseReceived', ({response}) => { if (response.status >= 400) networkErrors.push({url:response.url,status:response.status}); });
b.on('Network.loadingFailed', e => networkErrors.push({error:e.errorText}));
await b.call('Network.enable');
b.on('Fetch.requestPaused', ({ requestId, request }) => {
const url = new URL(request.url);
const allowed = url.origin === f.base;
void b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }).catch(e => errors.push(String(e)));
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario=${JSON.stringify(scenario)};
addEventListener('error', e => __errors.push(e.message)); addEventListener('unhandledrejection', e => __errors.push(String(e.reason)));
const data = (x,status=200) => new Response(JSON.stringify({at:ids.observed,...x}), {status,headers:{'content-type':'application/json'}});
const real = fetch.bind(window);
window.fetch = async (input, init={}) => {
const url=new URL(typeof input==='string' ? input : input.url, location.href), path=url.pathname+url.search, method=(init.method||'GET').toUpperCase();
if (url.origin!==location.origin) { __unexpected.push(method+' '+url.href); return new Response('refused',{status:403}); }
if (!url.pathname.startsWith('/api/')) return real(input,init);
if(method!=='GET') __writes.push({method,path,body:init.body||null});
const key = method+' '+path;
if (Object.hasOwn(__scenario,key)) { const response=__scenario[key]; return data(response.body ?? {},response.status ?? 200); }
if(method!=='GET') { __unexpected.push(key); return data({error:'fixture-write-refused'},403); }
if (Object.hasOwn(__scenario,path)) return data(__scenario[path]);
if(/^\\/api\\/library\\/status\\?project=[^&]+$/.test(path) || path==='/api/processing' || path==='/api/bus-status' || path==='/api/queue') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
if(path==='/api/themes') return real(input,init); // S4: the installed theme modules, read-only, from the fixture's own server
if(path==='/api/config') return data({send:true,edit:false,decide:false});
if(path==='/api/business') return data({projects:[ids.repository]});
if(path==='/api/chat/projects/archived') return data({rows:[]});
if(path==='/api/chat/projects') return data({rows:[{id:ids.project,name:'Active project',repo:ids.repository},{id:ids.empty,name:'Empty project',repo:null},{id:ids.archived,name:'Archived project',archived:true,repo:null}]});
if(path==='/api/chat/instances') return data({rows:[{instance:'assistant',name:'Assistant'}]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/shares$/.test(path)) return data({rows:[]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false});
if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]});
if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
__unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}});
};
})();` });
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,40));}throw new Error('Timed out: '+${JSON.stringify(expression)})})()`);
async function capture(state, width, route) {
if (!process.env.MOSAIC_UX_SHOTS) return;
const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir, { recursive: true });
await b.evaluate('document.fonts.ready');
const prefix = `${slice}-${state}-${width}`;
await b.screenshot(join(dir, `${prefix}.png`));
const metrics = await b.call('Page.getLayoutMetrics');
const size = metrics.cssContentSize || metrics.contentSize;
const { data } = await b.call('Page.captureScreenshot', { format:'png', captureBeyondViewport:true, clip:{x:0,y:0,width:Math.ceil(size.width),height:Math.ceil(size.height),scale:1} });
await writeFile(join(dir, `${prefix}-full.png`), Buffer.from(data,'base64'));
return {state,route,width,height:1000,observation:ids.observed,viewport:`${prefix}.png`,full:`${prefix}-full.png`,errors:[...errors,...await b.evaluate('window.__errors')],unexpected:await b.evaluate('window.__unexpected'),networkErrors:[...networkErrors]};
}
return { f, b, wait, capture, errors };
}