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.
581 lines
54 KiB
JavaScript
581 lines
54 KiB
JavaScript
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`));
|
||
}
|
||
}
|
||
});
|