Files
stack/packages/webui/tests/settings-redesign-browser.test.mjs
jason.woltje 1b6cac2bdf feat(webui): restart the Console, the org bus and agents from the WebUI (F3)
Self-service operation from the WebUI, for a dev instance first.

- Settings > Console > Restart Console. A foreground `mosaic console
  start` now supervises the webui: the child exits 75 after answering a
  restart and the supervisor relaunches it with the same argv. Org buses
  keep running. /api/config carries a per-process boot id, so the open
  page polls until a new process answers and then reloads. A detached
  Console has no supervisor and answers 409.
- Restart bus on every "Restart needed" receipt and on the bus-down
  banner: POST /api/orgs/<id>/restart/bus restarts a running bus or
  starts a stopped one, then shows a done receipt with the time.
- Settings > Routing: after a save, the page lists how the change
  reaches each agent (next start, session end, thread idle) and offers
  "Restart <agent> now" for a running one. A session is terminated with
  the reason "route change" and launched again; a thread is terminated.
- Organization settings: the signed-in owner's settings, routing,
  agent, plug-in and Model proxy writes now reach the selected org's
  bus instead of answering 403. Org mode exists only under
  --dev-local-owner. Proxy setup stays a terminal step; a dev-only note
  names the two commands.

Every control needs the signed-in owner session, the page's exact
Origin and Sec-Fetch-Site: same-origin. The tester listener, granted
users, API tokens, agents and bus roles are refused before any work.
Each use is written to <dataRoot>/console/actions.jsonl (0600, no
symlinks) with who, when, what and the outcome. The Console and bus
restart trails fail closed. No change to credential handling, the
broker, the bus tenant boundary or v2 schemas.
2026-11-09 13:37:28 -06:00

138 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { w15Fixture } from './w15-fixture.mjs';
test('Settings proxy navigation keeps the root document through Settings and Dashboard', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/config':{devOwner:true,send:true},'/api/proxy/accounts':{accounts:[]},'/api/proxy/keys':{keys:[]}}});
await b.navigate(f.base+'/#/settings');
await wait('!!document.querySelector("#settings-nav")');
assert.equal(await b.evaluate('location.pathname'), '/');
await b.evaluate('[...document.querySelectorAll("#settings-nav a")].find(a=>a.textContent.includes("Model proxy")).click()');
await new Promise(r=>setTimeout(r,500));
assert.equal(await b.evaluate('location.pathname'), '/');
await wait('document.querySelector("#settings-page-title")?.textContent==="Model proxy" && !!document.querySelector("#proxy-accounts")');
await b.evaluate('[...document.querySelectorAll("#settings-nav a")].find(a=>a.textContent.includes("Appearance")).click()');
await wait('document.querySelector("#settings-page-title")?.textContent==="Appearance" && !!document.querySelector(".settings-appearance")');
assert.equal(await b.evaluate('location.pathname'), '/');
await b.evaluate('document.querySelector(".wordmark").click()');
await wait('location.hash==="#/" && !document.querySelector("#board-head").hidden');
assert.equal(await b.evaluate('location.pathname'), '/');
assert.equal(await b.evaluate('document.querySelector("#board-head h1").textContent'), 'Dashboard');
});
test('Settings opens browser appearance and its section menu', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t);
await b.navigate(f.base+'/#/settings');
await wait('!!document.querySelector("#settings-nav")');
assert.equal(await b.evaluate('document.querySelector("#settings-page-title").textContent'), 'Appearance');
// The tiles are the installed theme modules (S4), so they follow the /api/themes answer: ten once it has arrived, Harbor alone before.
await wait('document.querySelectorAll("input[name=palette]").length===10');
assert.equal(await b.evaluate('document.querySelectorAll("input[name=palette]").length'), 10);
assert.equal(await b.evaluate('document.querySelector("#avatar-menu a").textContent'), 'Settings');
});
test('browser choices persist, disclosure works, and controls fit at phone width', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t);
await b.viewport(390,900); await b.navigate(f.base+'/#/settings');
await wait('document.querySelectorAll(".palette-tile").length===10');
assert.equal(await b.evaluate('document.querySelector("#settings-disclosure").getAttribute("aria-expanded")'), 'false');
await b.evaluate('document.querySelector("#settings-disclosure").click()');
assert.equal(await b.evaluate('document.querySelector("#settings-disclosure").getAttribute("aria-expanded")'), 'true');
await b.key('Escape');
assert.equal(await b.evaluate('document.activeElement.id'), 'settings-disclosure');
await b.evaluate('document.querySelector("[name=palette][value=grove]").click();document.querySelector("[name=density][value=compact]").click()');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'grove');
assert.equal(await b.evaluate('document.documentElement.dataset.sidebarDensity'), 'compact');
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
assert.equal(await b.evaluate('[...document.querySelectorAll(".palette-content,.settings-segments span,#settings-disclosure")].every(e=>e.getBoundingClientRect().height>=44&&e.getBoundingClientRect().width>=44)'),true);
await b.call('Page.reload'); await wait('document.querySelector("[name=palette][value=grove]")?.checked');
assert.equal(await b.evaluate('document.querySelector("[name=density][value=compact]").checked'),true);
await b.evaluate('location.hash="#/tasks"'); await wait('location.hash==="#/tasks"');
assert.equal(await b.evaluate('!!document.querySelector("dialog[open]")'),false);
});
test('server sections render and a routing draft can be discarded at phone width', {timeout:90000}, async t => {
const revision='a'.repeat(64);
const routes={revision,harnesses:['claude-code'],models:['opus'],levels:{'claude-code':['high','xhigh']},instances:{chief:{harness:'claude-code',model:'opus',thinking:'high'}}};
const launch={revision,models:{opus:{provider:'p',model:'m'}},limits:{},usage:{},assistants:{}};
const instances={revision,launch:true,instances:{chief:{definition:'chief',launcher:true,launched:false,kept:false,running:[]}},definitions:{chief:{accepted:true,services:[]}},noHire:[],retired:[]};
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/settings':{launch,notifier:{binding:null}},'/api/bus/routes':{rows:[routes]},'/api/bus/instances':{rows:[instances]},'/api/bus/names':{rows:[]},'/api/bus/limits':{rows:[]},'/api/bus/usage':{rows:[]}}});
await b.viewport(390,900);
for(const [path,title] of [['limits','Launch limits'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['about','About']]) {
await b.navigate(f.base+'/#/settings/'+path); await wait(`document.querySelector('#settings-page-title')?.textContent===${JSON.stringify(title)}`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'),true,path);
const undersized=await b.evaluate(`[...document.querySelectorAll('.settings-content button,.settings-content select,.settings-content input:not([type=checkbox]):not([type=radio]):not([type=hidden]),.settings-content summary')].filter(e=>e.getClientRects().length).filter(e=>{const r=e.getBoundingClientRect();return r.width<44||r.height<44}).map(e=>e.outerHTML.slice(0,150))`);
assert.deepEqual(undersized,[],path+' control targets');
}
await b.navigate(f.base+'/#/settings/routing'); await wait('!!document.querySelector("#routes-table select")');
await b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-route]')].find(e=>JSON.parse(e.dataset.route)[1]==='thinking');e.value='xhigh';e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
assert.equal(await b.evaluate('!!document.querySelector("#settings-nav .settings-dot")'),true);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".settings-actions")).position'),'fixed');
await b.evaluate('document.querySelector("#routes-reload").click()');
await wait('!!document.querySelector("#routes-table select") && !document.querySelector("#settings-nav .settings-dot")');
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-route]')].find(e=>JSON.parse(e.dataset.route)[1]==='thinking').value`),'high');
assert.deepEqual(await b.evaluate('window.__writes'),[]);
});
test('agent More menu and confirmation dialogs support keyboard and pointer without posting on cancel', {timeout:90000}, async t => {
const revision='a'.repeat(64), instances={revision,launch:true,instances:{pm:{definition:'coder',launched:true,kept:false,running:[],protected:false,endProtected:false}},definitions:{coder:{accepted:true,services:[]}},noHire:[],retired:[]};
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/settings':{notifier:{}},'/api/bus/instances':{rows:[instances]}}});
await b.viewport(1440,1000); await b.navigate(f.base+'/#/settings/agents'); await wait('!!document.querySelector(".agent-more")');
await b.evaluate('document.querySelector(".agent-more").focus()'); await b.key('ArrowDown');
assert.equal(await b.evaluate('document.querySelector(".agent-more").getAttribute("aria-expanded")'),'true');
assert.equal(await b.evaluate('document.activeElement.textContent'),'Edit');
assert.equal(await b.evaluate(`(()=>{const m=document.querySelector('.agent-menu'), last=m.querySelector('[data-agent-dialog=delete]'), r=last.getBoundingClientRect();return document.elementFromPoint(r.left+r.width/2,r.top+r.height/2)===last})()`),true,'the full menu is visible and clickable outside the table');
await b.key('ArrowDown'); assert.equal(await b.evaluate('document.activeElement.textContent'),'Terminate…');
await b.key('Enter'); assert.equal(await b.evaluate('document.querySelector("#agent-dialog").open'),true);
assert.equal(await b.evaluate('document.activeElement.id'),'agent-dialog-reason');
await b.key('Escape'); assert.equal(await b.evaluate('document.activeElement.className'),'agent-more');
await b.evaluate('document.querySelector(".agent-more").click();document.querySelector("[data-agent-dialog=delete]").click()');
assert.equal(await b.evaluate('document.activeElement.id'),'agent-dialog-cancel');
assert.match(await b.evaluate('document.querySelector("#agent-dialog-copy").textContent'),/evidence.*workspace/);
await b.evaluate('document.querySelector("#agent-dialog-cancel").click()');
assert.deepEqual(await b.evaluate('window.__writes'),[]);
});
test('dev-owner send enables proxy controls after a delayed config read', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/proxy/accounts':{accounts:[]},'/api/proxy/keys':{keys:[]}}});
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{const original=fetch;window.fetch=async(u,o)=>{if(u==='/api/config'){await new Promise(r=>setTimeout(r,300));return new Response(JSON.stringify({devOwner:true,send:true,decide:true,edit:true,sso:true}));}return original(u,o)}})()`});
await b.navigate(f.base+'/#/settings/model-proxy');
await wait('document.querySelector("#proxy-login-start [data-write]")?.disabled===false');
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false);
assert.equal(await b.evaluate('document.querySelector("#proxy-apikey [data-write]").disabled'),false);
});
test('hiring switches lead names and launch settings inputs align in columns', {timeout:90000}, async t => {
const revision='a'.repeat(64), instances={revision,launch:true,instances:{coder:{definition:'coder',launched:true}},definitions:{coder:{accepted:true,services:[]},reviewer:{accepted:true,services:[]}},noHire:[],retired:[]};
const launch={revision,models:{opus:{provider:'anthropic',model:'opus'},sonnet:{provider:'anthropic',model:'sonnet'},haiku:{provider:'anthropic',model:'haiku'}},limits:{providers:{anthropic:4},models:{anthropic:{opus:4,sonnet:4,haiku:20}}},usage:{}};
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/settings':{launch,notifier:{}},'/api/bus/instances':{rows:[instances]}}});
await b.viewport(1440,1000);await b.navigate(f.base+'/#/settings/agents');await wait('document.querySelectorAll(".hiring-grid [role=switch]").length===2');
const switchesLead=await b.evaluate('[...document.querySelectorAll(".hiring-grid > div")].every(row=>row.querySelector("[role=switch]").getBoundingClientRect().right<=row.querySelector(":scope > span").getBoundingClientRect().left)');
const formLayout=await b.evaluate(`(()=>{const f=document.querySelector('#instance-add-form'),labels=[...f.querySelectorAll('.filters>label')],boxes=labels.map(e=>e.getBoundingClientRect()),details=f.querySelector('details').getBoundingClientRect(),button=f.querySelector('button[type=submit]').getBoundingClientRect();return {separate:boxes[1].left>=boxes[0].right&&boxes[2].top>=boxes[0].bottom,gap:button.top-details.bottom,headingOutline:getComputedStyle(document.querySelector('#settings-page-title')).outlineStyle}})()`);
assert.equal(formLayout.separate,true,JSON.stringify(formLayout));assert.ok(formLayout.gap>=12,JSON.stringify(formLayout));assert.equal(formLayout.headingOutline,'none');
await b.evaluate('location.hash="#/settings/limits"');await wait('document.querySelectorAll("#launch-settings-form input[type=number]").length>=5');
const positions=await b.evaluate(`(()=>{const f=document.querySelector('#launch-settings-form'), numbers=[...f.querySelectorAll('input[type=number]')], table=[...f.querySelectorAll('[data-setting]')].filter(e=>JSON.parse(e.dataset.setting)[0]==='table');const x=e=>Math.round(e.getBoundingClientRect().left);return {limits:numbers.map(x),providers:table.filter(e=>JSON.parse(e.dataset.setting)[2]==='provider').map(x),models:table.filter(e=>JSON.parse(e.dataset.setting)[2]==='model').map(x)};})()`);
for(const column of Object.values(positions)) assert.equal(new Set(column).size,1,JSON.stringify(positions));
assert.equal(switchesLead,true);
});
test('an unconfigured proxy is one empty state without forms or a false read-only note', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/config':{send:true,devOwner:true},'GET /api/proxy/accounts':{status:503,body:{error:'not-configured'}},'GET /api/proxy/keys':{status:503,body:{error:'not-configured'}}}});
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
assert.equal(await b.evaluate('document.querySelector("#proxy-message").classList.contains("empty")'),true);
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false);
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel:not(#proxy-dev-setup)")].every(e=>e.hidden)'),true);
// F3: a dev Console adds one note naming the terminal setup. It holds no form and no input: the
// management key is never typed into this page.
assert.equal(await b.evaluate('document.querySelector("#proxy-dev-setup code")?.textContent'),'mosaic proxy fetch\nmosaic proxy init');
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-dev-setup form,#proxy-dev-setup input,#proxy-dev-setup textarea").length'),0);
});
test('an unconfigured proxy without the dev owner shows no setup note', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/config':{send:true},'GET /api/proxy/accounts':{status:503,body:{error:'not-configured'}},'GET /api/proxy/keys':{status:503,body:{error:'not-configured'}}}});
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'),false);
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel")].every(e=>e.hidden)'),true);
});