The Dashboard's tiles are Agents working of all agents, Organizations running, Needs you, Open tasks, Done this week and Projects, each added up only from the organizations whose reads gave it; a tile names every organization it leaves out and why. The Sessions and Model spend tiles go: the first is the Agents tile now, the second had no figure. An organization's page replaces its KPIs and Metrics placeholders with Agents working, Open tasks, Needs you and Done this week for that organization, each a link to the system page filtered to it, from one cross-organization read scoped to it. The system Agents page's row building is shared so a tile's figure is that page's row count.
222 lines
16 KiB
JavaScript
222 lines
16 KiB
JavaScript
// SP8 (#1873, single pane slice 8): the Dashboard leads with one row of KPI tiles, each built from a
|
||
// number an existing read returns and each a link to the page that lists what it counts, with the
|
||
// filter in the address where that page has one. A tile whose number some organization could not give
|
||
// names that organization; it never shows a lower number as if it were complete. An organization's
|
||
// page leads with the same tiles scoped to it, each linking to the system page filtered to it.
|
||
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and
|
||
// the real per-organization routes serve the pages.
|
||
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 { browser } from './browser.mjs';
|
||
import { close } from './fixture.mjs';
|
||
|
||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||
|
||
// fail: { org: [read names] } makes those reads refuse; `none` empties the organization list.
|
||
function fixture({ fail = {}, none = false } = {}) {
|
||
const LIST = none ? [] : [
|
||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
|
||
];
|
||
const decision = (id, question, role, minutes) => ({ id, question, class: 'within-role', blocking: false, raised_by_role: role, at: at(minutes), options: [{ key: 'yes', text: 'Yes' }] });
|
||
const DATA = {
|
||
acme: {
|
||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-old', repo: 'old', name: 'Old', archived: true, at: at(9000) }],
|
||
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false } }],
|
||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||
inbox: [decision('dec-acme-1', 'Ship the hero?', 'coder', 10)],
|
||
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
|
||
},
|
||
beta: {
|
||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
||
tasks: [
|
||
{ task_ref: 'local:7', project_key: 'shop', at: at(50), fields: { title: 'Price list', done: false } },
|
||
{ task_ref: 'local:8', project_key: 'shop', at: at(70), fields: { title: 'Cart copy', done: false } },
|
||
{ task_ref: 'local:9', project_key: 'shop', at: at(60 * 24), fields: { title: 'Logo', done: true } },
|
||
],
|
||
agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
|
||
inbox: [decision('dec-beta-1', 'Pick a launch price', 'pm', 5), decision('dec-beta-2', 'Name the shop', 'pm', 90)],
|
||
instances: { pm: { definition: 'pm', running: ['run-9'] }, writer: { definition: 'writer', running: [] } },
|
||
},
|
||
};
|
||
const value = (id, name) => (fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])));
|
||
const opts = id => ({
|
||
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
|
||
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
|
||
});
|
||
const member = org => ({
|
||
id: org.id, doc: { appearance: { theme: org.theme } },
|
||
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
|
||
options: () => opts(org.id),
|
||
});
|
||
return {
|
||
list: async () => LIST,
|
||
members: async () => LIST.map(member),
|
||
resolve: async id => {
|
||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||
return {
|
||
busHealthy: true,
|
||
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||
bus: {
|
||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
|
||
instances: () => (fail[id]?.includes('instances') ? Promise.reject(Object.assign(new Error('off'), { code: 'bus-unavailable' })) : Promise.resolve({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances })),
|
||
},
|
||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none } = {}) {
|
||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none }), log: () => {} });
|
||
t.after(() => close(server));
|
||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||
await b.viewport(...viewport);
|
||
const base = `http://127.0.0.1:${server.address().port}`;
|
||
await b.navigate(`${base}/${hash}`);
|
||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||
}
|
||
const tileKeys = scope => `[...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => e.dataset.wsTile)`;
|
||
const hrefs = scope => `Object.fromEntries([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => [e.dataset.wsTile, e.tagName === 'A' ? e.getAttribute('href') : null]))`;
|
||
const big = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-big`)})?.textContent.replace(/\\s+/g, ' ').trim() ?? null`;
|
||
const homeReady = 'document.querySelector("[data-ws-home] [data-ws-tiles] [data-ws-tile=agents] .ws-big")';
|
||
|
||
test('The Dashboard tiles add both answering organizations\' numbers, one row, each tile a link to the page that lists it', async t => {
|
||
const { b, wait, text } = await setup(t);
|
||
await wait(homeReady);
|
||
// One row: no second tile group anywhere on the page, and the row leads the page under its heading.
|
||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] [data-ws-tiles]").length'), 1);
|
||
assert.equal(await b.evaluate('document.querySelector("[data-ws-home] .ws-vh").nextElementSibling.hasAttribute("data-ws-tiles")'), true);
|
||
assert.deepEqual(await b.evaluate(tileKeys('[data-ws-home]')), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']);
|
||
// Agents working of total: acme's coder works; beta's pm waits; chief and writer run nothing.
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'orgs')), '2 of 3 running');
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'needs')), '3 items');
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'tasks')), '3');
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'done')), '1');
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'projects')), '2 active');
|
||
// Each organization's share, named for a screen reader.
|
||
assert.match(await text('[data-ws-tile=needs] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
|
||
assert.match(await text('[data-ws-tile=tasks] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
|
||
assert.match(await text('[data-ws-tile=agents] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*0/);
|
||
// Every tile is a link, with the filter in the address where the page has one.
|
||
const links = await b.evaluate(hrefs('[data-ws-home]'));
|
||
assert.equal(links.agents, '#/agents?state=working');
|
||
assert.equal(links.orgs, '#/dashboard');
|
||
assert.equal(links.needs, '#/inbox');
|
||
assert.equal(links.tasks, '#/tasks');
|
||
assert.equal(links.done, '#/tasks');
|
||
assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/);
|
||
// No sample numbers and no trend line: no tile draws an svg sparkline.
|
||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0);
|
||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tile=spend],[data-ws-tile=sessions]").length'), 0);
|
||
// The Open tasks link under Tasks by state goes to the system board too.
|
||
assert.equal(await b.evaluate('document.querySelector("[data-focus-key=home-tasks]").getAttribute("href")'), '#/tasks');
|
||
});
|
||
|
||
test('A stopped organization is named on every tile its numbers would have joined, and on none it counted', async t => {
|
||
const { b, wait, text } = await setup(t);
|
||
await wait(homeReady);
|
||
for (const key of ['agents', 'needs', 'tasks', 'done', 'projects']) {
|
||
assert.match(await text(`[data-ws-tile=${key}] [data-ws-miss=${key}]`) ?? '', /Not read in Gamma Home \(bus stopped\)/, key);
|
||
}
|
||
// The Organizations tile counts it as not running and says which one.
|
||
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=orgs] [data-ws-miss]")'), null);
|
||
assert.match(await text('[data-ws-tile=orgs]'), /Gamma Home bus stopped/);
|
||
});
|
||
|
||
test('Following a tile lands on its page showing what the tile counted', async t => {
|
||
const { b, wait, text } = await setup(t);
|
||
await wait(homeReady);
|
||
await b.evaluate('document.querySelector("[data-ws-tile=agents]").click()');
|
||
await wait('location.hash === "#/agents?state=working" && document.querySelector("[data-ws-agent]")');
|
||
await wait('JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
|
||
assert.match(await text('[data-ws-astate=""] .n'), /^4$/);
|
||
await b.evaluate('location.hash = "#/dashboard"');
|
||
await wait(homeReady);
|
||
await b.evaluate('document.querySelector("[data-ws-tile=needs]").click()');
|
||
await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3');
|
||
await b.evaluate('location.hash = "#/dashboard"');
|
||
await wait(homeReady);
|
||
// The Organizations tile's list is this page's own table: following it keeps the page and moves focus there.
|
||
await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()');
|
||
await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")');
|
||
assert.equal(await b.evaluate('location.hash'), '#/dashboard');
|
||
});
|
||
|
||
test('An answering organization whose agent read failed is named on the Agents tile, never counted as none', async t => {
|
||
const { b, wait, text } = await setup(t, { fail: { beta: ['instances'] } });
|
||
await wait(homeReady);
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 2');
|
||
assert.match(await text('[data-ws-tile=agents] [data-ws-miss=agents]'), /Beta Works/);
|
||
assert.doesNotMatch(await text('[data-ws-tile=agents] .ws-by') ?? '', /Beta Works/);
|
||
});
|
||
|
||
test('When no answering organization could read its agents, the Agents tile says Not read instead of a number', async t => {
|
||
const { b, wait, text } = await setup(t, { fail: { acme: ['instances'], beta: ['instances'] } });
|
||
await wait('document.querySelector("[data-ws-tile=agents]")');
|
||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), null);
|
||
assert.match(await text('[data-ws-tile=agents]'), /Not read/);
|
||
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working');
|
||
});
|
||
|
||
test('With no organizations there is nothing to count, so no tile shows a zero', async t => {
|
||
// With no organization the workspace pages are not drawn; the Console's own board keeps its dashes.
|
||
const { b, wait } = await setup(t, { none: true });
|
||
await wait('document.querySelector("#dashboard-kpis .kpi strong")');
|
||
assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0);
|
||
assert.deepEqual(await b.evaluate('[...new Set([...document.querySelectorAll("#dashboard-kpis .kpi strong")].map(e => e.textContent.trim()))]'), ['–']);
|
||
});
|
||
|
||
test('An organization page leads with its own tiles, each linking to the system page filtered to it', async t => {
|
||
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta' });
|
||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||
// The tiles come straight after the header, before the docs row.
|
||
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead").nextElementSibling.hasAttribute("data-org-tiles")'), true);
|
||
assert.deepEqual(await b.evaluate(tileKeys('[data-org-tiles]')), ['agents', 'tasks', 'needs', 'done']);
|
||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
|
||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2');
|
||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items');
|
||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1');
|
||
assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' });
|
||
// The empty KPI and metric placeholders are gone; there is one tile row.
|
||
assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/);
|
||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1);
|
||
// Following Needs you shows beta's two items on the system Inbox.
|
||
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").click()');
|
||
await wait('location.hash === "#/inbox?org=beta" && document.querySelectorAll("[data-ws-item]").length === 2');
|
||
// Acme's own page counts only acme.
|
||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big") && location.hash.startsWith("#/orgs/acme")');
|
||
await wait(`${big('[data-org-tiles]', 'agents')} === "1 of 2"`);
|
||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '1 item');
|
||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '0');
|
||
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=agents]").click()');
|
||
await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
|
||
});
|
||
|
||
test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => {
|
||
const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' });
|
||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")');
|
||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'), 0);
|
||
for (const key of ['agents', 'tasks', 'needs', 'done']) assert.match(await text(`[data-org-tiles] [data-ws-tile=${key}]`), /Not read: the bus is stopped/, key);
|
||
assert.equal(await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").getAttribute("href")'), '#/inbox?org=gamma');
|
||
});
|
||
|
||
test('At 390 wide the tiles sit two to a row with no sideways scroll, on the Dashboard and an organization page', async t => {
|
||
const { b, wait } = await setup(t, { viewport: [390, 844] });
|
||
await wait(homeReady);
|
||
const cols = scope => b.evaluate(`new Set([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => Math.round(e.getBoundingClientRect().left))).size`);
|
||
assert.equal(await cols('[data-ws-home]'), 2);
|
||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'dashboard');
|
||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||
await wait('document.querySelector("[data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||
assert.equal(await cols('[data-org-tiles]'), 2);
|
||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'organization page');
|
||
});
|