Files
stack/packages/webui/tests/unified-workspace-browser.test.mjs
jason.woltje 17a48217f6 fix(agents): A3 project unions reach every agent, copies never collide or shadow (#1873)
Review fix round for slice A3.

- Every project's union is recomputed from the current organization file
  at each load. A refused union refuses the project's own copies and every
  inherited root or org agent there: no mount at launch (checkout-skipped,
  project-refused), no thread and no room, each naming the project and why.
- A project exclude removes that project's mount from the excluded agent;
  a narrow reaches the instance's resolved limits, so its real bundle is
  narrowed. A narrow in any project narrows the instance for every launch,
  since one launch mounts every project it works in.
- Two copies whose instance names collide both refuse, naming each
  claimant; the view lists the claimants. An organization instance of the
  same name replaces a root or project copy, at boot and after it, and the
  copy is gone rather than refused.
- Rooms hold only the thread instances that work in the room's project.
- The grid finds an agent's copy by name and project from the bus view,
  never by rebuilding the instance name.
- Real-boot regressions: inherited agents in a refused union, exclude and
  narrow in the bundle, an org override after boot, two organizations each
  running a copy, a root file change while running, and a grid fed by a
  booted bus.
2026-11-10 20:27:33 -06:00

581 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { fileURLToPath } from 'node:url';
import { systemTypesFake } from './helpers/system-types-fake.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running
// organization's System types read comes from the installed modules; `send` adds the owner's writes.
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {}, copies = {}, buses = {} } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
const INSTANCES = { ddigital: { chief: ['chief-of-staff', ['r1']], copywriter: ['copywriter', ['r2']], 'montage-producer': ['producer', ['r3', 'r4']], editor: ['editor', []] }, forge: { chief: ['chief-of-staff', []] } };
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'V-112 Launch email, draft 2', project: 'spring-launch', instance: 'copywriter' }, { id: 't2', name: 'V-118 Teaser reel', project: 'spring-launch', instance: 'montage-producer' }, { id: 't3', name: 'Weekly plan', project: 'brand-refresh', instance: 'chief' }], forge: [] };
// The bus's derived state per held role: chief waits on the human, copywriter works, montage-producer
// reports none, so its activity is not known and it shows only as running.
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
const TYPES = { ddigital: { enabled: ['coding', 'marketing'], projects: { 'spring-launch': ['coding'], 'brand-refresh': ['marketing'] } }, forge: { enabled: [], projects: { 'api-gateway': [] } }, ...types };
const fakes = {};
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])),
// #1873 A3: the bus's copies key, present only when the test gives the organization copies.
...(copies[id] ? { copies: copies[id] } : {}) });
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
return {
list: async () => ORGS,
resolve: async id => {
seen?.push(id);
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
fakes[id] ??= systemTypesFake(TYPES[id]);
const st = await fakes[id];
return {
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
// `buses` hands an organization's instance read to a real booted bus instead.
bus: { instances: async () => (buses[id] ? buses[id]() : instances(id)), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
};
},
};
}
// No bus or setup work runs in these tests: every restart below is refused before the admin is used.
const refusingAdmin = () => createOrgAdmin({ service: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const SETTINGS = ['Identity', 'Appearance', 'System types', 'Projects', 'Agents and roles', 'Models and routing', 'Arbiters', 'Task tracker', 'Channels', 'Launch limits', 'Assistant', 'Token use', 'Model proxy', 'Console', 'Organizations', 'About', 'Members and invites', 'Bus', 'Archive or delete'];
const TAGS = ['org file', 'override', 'system', 'root', 'not built', 'danger'];
// Every page the Settings view has for the owner (bus.js settingsPages).
const SETTINGS_PAGES = ['appearance', 'business', 'limits', 'organizations', 'routing', 'agents', 'assistant', 'tokens', 'model-proxy', 'console', 'about'];
test('workspace navigation exposes all organizations and remembers project grouping', async t => {
const organizations = {
list: async () => [{ id:'alpha', label:'Alpha', theme:null, busHealthy:true }, { id:'beta', label:'Beta', theme:'cobalt', busHealthy:false }],
resolve: async id => ({ busHealthy:id === 'alpha', reads:{ business:async()=>({ business:{ id, projects:[] } }) }, bus:{}, chat:{projects:async()=>[],threads:async()=>[]} }),
};
const server = await startServer({port:0, sso:createDevOwner(), organizations, log:()=>{}}); t.after(()=>close(server));
const b=await browser({machine:false}); t.after(()=>b.close());
const wait = waitFor(b);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`);
await wait('document.querySelectorAll("[data-workspace-org]").length === 2');
assert.match(await b.evaluate('document.querySelector("[data-workspace-org=beta]").textContent'), /Beta.*bus unavailable/is);
// An organization's colour is its theme's action colour; no theme is the harbor default.
assert.equal(await b.evaluate('document.querySelector("[data-workspace-org=alpha] .ws-av").style.getPropertyValue("--org-l")'), '#2266bf');
await b.evaluate('document.querySelector("[data-org-toggle]").click()');
assert.equal(await b.evaluate('document.querySelector("[data-org-list]").hidden'),true);
await b.evaluate('document.querySelector("[data-org-toggle]").click(); document.querySelector("[data-group=flat]").click()');
// The Projects group collapses too, and each group's open state is remembered (sample 01).
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-projects-toggle]").getAttribute("aria-expanded"), document.activeElement?.dataset.focusKey]'), [true, 'false', 'projects-toggle']);
await b.evaluate('location.reload()');
// The reload repaints the sidebar in stages; wait for both lists before reading them.
await wait('document.querySelector("[data-group=flat]")?.getAttribute("aria-pressed") === "true" && !!document.querySelector("#ws-project-list") && !!document.querySelector("[data-org-list]")');
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-org-list]").hidden]'), [true, false]);
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
assert.equal(await b.evaluate('document.querySelector("#ws-project-list").hidden'), false);
await b.viewport(390,844);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
});
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']);
assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'),
[['ddigital', true, 's-working'], ['forge', true, 's-working'], ['home', true, 's-offline']]);
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").getAttribute("aria-pressed")'), 'true');
// The four-tile logo stays.
assert.equal(await b.evaluate('document.querySelectorAll(".wordmark .mosaic-logo rect").length'), 4);
// No organization switcher remains anywhere on the page.
assert.equal(await b.evaluate('document.querySelector("#org-picker, select[name=org], [data-org-switch]")'), null);
// Projects has no conversation tree under it; the Projects group links to each project page.
assert.equal(await b.evaluate('document.querySelector("#sections #project-tree, #sections .project-tree-list")'), null);
assert.match(await b.evaluate('document.querySelector("#workspace-nav .ws-proj").getAttribute("href")'), /^#\/orgs\/[a-z0-9-]+\/projects\/[^/]+$/);
// Queue and Docs are not sidebar items.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
// Settings stays pinned to the foot of the sidebar.
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
assert.equal(queue, '#/queue');
await b.evaluate('document.querySelector("[data-org-queue]").click()');
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
// tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs).
});
test('organization dashboard renders every section from the per-organization reads', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0');
const head = await b.evaluate(`(() => { const h = document.querySelector('[data-org-dashboard] .ws-orghead'); return { avatar: h.querySelector('.ws-av')?.textContent, title: h.querySelector('h1').textContent, meta: h.querySelector('.ws-meta').innerText, colour: h.style.getPropertyValue('--org-l') }; })()`);
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
// System types: the enabled modules by their own labels (P2).
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+Coding\s*Marketing\s+Each project picks from these/i);
// Docs come from the project files reads: only documents, named by project.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
// KPIs and metrics: one honest empty tile each, with no invented number.
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
// Active agent sessions: the running instances, the thread their live run holds (chief's older
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
// reported state (montage-producer) the row says only running.
const rows = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => [...tr.cells].map(c => c.innerText.replace(/\\s+/g, " ").trim()))');
assert.deepEqual(rows, [
['chief', 'Brand refresh Weekly plan', 'sonnet-5.5 Change', '◐ waiting on you'],
['copywriter', 'Spring launch V-112 Launch email, draft 2', 'sonnet-5.5 Change', '● working'],
['montage-producer', 'Spring launch V-118 Teaser reel', 'sonnet-5.5 Change', '● running 2 sessions'],
]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
assert.deepEqual(settings.map(s => s.name), SETTINGS);
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
const by = Object.fromEntries(settings.map(s => [s.name, s]));
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
assert.deepEqual([by['System types'].tag, by['System types'].value], ['org file', '2 of 5 installed enabled']);
assert.equal(await b.evaluate('document.querySelector(\'[data-org-setting="System types"] button.go\').textContent'), 'Open');
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
assert.equal(by['Task tracker'].value, 'Vikunja');
assert.equal(by['Launch limits'].value, '4 sessions at once');
assert.equal(by['Agents and roles'].value, '4 agents · 3 running');
for (const page of SETTINGS_PAGES) assert.ok(settings.some(s => s.href === `#/settings/${page}`), `no row opens settings/${page}`);
});
test('organization dashboard empty states say so in one short line', async t => {
const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }], resolve: async () => ({ busHealthy: true,
reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => ({ business: 'alpha', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [] },
chat: { projects: async () => [], threads: async () => [] } }) };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
// The empty organization has no System types read, so it says unavailable, never that none exist.
for (const line of [/System types\s+System types unavailable/i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
assert.match(text, line);
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
});
test('organization dashboard presents sections and honest unavailable controls', async t => {
const organizations = { list:async()=>[{id:'alpha',label:'Alpha',busHealthy:false}], resolve:async()=>({busHealthy:false,reads:{business:async()=>({business:{id:'alpha',projects:[]}})},bus:{},chat:{projects:async()=>[],threads:async()=>[]}}) };
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}}); t.after(()=>close(server));
const b=await browser({machine:false}); t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
assert.match(text,/reload organization file/i);
assert.match(text,/no metrics recorded yet/i);
assert.match(text,/bus unavailable, read \d/);
assert.match(text,/Sessions unavailable/);
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
});
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
})()`));
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
// The owner on a Console started with --send gets console-restart.js's restart button.
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
// shows no controls at all.
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-unavailable]")');
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
// There is no reload route.
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
});
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
async function tabStops(b, start) {
await b.evaluate(`(${start}).focus()`);
const stops = [];
for (let i = 0; i < 90; i++) {
const at = await b.evaluate(`(() => { const a = document.activeElement, sec = a.closest('[data-org-dashboard] section'); return { i: [...document.querySelectorAll('*')].indexOf(a), region: a.closest('#console-nav') ? 'nav' : sec ? sec.getAttribute('aria-label') : a.closest('[data-org-dashboard]') ? 'head' : 'other', key: a.dataset.focusKey || a.getAttribute('href') || a.textContent.trim() }; })()`);
if (stops.length && at.region === 'other' && stops.at(-1).region !== 'other') break;
stops.push(at);
await b.key('Tab');
}
const boxes = await b.evaluate(`(${JSON.stringify(stops.map(s => s.i))}).map(i => { const r = document.querySelectorAll('*')[i].getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left }; })`);
return stops.map((s, n) => ({ ...s, ...boxes[n] }));
}
function assertVisualOrder(stops, regions, label) {
for (let n = 1; n < stops.length; n++) {
const a = stops[n - 1], z = stops[n], where = `${label}: ${a.key} then ${z.key}`;
assert.ok(z.i > a.i, `tab order leaves DOM order at ${where}`);
assert.ok(regions.indexOf(z.region) >= regions.indexOf(a.region), `region order at ${where}`);
if (z.region !== a.region) continue;
const sameRow = z.top < a.bottom && z.bottom > a.top;
assert.ok(sameRow ? z.left > a.left : z.top >= a.top, `visual order at ${where}`);
}
}
test('keyboard focus follows visual order and survives a data refresh', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0 && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
const wide = await tabStops(b, 'document.querySelector("#sections a")');
const regions = ['nav', 'head', 'Measures and sessions', 'Settings'];
assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions);
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
const keys = wide.map(s => s.key);
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
// The disabled reload is not a tab stop; the restart button is.
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
await b.viewport(390, 844);
await wait('document.querySelector("[data-org-dashboard]")');
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');
assertVisualOrder(narrow, regions.slice(1), '390');
for (let n = 1; n < narrow.length; n++) assert.ok(narrow[n].top >= narrow[n - 1].top - 1 || narrow[n].region === narrow[n - 1].region, `stacked order at 390: ${narrow[n - 1].key} then ${narrow[n].key}`);
await b.viewport(1440, 900);
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), key);
}
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-bus-restart]').focus(); document.getElementById('refresh').click()`);
await wait('document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
assert.equal(await b.evaluate('document.activeElement.hasAttribute("data-bus-restart")'), true);
await b.evaluate(`window.oldRow = document.querySelector('[data-workspace-org=forge]'); document.querySelector('[data-focus-key="org:forge"]').focus(); window.MosaicOrg.refreshList()`);
await wait('document.querySelector("[data-workspace-org=forge]") !== window.oldRow');
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), 'org:forge');
});
test('phone drawer opens over the dashboard with no horizontal scroll', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(390, 844);
for (const route of ['organization', 'agents']) {
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/${route}`);
await wait(route === 'organization' ? 'document.querySelector("[data-org-dashboard] [data-org-settings]")' : 'document.querySelector("[data-org-agents] .ws-levels")');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, route);
}
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility === "hidden" || document.getElementById("console-nav").getBoundingClientRect().right <= 0'), true);
await b.evaluate('document.getElementById("nav-open").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
await wait('document.querySelectorAll("[data-workspace-org]").length === 3');
const drawer = await b.evaluate('(() => { const r = document.getElementById("console-nav").getBoundingClientRect(); return { left: r.left, right: r.right, sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }; })()');
assert.ok(drawer.left >= 0 && drawer.right <= 390 && drawer.right > 200, JSON.stringify(drawer));
assert.ok(drawer.sw <= drawer.cw, JSON.stringify(drawer));
// The open drawer itself does not scroll sideways; the only clipped children are screen-reader text.
assert.deepEqual(await b.evaluate('(() => { const nav = document.getElementById("console-nav"); return [nav.scrollWidth <= nav.clientWidth, [...nav.querySelectorAll("*")].filter(e => e.scrollWidth > e.clientWidth + 1 && getComputedStyle(e).overflowX !== "visible" && !e.classList.contains("ws-sr")).map(e => e.className)]; })()'), [true, []]);
});
test('agents view includes organization-scoped instances without inventing root or project agents', async t => {
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:{messages:async()=>({messages:[],unread:0}),agents:async()=>[],instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})},chat:{threads:async()=>[],projects:async()=>[]}})};
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
const b=await browser({machine:false});t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/agents`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/);
// No levels read is wired here (no organizations.members), so root and project say unavailable, never none.
assert.match(text,/Root agents unavailable\. The levels read did not answer/); assert.match(text,/Project agents unavailable/);
assert.doesNotMatch(text,/No root agents/);
});
test('an unavailable selected bus does not hide other organizations on Agents', async t => {
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:id==='alpha'?{agents:async()=>[],messages:async()=>({messages:[],unread:0}),instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})}:{},chat:{threads:async()=>[],projects:async()=>[]}})};
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
const b=await browser({machine:false});t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/beta/agents`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents unavailable/);
});
test('a failed conversation read says unavailable, never that none exists', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ failThreads: true }), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await waitFor(b)('document.querySelectorAll("[data-org-dashboard] .ws-tbl tbody tr").length === 3');
const where = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => tr.cells[1].innerText.trim())');
assert.deepEqual(where, ['Conversation unavailable', 'Conversation unavailable', 'Conversation unavailable']);
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /No conversation/);
});
test('an organization missing from the list, or an unreadable list, is never read', async t => {
const seen = [];
const orgs = workspaceOrgs({ seen });
for (const [label, organizations, message] of [['not listed', orgs, /not on your list/], ['list fails', { ...orgs, list: async () => { throw new Error('list down'); } }, /could not be read/]]) {
seen.length = 0;
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const target = label === 'not listed' ? 'denied' : 'ddigital';
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/${target}/organization`);
await waitFor(b)('document.querySelector("[data-org-unavailable]")');
const view = await b.evaluate('document.querySelector("[data-org-unavailable]").innerText');
assert.match(view, /Organization unavailable/, label); assert.match(view, message, label);
// None of the dashboard's per-organization reads went out for the target.
const asked = await b.evaluate(`performance.getEntriesByType('resource').map(e => new URL(e.name).pathname).filter(p => p.startsWith('/api/orgs/${target}/'))`);
// The app shell's own boot (app.js, unchanged here) reads /business once; the dashboard adds none.
const dashboardReads = /\/(settings|queue|chat\/threads|chat\/projects|bus\/instances|bus\/agents|bus\/thread\.claims)$/;
assert.deepEqual(asked.filter(p => dashboardReads.test(p)), [], `${label}: ${asked.join(' ')}`);
assert.ok(asked.filter(p => p.endsWith('/business')).length <= 1, `${label}: ${asked.join(' ')}`);
}
});
test('agents grid shows the reported state and a model Change link', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr").length === 4');
const rows = await b.evaluate('[...document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr")].map(tr => [tr.cells[0].innerText.split("\\n")[0].trim(), tr.cells[4].innerText.replace(/\\s+/g, " ").trim(), tr.cells[5].innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(rows, [['chief', '◐ waiting on you', 'sonnet-5.5 Change'], ['copywriter', '● working', 'sonnet-5.5 Change'], ['montage-producer', '● running', 'sonnet-5.5 Change'], ['editor', '– not running', 'sonnet-5.5 Change']]);
const pills = await b.evaluate('[...document.querySelectorAll("[data-org=ddigital] .ws-agent")].map(p => p.querySelector(".ws-dot").title)');
assert.deepEqual(pills, ['waiting on you', 'working', 'running', 'not running']);
// Keyboard reaches the Change links.
const label = await b.evaluate('(() => { const a = document.querySelector("[data-org-agents] a.ws-change"); a.focus(); return document.activeElement === a && a.getAttribute("aria-label"); })()');
assert.equal(label, 'Change the model for chief');
});
// #1873 A1: the three levels read from their files. Root agents (reviewer turned off for Home), each
// organization's own instances and two projects' entries: one defines, narrows and keeps; one excludes.
test('agents grid shows each agent\'s level and where it is defined, per project', async t => {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {
assistant: { definition: 'assistant', vars: { model: 'opus-5.5' } },
researcher: { definition: 'research', vars: { model: 'opus-5.5' } },
reviewer: { definition: 'reviewer', excludeOrgs: ['home'] },
} }, { rolesDir }) };
const DOCS = {
ddigital: { roles: { chief: { definition: 'chief-of-staff' }, copywriter: { definition: 'copywriter' }, 'montage-producer': { definition: 'producer' }, editor: { definition: 'editor' } },
projects: { 'spring-launch': { root: '/p/spring', agents: { 'social-scheduler': { definition: 'coder', vars: { model: 'haiku-5.5' } }, editor: { vars: { 'limits.network': 'none' } } } },
'brand-refresh': { root: '/p/brand', agents: { copywriter: { exclude: true } } } } },
forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'api-gateway': { root: '/p/api', agents: { 'load-tester': { definition: 'coder' } } } } },
home: { roles: {}, projects: {} },
};
const orgs = workspaceOrgs();
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll("[data-org-agents] [data-agents-available] tbody tr").length === 8');
// The levels grid: a copy of each root agent per organization, the opt-out said, project-defined agents with their project.
const cells = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org]")].map((c, i) => [`${c.dataset.org}:${i}`, c.innerText.replace(/\\s+/g, " ").trim()]))');
const cell = (org, n) => Object.entries(cells).filter(([k]) => k.startsWith(`${org}:`)).map(([, v]) => v)[n];
assert.match(cell('ddigital', 0), /researcher copy.*reviewer copy/);
assert.match(cell('home', 0), /researcher copy/); assert.doesNotMatch(cell('home', 0), /reviewer copy/); assert.match(cell('home', 0), /reviewer turned off for Home/);
assert.match(cell('ddigital', 2), /social-scheduler Spring launch/); assert.doesNotMatch(cell('ddigital', 2), /editor|copywriter/);
assert.match(cell('forge', 2), /load-tester API gateway/); assert.match(cell('home', 2), /none/);
// The table for the first project: level, where it is defined, memory, status and model per row.
const table = () => b.evaluate('[...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [...tr.cells].map(c => c.innerText.replace(/\\s+/g, " ").trim()))');
const rows = await table();
assert.deepEqual(rows.map(r => [r[0].split(' ')[0], r[1], r[2], r[3]]), [
['assistant', 'ROOT', 'Your root configuration', 'Shared across all organizations'],
['researcher', 'ROOT', 'Your root configuration', 'This organization only'],
['reviewer', 'ROOT', 'Your root configuration', 'This organization only'],
['chief', 'ORG', 'Distinctly Digital file', 'This organization only'],
['copywriter', 'ORG', 'Distinctly Digital file', 'This organization only'],
['editor', 'ORG', 'Distinctly Digital file', 'This organization only'],
['montage-producer', 'ORG', 'Distinctly Digital file', 'This organization only'],
['social-scheduler', 'PROJECT', 'Spring launch project', 'This organization only'],
]);
const byName = Object.fromEntries(rows.map(r => [r[0].split(' ')[0], r]));
// A3: this fixture bus booted with no copies, so a copy's status is the next boot.
assert.equal(byName.researcher[4], 'starts with the next bus start'); assert.equal(byName.researcher[5], 'opus-5.5');
assert.equal(byName.reviewer[5], 'not set'); assert.equal(byName.assistant[4], '? status not reported');
// The model the root file sets for the assistant, shown as configured.
assert.equal(byName.assistant[5], 'opus-5.5');
assert.equal(byName.chief[4], '◐ waiting on you'); assert.equal(byName.chief[5], 'sonnet-5.5 Change');
assert.match(byName.editor[0], /narrowed here: limits\.network/);
assert.equal(byName['social-scheduler'][4], 'starts with the next bus start'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
assert.match(byName['social-scheduler'][0], /coder/);
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /Not available here: every Forge or Home agent\./);
// Another project: its exclusion and the first project's own agent are said, not hidden.
await b.evaluate('(() => { const s = document.querySelector("[data-agents-project]"); s.value = "brand-refresh"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await waitFor(b)('!document.querySelector("[data-agents-available] tr[data-agent=copywriter]")');
const names = (await table()).map(r => r[0].split(' ')[0]);
assert.deepEqual(names, ['assistant', 'researcher', 'reviewer', 'chief', 'editor', 'montage-producer']);
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /social-scheduler \(Spring launch only\), copywriter \(excluded in this project\) and every Forge or Home agent/);
// Home, where the reviewer is turned off: the table says so.
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/home/agents`);
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
});
// #1873 A3: the same grid, fed by a booted bus. Distinctly Digital's bus boots from a real organization
// file and root file: gate is a bound copy with a real claim, researcher is unbound (its role needs
// gitea), stack's checker is a bound project copy and web's union refuses (it excludes an agent
// nothing defines), so web's lint says why. The test secret sits in the organization's coder token
// and never reaches the page.
test('agents grid takes a running copy, an unbound copy and a refused project from a booted bus', { timeout: 90000 }, async t => {
const { join } = await import('node:path');
const { mkdirSync, writeFileSync } = await import('node:fs');
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const { launchDoc, tokenFile, writeJson } = await import('../../organization/tests/helpers.mjs');
const { fixture, tmp, withoutHostCredentials } = await import('../../cli/tests/helpers.mjs');
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
const { startTimeOf } = await import('../../cli/src/host.mjs');
const { startBroker } = await import('../../bus/src/runtime.mjs');
const { Client } = await import('../../bus/src/client.mjs');
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const VARS = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' };
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const dir = tmp(t, 'ws-copy-');
const f = fixture(dir, 'ddigital', doc => {
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high' };
doc.roles.coder.vars.model = 'claude-sonnet-5-5';
const secret = tokenFile(join(dir, 'secrets'), SECRET);
writeFileSync(secret, SECRET);
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
doc.launch = launchDoc('chief', ['coder'], { opus: 1, sonnet: 4 });
doc.projects.stack.agents = { checker: { definition: 'merge-gate', vars: VARS } };
doc.projects.web = { root: join(dir, 'web'), agents: { lint: { definition: 'merge-gate', vars: VARS }, ghost: { exclude: true } } };
return doc;
});
mkdirSync(join(dir, 'web'), { recursive: true });
withoutHostCredentials(f.env, { piKey: true });
const rootDoc = { agentsVersion: 1, agents: { gate: { definition: 'merge-gate', vars: VARS }, researcher: { definition: 'research', vars: VARS } } };
writeJson(join(dir, 'config', 'agents.json'), rootDoc, 0o600);
const boot = (({ trackers, ...rest }) => rest)(bootConfig({ system: loadSystem({ env: f.env }), businessId: 'ddigital', env: f.env }));
const runtime = await startBroker(boot); t.after(() => runtime.close());
// gate runs: a launch bound to the bus and its claim, as the launcher makes them.
const bound = await runtime.bindLaunch({ business: 'ddigital', role: 'gate', run: 'gate-run', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: runtime.path, cap: bound.cap }).call('role.claim');
const human = runtime.broker.bindHuman({ business: 'ddigital', human: f.doc.human, via: 'cli', outsideAgent: true });
const read = () => runtime.broker.request(human, { verb: 'instances' });
assert.deepEqual(Object.fromEntries(Object.entries(read().copies).map(([k, c]) => [k, c.state])), { gate: 'instance', researcher: 'unbound', 'checker.stack': 'instance', 'lint.web': 'refused' });
const orgs = workspaceOrgs({ buses: { ddigital: async () => read() } });
const DOCS = { ddigital: f.doc, forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} }, home: { roles: {}, projects: {} } };
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
const root = { state: 'ok', ...checkRootAgents(rootDoc, { rolesDir }) };
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]").length === 4');
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]")].map(p => [p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title])');
assert.deepEqual(pills, [['gate', 'instance', 'running'], ['researcher', 'unbound', "can't launch"], ['checker', 'instance', 'not running'], ['lint', 'refused', "can't launch"]]);
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-why]")].map(n => [n.dataset.copyWhy, n.innerText.trim()]))');
assert.deepEqual(Object.keys(why), ['researcher', 'lint.web']);
assert.equal(why.researcher, 'researcher: no binding in this organization for gitea');
assert.match(why['lint.web'], /^lint: project web refuses its agents: .*ghost/);
const text = await b.evaluate('document.body.innerText');
for (const value of [SECRET, dir]) assert.equal(text.includes(value), false, value);
});
// #1873 A3: the grid's copy status comes from each bus's copies. Distinctly Digital runs its researcher
// copy, its reviewer copy is unbound (the reason names the services, never a value), and Forge's
// api-gateway project refuses, so its load-tester says why, naming the project.
test('agents grid shows a running copy, an unbound copy and a refused project with their reasons', async t => {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {
assistant: { definition: 'assistant' },
researcher: { definition: 'research', vars: { model: 'opus-5.5' } },
reviewer: { definition: 'reviewer', excludeOrgs: ['home'] },
} }, { rolesDir }) };
const DOCS = {
ddigital: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'spring-launch': { root: '/p/spring', agents: { 'social-scheduler': { definition: 'research' } } } } },
forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'api-gateway': { root: '/p/api', agents: { 'load-tester': { definition: 'research' } } } } },
home: { roles: {}, projects: {} },
};
const copy = (name, project, state, extra = {}) => ({ name, level: project ? 'project' : 'root', project, definition: 'research', state, running: [], ...extra });
const copies = {
ddigital: { researcher: copy('researcher', null, 'instance', { running: ['r9'] }), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
'social-scheduler.spring-launch': copy('social-scheduler', 'spring-launch', 'instance') },
forge: { researcher: copy('researcher', null, 'instance'), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
'load-tester.api-gateway': copy('load-tester', 'api-gateway', 'refused', { reason: 'project api-gateway refuses its agents: instance load-tester: model is not one the organization lists' }) },
};
const orgs = workspaceOrgs({ copies });
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll(".ws-levels [data-copy-state]").length === 7');
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-state]")].map(p => [c.dataset.org, p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title]))');
assert.deepEqual(pills, [
['ddigital', 'researcher', 'instance', 'running'], ['ddigital', 'reviewer', 'unbound', "can't launch"],
['forge', 'researcher', 'instance', 'not running'], ['forge', 'reviewer', 'unbound', "can't launch"],
// Home's bus is down: no answer reads as unavailable, never as idle.
['home', 'researcher', 'unknown', 'status unavailable'],
['ddigital', 'social-scheduler', 'instance', 'not running'], ['forge', 'load-tester', 'refused', "can't launch"],
]);
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-why]")].map(n => [`${c.dataset.org}:${n.dataset.copyWhy}`, n.innerText.trim()])))');
assert.deepEqual(why, {
'ddigital:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
'forge:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
'forge:load-tester.api-gateway': 'load-tester: project api-gateway refuses its agents: instance load-tester: model is not one the organization lists',
});
// The table for Distinctly Digital's first project: the copies' status from its bus, the reason under an unbound one.
const rows = await b.evaluate('Object.fromEntries([...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [tr.cells[0].innerText.trim().split(/\\s/)[0], tr.cells[4].innerText.replace(/\\s+/g, " ").trim()]))');
assert.equal(rows.researcher, '● running');
assert.equal(rows.reviewer, "▲ can't launch no binding in this organization for gitea, vikunja");
assert.equal(rows['social-scheduler'], '– not running');
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /not launched yet/);
const shots = process.env.MOSAIC_UX_SHOTS;
if (shots) {
const { mkdir } = await import('node:fs/promises');
const { join } = await import('node:path');
await mkdir(shots, { recursive: true });
for (const [width, height] of [[1440, 900], [390, 844]]) for (const mode of ['light', 'dark']) {
await b.viewport(width, height);
// The header's switch sets this browser's mode for the organization, so the theme's colours follow.
await b.evaluate(`(() => { const s = document.querySelector("#theme-switch"); if ((s.getAttribute("aria-checked") === "true") !== ${mode === 'dark'}) s.click(); scrollTo(0, 0); document.querySelector("main, .ws-main")?.scrollTo?.(0, 0); })()`);
await new Promise(r => setTimeout(r, 200));
await b.screenshot(join(shots, `a3-agents-${width}-${mode}.png`));
}
}
});