feat(webui): an organization's workflows read only, a list, a step graph with the selected step's detail, and a Workflows card on the organization page (#1873) #1973

Merged
jason.woltje merged 3 commits from sp9-workflows into refactor 2026-11-15 14:54:48 +00:00
9 changed files with 545 additions and 8 deletions

No files matched your search

+1 -1
View File
@@ -201,7 +201,7 @@
if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = '<span aria-current="page">Control board</span>'; return; }
if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = '<span aria-current="page">Dashboard</span>'; return; }
if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `<a href="#/dashboard">Dashboard</a>${h ? `<span aria-hidden="true">/</span><span aria-current="page">${escape(h)}</span>` : ''}`; return; }
const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',assistant:'Assistant',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',assistant:'Assistant',tasks:'Tasks',agents:'Agents',workflows:'Workflows',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
if (parts[0] === 'settings' && parts[1] === 'business' && $('bus-view').querySelector('#settings-page-title')?.textContent.trim() === 'Business') { box.innerHTML = '<a href="#/settings">Settings</a><span aria-hidden="true">/</span><span aria-current="page">Business</span>'; return; }
const name = names[parts[0]] || 'Dashboard', heading = $('bus-view').querySelector('h1')?.textContent?.trim();
const project = $('bus-view').querySelector('.chat-conversation > p:first-child a, .chat-room > p:first-child a');
+7 -1
View File
@@ -1717,6 +1717,12 @@
return agentsView(get, q, org);
}
if (a === 'agents' && n === 1) return agentsView(get);
// SP9 (#1873): #/orgs/<id>/workflows lists an organization's workflows; #/orgs/<id>/workflows/<name> draws one, read only.
if (a === 'workflows' && (n === 1 || n === 2) && window.MosaicOrg && window.MosaicWorkflows?.page) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : window.MosaicOrg.id; } catch { return notFound(); }
return org ? window.MosaicWorkflows.page(org, n === 2 ? b : null, q) : notFound();
}
if (a === 'queue' && n === 1) return queueView(get, q);
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
if (a === 'settings' && (n === 1 || (n === 2 && [...settingsPages.map(p=>p[0]),'names'].includes(b)))) return settingsView(get, b || 'appearance');
@@ -1731,7 +1737,7 @@
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
// The cross-organization pages (U3) poll at the workspace read's pollMs.
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks');
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path));
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/assistant.css">
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/assistant.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><link rel="stylesheet" href="/workflows.css"><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/assistant.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
+56
View File
@@ -0,0 +1,56 @@
/* SP9 (#1873): the read-only workflow pages (#/orgs/<id>/workflows[/<name>]) and the organization
page's Workflows card. Colours come from the theme tokens, so light and dark both follow the theme. */
.wfg{min-width:0}
.wfg h2{font-size:15px;margin:22px 0 8px}
.wfg-scope:first-of-type h2{margin-top:6px}
.wfg-list{list-style:none;margin:0;padding:0;background:var(--surface);border:1px solid var(--line);border-radius:10px}
.wfg-item{padding:12px 14px;border-top:1px solid var(--line-soft,var(--line));overflow-wrap:anywhere}
.wfg-item:first-child{border-top:0}
.wfg-item-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px}
.wfg-item-h a{font-weight:600;color:var(--action,var(--accent));text-decoration:none}
.wfg-item-h a:hover{text-decoration:underline}
.wfg-desc{margin:4px 0 0;color:var(--muted);font-size:13.5px}
.wfg-chain{list-style:none;display:flex;flex-wrap:wrap;gap:4px 0;margin:8px 0 0;padding:0;font-size:13px;color:var(--text)}
.wfg-chain li{display:inline-flex;align-items:center}
.wfg-chain li+li::before{content:"\2192";color:var(--muted);padding:0 8px}
:is(.wfg,.wfg-card) a:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px}
/* The graph scrolls inside its own frame when it is wider than the frame; the page never does. */
.wfg-frame{max-width:100%;overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px}
.wfg-graph{display:block}
.wfg-graph.is-vertical{width:100%;max-width:340px;height:auto;margin:0 auto}
.wfg-node{cursor:pointer;text-decoration:none}
.wfg-node rect{fill:var(--canvas);stroke:var(--line);stroke-width:1.5}
.wfg-node:hover rect{stroke:var(--muted)}
.wfg-node.is-sel rect{stroke:var(--accent);stroke-width:2.5;fill:var(--raised)}
.wfg-node:focus{outline:none}
.wfg-node:focus-visible rect{stroke:var(--focus);stroke-width:3}
.wfg-node text{font-family:var(--font);fill:var(--text)}
.wfg-meta{font-size:12px;fill:var(--muted)!important;letter-spacing:.02em}
.wfg-title{font-size:15px;font-weight:600;fill:var(--strong,var(--text))!important}
.wfg-roles{font-size:13px;font-family:var(--mono)!important;fill:var(--text)}
.wfg-miss{font-size:12px;fill:var(--danger)!important}
.wfg-node .wfg-none{fill:var(--muted);font-family:var(--font)}
.wfg-edge{fill:none;stroke:var(--muted);stroke-width:1.5}
.wfg-arrow{fill:var(--muted)}
.wfg-detail{margin-top:16px;overflow-wrap:anywhere}
.wfg-detail h2{margin:0 0 10px}
.wfg-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:baseline;gap:8px 16px;margin:0 0 12px}
.wfg-dl dt{color:var(--muted);font-size:13px}
.wfg-dl dd{margin:0}
@media (max-width:700px){
.wfg-dl{grid-template-columns:minmax(0,1fr);gap:2px}
.wfg-dl dd{margin-bottom:8px}
}
/* The organization page's card, above Settings in the right column. */
.wfg-card>.ws-sec-h{margin-top:0}
.wfg-card+.ws-sec-h{margin-top:22px}
.wfg-cardlist{list-style:none;margin:0}
.wfg-cardlist li+li{border-top:1px solid var(--line-soft,var(--line))}
.wfg-cardlist a{display:block;padding:8px 0;color:var(--text);text-decoration:none;overflow-wrap:anywhere}
.wfg-cardlist a:hover strong{text-decoration:underline}
.wfg-cardlist strong{color:var(--action,var(--accent))}
.wfg-card [data-wfg-none]{margin:0 0 8px}
.wfg-card p:last-child{margin-bottom:0}
+185 -1
View File
@@ -197,5 +197,189 @@
} catch (error) { s.status = error.message; }
redraw(org);
});
window.MosaicWorkflows = { load, section };
// ---------- SP9 (#1873): the read-only view ----------
// #/orgs/<id>/workflows lists the organization's workflows; #/orgs/<id>/workflows/<name>[?project=<p>]
// draws one as an inline-SVG graph. The data is the same read as the editor above: each step's title,
// state and roles, in file order. A workflow has no edges or branches in the file, so a step hands on to
// the next one. Nothing else is drawn: no gate, no inputs and no running mark, because no read gives them.
const WS = () => window.MosaicWorkspace;
const orgHash = org => `#/orgs/${encodeURIComponent(org)}`;
const flowHref = (org, scope, name, step) => {
const q = new URLSearchParams();
if (scope) q.set('project', scope);
if (step) q.set('step', String(step));
return `${orgHash(org)}/workflows/${encodeURIComponent(name)}${q.size ? `?${q}` : ''}`;
};
const stepsOf = w => (Array.isArray(w?.steps) ? w.steps.filter(st => st && typeof st === 'object') : []);
const rolesOf = st => (Array.isArray(st?.roles) ? st.roles.filter(r => typeof r === 'string') : []);
const count = n => `${n} step${n === 1 ? '' : 's'}`;
const stateLabel = st => STATE_LABEL[st?.state] ?? String(st?.state ?? '');
const scopeLabel = scope => (scope ? `Project ${scope}` : 'Organization');
// Each scope that holds a workflow, the organization first. Names are own keys only.
const listed = s => scopes(s).map(([scope, label, flows]) => [scope, label, Object.keys(plain(flows) ? flows : {}).sort().map(n => [n, flows[n]])]);
const total = s => listed(s).reduce((n, [, , rows]) => n + rows.length, 0);
// The organization's bus state from the membership list, and for one that is down the workspace read's
// reason in the Inbox's words (orgsOf + unreadNote, the data-ws-unread note).
async function orgOf(org) {
const list = await window.MosaicWorkspaceData.organizations().catch(() => null);
const row = Array.isArray(list) ? list.find(o => o?.id === org && !o.invalid) : null;
if (!row) return { row: null, list: Array.isArray(list) };
let unread = '';
if (!row.busHealthy) {
const body = await window.MosaicWorkspaceData.workspace({ org, sections: 'projects' }).catch(() => null);
const o = body ? WS().orgsOf({ orgs: (Array.isArray(body.orgs) ? body.orgs : []).filter(r => r?.org === org) }) : null;
unread = o?.unread.length ? WS().unreadNote(o, [], 'workflows')
: `<p class="note ws-unread-note" data-ws-unread>Not read: ${esc(row.label || org)} ${row.busStopped ? 'bus stopped' : 'bus not answering'}. No reason was read.</p>`;
}
return { row, unread, stopped: !row.busHealthy && row.busStopped === true, label: row.label || org };
}
const head = (title, sub) => `<div class="ws-vh"><h1 tabindex="-1">${title}</h1><p class="sub">${sub}</p></div>`;
const applyLink = (org, s) => (s?.data?.writable
? `<p><a class="ws-btn" href="${esc(`${orgHash(org)}/organization`)}" data-wfg-apply data-focus-key="wfg-apply">Apply a pack</a></p>`
: '<p class="note">A starter pack brings workflows. Only the owner, on a Console started with send, can apply one.</p>');
const none = (org, s, label) => `<p class="note" data-wfg-none>No workflows in ${esc(label)} yet.</p>${applyLink(org, s)}`;
function listBody(org, s, label) {
if (total(s) === 0) return none(org, s, label);
return listed(s).filter(([scope, , rows]) => !scope || rows.length).map(([scope, label2, rows]) => `<section class="wfg-scope" data-wfg-scope-list="${esc(scope)}"><h2>${esc(label2)}</h2>${rows.length ? `<ul class="wfg-list">${rows.map(([name, w]) => {
const steps = stepsOf(w);
return `<li class="wfg-item" data-wfg-item="${esc(name)}" data-wfg-scope="${esc(scope)}"><div class="wfg-item-h"><a href="${esc(flowHref(org, scope, name))}" data-wfg-open data-focus-key="${esc(`wfg-open:${scope}:${name}`)}">${esc(w?.title || name)}</a>${w?.title ? ` <code>${esc(name)}</code>` : ''} <span class="muted">${count(steps.length)}</span></div>
${w?.description ? `<p class="wfg-desc">${esc(w.description)}</p>` : ''}<ol class="wfg-chain" data-wfg-chain aria-label="Steps in order">${steps.map(st => `<li>${esc(st.title)}</li>`).join('')}</ol></li>`;
}).join('')}</ul>` : '<p class="note">None at the organization level.</p>'}</section>`).join('');
}
// Layout: left to right on a wide screen, top to bottom on a narrow one. The graph scrolls inside its
// own frame when it is wider than the frame; the page never does.
const NARROW = '(max-width: 700px)';
const narrow = () => window.matchMedia(NARROW).matches;
const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1)}…` : text);
// Up to two lines of about `n` characters, broken at spaces; the second ends in … when the title is longer.
function wrap(text, n) {
const words = String(text ?? '').split(/\s+/).filter(Boolean), lines = [''];
for (const w of words) {
const cur = lines[lines.length - 1];
if (!cur || (cur + ' ' + w).length <= n) lines[lines.length - 1] = cur ? `${cur} ${w}` : w;
else if (lines.length < 2) lines.push(w);
else { lines[1] = `${lines[1]} ${w}`; }
}
if (lines[0].length > n) return [clip(lines[0], n)];
if (lines[1] && lines[1].length > n) lines[1] = clip(lines[1], n);
return lines;
}
function graph(org, s, scope, name, steps, sel) {
const vertical = narrow();
const W = vertical ? 300 : 188, GAP = vertical ? 32 : 36, PAD = 14;
const per = vertical ? 36 : 21;
// Roles are monospace (13px, about 7.9px a character), so a character budget fits the box; the clip path below catches the rest.
const rper = Math.floor((W - 24) / 7.9);
// One role a line, at most three lines: with four or more, two roles and "+N more". Every role is in the label and the detail panel.
const roleLines = st => { const r = rolesOf(st); return !r.length ? [] : r.length <= 3 ? r : [...r.slice(0, 2), `+${r.length - 2} more`]; };
const titleEnd = st => 46 + (wrap(st.title, per).length - 1) * 19;
const rolesY = st => titleEnd(st) + 24;
const missY = st => rolesY(st) + (Math.max(roleLines(st).length, 1) - 1) * 17 + 18;
// Each box is as tall as the tallest step needs: meta, one or two title lines, its role lines, and the missing-role line if any.
const H = Math.max(...steps.map(st => (rolesOf(st).some(r => !s.known.has(r)) ? missY(st) : missY(st) - 18) + 16));
const pos = i => (vertical ? [PAD, PAD + i * (H + GAP)] : [PAD + i * (W + GAP), PAD]);
const width = vertical ? W + 2 * PAD : PAD * 2 + steps.length * W + (steps.length - 1) * GAP;
const height = vertical ? PAD * 2 + steps.length * H + (steps.length - 1) * GAP : H + 2 * PAD;
const edges = steps.slice(1).map((_, i) => {
const [x, y] = pos(i), [x2, y2] = pos(i + 1);
const d = vertical ? `M${x + W / 2} ${y + H} V${y2 - 3}` : `M${x + W} ${y + H / 2} H${x2 - 3}`;
return `<path class="wfg-edge" d="${d}" data-wfg-edge="${i + 1}-${i + 2}" marker-end="url(#wfg-arrow)"/>`;
}).join('');
const nodes = steps.map((st, i) => {
const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), missing = roles.filter(r => !s.known.has(r));
const lines = wrap(st.title, per), on = sel === n;
const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${missing.length ? `; ${missing.join(', ')} ${MISSING}` : ''}.`;
const rl = roleLines(st), more = roles.length > 3;
const roleText = rl.length
? rl.map((r, j) => `<tspan x="${x + 12}" dy="${j ? 17 : 0}"${more && j === rl.length - 1 ? ' data-wfg-more' : ''}>${esc(more && j === rl.length - 1 ? r : clip(r, rper))}</tspan>`).join('')
: '<tspan class="wfg-none">No roles recorded</tspan>';
return `<a class="wfg-node${on ? ' is-sel' : ''}" href="${esc(flowHref(org, scope, name, n))}" data-wfg-step="${n}" data-focus-key="${esc(`wfg-step:${n}`)}" aria-label="${esc(label)}"${on ? ' aria-current="step"' : ''}>
<title>${esc(`${st.title ?? ''}${roles.length ? ` (${roles.join(', ')})` : ''}`)}</title><rect x="${x}" y="${y}" width="${W}" height="${H}" rx="10"/>
<clipPath id="wfg-clip-${n}"><rect x="${x + 2}" y="${y}" width="${W - 10}" height="${H}"/></clipPath>
<g clip-path="url(#wfg-clip-${n})">
<text class="wfg-meta" x="${x + 12}" y="${y + 22}">Step ${n} · ${esc(stateLabel(st))}</text>
<text class="wfg-title" data-wfg-title x="${x + 12}" y="${y + 46}">${lines.map((l, j) => `<tspan x="${x + 12}" dy="${j ? 19 : 0}">${esc(l)}${j < lines.length - 1 ? ' ' : ''}</tspan>`).join('')}</text>
<text class="wfg-roles" data-wfg-roles x="${x + 12}" y="${y + rolesY(st)}">${roleText}</text>
${missing.length ? `<text class="wfg-miss" data-wfg-missing x="${x + 12}" y="${y + missY(st)}">${esc(clip(`No current role: ${missing.join(", ")}`, rper + 2))}</text>` : ''}</g></a>`;
}).join('');
return `<svg class="wfg-graph${vertical ? ' is-vertical' : ''}" data-wfg-graph role="group" aria-label="${esc(`${count(steps.length)}, in order`)}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs><marker id="wfg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="wfg-arrow" d="M0 0L10 5L0 10z"/></marker></defs>${edges}${nodes}</svg>`;
}
const stepRef = (org, scope, name, steps, n) => `<a href="${esc(flowHref(org, scope, name, n))}" data-wfg-go="${n}">Step ${n}, ${esc(steps[n - 1].title)}</a>`;
function detail(org, s, scope, name, steps, sel) {
if (!sel) return `<section class="panel wfg-detail" data-wfg-detail><h2>Step details</h2><p class="note">Select a step to see its roles, the state it puts a task in and the step it hands on to.</p></section>`;
const st = steps[sel - 1], roles = rolesOf(st);
const roleList = roles.map(r => r === 'human' ? '<code>human</code> (a person)' : s.known.has(r) ? `<code>${esc(r)}</code>` : `<code>${esc(r)}</code> <span class="wf-missing" data-wfg-missing>${MISSING}</span>`).join(', ');
return `<section class="panel wfg-detail" data-wfg-detail><h2 data-wfg-detail-title>${esc(st.title)}</h2><dl class="wfg-dl">
<dt>Position</dt><dd data-wfg-field="position">Step ${sel} of ${steps.length}</dd>
<dt>Roles</dt><dd data-wfg-field="roles">${roleList || '<span class="muted">None recorded</span>'}</dd>
<dt>Puts the task in</dt><dd data-wfg-field="state">${esc(stateLabel(st))}</dd>
<dt>Comes after</dt><dd data-wfg-field="after">${sel > 1 ? stepRef(org, scope, name, steps, sel - 1) : 'Nothing: this is the first step'}</dd>
<dt>Hands on to</dt><dd data-wfg-field="next">${sel < steps.length ? stepRef(org, scope, name, steps, sel + 1) : 'Nothing: this is the last step'}</dd>
</dl><p class="note">The workflow file records no gate or inputs for a step, and no read says which step a task is on now.</p></section>`;
}
function graphBody(org, s, name, q) {
const scope = q.get('project') || '';
const flows = scope ? s.data.projects[scope] : s.data.workflows;
if (scope && !Object.hasOwn(s.data.projects, scope)) return { title: esc(name), sub: '', body: `<p class="note" data-wfg-unknown>No code project named ${esc(scope)} in the organization file.</p>` };
if (!plain(flows) || !Object.hasOwn(flows, name)) return { title: esc(name), sub: '', body: `<p class="note" data-wfg-unknown>No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.</p>` };
const w = flows[name], steps = stepsOf(w);
const raw = Number(q.get('step')), sel = Number.isInteger(raw) && raw >= 1 && raw <= steps.length ? raw : 0;
const sub = `${esc(scopeLabel(scope))}, <code>${esc(name)}</code>, ${count(steps.length)}.${w?.description ? ` ${esc(w.description)}` : ''}`;
const body = steps.length
? `<div class="wfg-frame" data-wfg-frame>${graph(org, s, scope, name, steps, sel)}</div>${detail(org, s, scope, name, steps, sel)}`
: '<p class="note">This workflow has no steps.</p>';
return { title: esc(w?.title || name), sub, body };
}
// The page for #/orgs/<org>/workflows (name null) or #/orgs/<org>/workflows/<name>.
async function page(org, name, q) {
const attr = name === null ? 'data-wfg-list' : 'data-wfg-page';
const info = await orgOf(org);
if (!info.row) return `<article class="ws wfg" ${attr} data-wfg-org="${esc(org)}">${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}</article>`;
const wrapIt = (title, sub, body) => `<article class="ws wfg" ${attr} data-wfg-org="${esc(org)}">${head(title, sub)}${info.unread}${body}</article>`;
// E2E6 P2-8: a stopped bus is not read; the note above says why.
if (info.stopped) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, '');
await load(org);
const s = states.get(org);
if (!s?.data) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, `<p class="note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p>`);
if (name === null) return wrapIt('Workflows', `The steps a task goes through in ${esc(info.label)}, in order, with the roles that do each. Read only; the organization page changes them.`, listBody(org, s, info.label));
const g = graphBody(org, s, name, q);
return wrapIt(g.title, g.sub, g.body);
}
// The organization page's card: each workflow as a link to its graph, from the page's one workflow read.
function card(org, { stopped = false, label = org } = {}) {
const all = `<a href="${esc(`${orgHash(org)}/workflows`)}" data-wfg-all data-focus-key="wfg-all">All workflows</a>`;
const h = note => `<div class="ws-sec-h"><h2>Workflows</h2>${note ? `<span class="note">${note}</span>` : ''}${all}</div>`;
if (stopped) return `<div class="wfg-card" data-wfg-card data-bus-stopped>${h('')}<p class="ws-panel tight note">Not read: the bus is stopped.</p></div>`;
const s = states.get(org);
if (!s?.data) return `<div class="wfg-card" data-wfg-card>${h('')}<p class="ws-panel tight note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p></div>`;
const rows = listed(s).flatMap(([scope, , list]) => list.map(([name, w]) => [scope, name, w]));
if (!rows.length) return `<div class="wfg-card" data-wfg-card>${h('none')}<div class="ws-panel tight">${none(org, s, label)}</div></div>`;
return `<div class="wfg-card" data-wfg-card>${h(String(rows.length))}<ul class="ws-panel tight wfg-cardlist">${rows.map(([scope, name, w]) => `<li><a href="${esc(flowHref(org, scope, name))}" data-wfg-card-item data-focus-key="${esc(`wfg-card:${scope}:${name}`)}"><strong>${esc(w?.title || name)}</strong> <span class="muted">${count(stepsOf(w).length)}${scope ? `, project ${esc(scope)}` : ''}</span></a></li>`).join('')}</ul></div>`;
}
// A step box or a step link in the details selects that step in the address (setQuery repaints).
document.addEventListener('click', ev => {
const link = ev.target.closest('[data-wfg-step],[data-wfg-go]');
if (link && WS()?.setQuery && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey) {
ev.preventDefault();
WS().setQuery({ step: link.dataset.wfgStep ?? link.dataset.wfgGo });
return;
}
// Apply a pack: the pack picker on the organization page, scrolled into view.
const apply = ev.target.closest('[data-wfg-apply]');
if (!apply || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const show = () => { const p = document.querySelector('[data-packs]'); if (!p) return false; p.scrollIntoView({ block: 'start' }); return true; };
if (location.hash === apply.getAttribute('href') && show()) { ev.preventDefault(); return; }
const until = Date.now() + 8000;
const wait = () => { if (location.hash === apply.getAttribute('href') && document.querySelector('[data-org-dashboard] [data-packs]')) setTimeout(show, 0); else if (Date.now() < until) setTimeout(wait, 60); };
setTimeout(wait, 60);
});
// Crossing the narrow breakpoint redraws the graph in the other direction.
window.matchMedia(NARROW).addEventListener('change', () => {
if (document.querySelector('[data-wfg-graph]')) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
});
window.MosaicWorkflows = { load, section, page, card };
})();
+2 -1
View File
@@ -343,7 +343,8 @@
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
${queueWhy}
</section>
<section aria-label="Settings">
<section aria-label="Workflows and settings">
${window.MosaicWorkflows?.card?.(id, { stopped, label: label(org) }) ?? ''}
${secH('Settings', 'every setting, with where its value comes from')}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
</section>
+2 -1
View File
@@ -86,6 +86,7 @@ const files = new Map([
['/packs.js', ['packs.js', 'text/javascript; charset=utf-8']],
['/workflows.js', ['workflows.js', 'text/javascript; charset=utf-8']],
['/packs.css', ['packs.css', 'text/css; charset=utf-8']],
['/workflows.css', ['workflows.css', 'text/css; charset=utf-8']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]);
@@ -1085,7 +1086,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin });
}
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' });
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js', '/workflows.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
@@ -301,11 +301,12 @@ test('keyboard focus follows visual order and survives a data refresh', async t
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'];
// The right column holds the Workflows card (SP9) above Settings.
const regions = ['nav', 'head', 'Measures and sessions', 'Workflows and 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}`);
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'wfg-all', '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, and Stop bus follows it.
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1);
@@ -0,0 +1,288 @@
// Single pane SP9 (#1873): an organization's workflows, read only. #/orgs/<id>/workflows lists them,
// #/orgs/<id>/workflows/<name> draws one as an inline-SVG graph (one box per step, an arrowed line per
// hand-off), and the organization page carries a small card. The data is what GET /api/orgs/<id>/workflows
// returns from the organization file: steps in order, each with a title, a state and its roles. Nothing
// else is drawn. A stopped organization is not read and says why.
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 XSS = '<img src=x onerror=window.__sp9pwn=1>';
const at = minutes => new Date(Date.now() - minutes * 60000).toISOString();
const waitFor = b => (expression, what = expression) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,1500))})()`);
// The coding starter pack's workflow as pack.apply writes it (the pack's step ids are not kept).
const CHANGE = {
description: 'One change from task to merge.',
steps: [
{ title: 'Write the task', roles: ['pm'], state: 'todo' },
{ title: 'Implement the change', roles: ['coder'], state: 'in-progress' },
{ title: 'Review the change', roles: ['reviewer'], state: 'in-review' },
{ title: 'Approve the merge', roles: ['human', 'merge-gate'], state: 'done' },
],
};
const DOCS = {
acme: {
definitions: { pm: {}, coder: {}, reviewer: {} },
workflows: { change: CHANGE, intake: { title: `Intake ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['human'] }] } },
projects: { site: { workflows: { change: { title: 'Site release', steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } } }, notes: { kind: 'library', workflows: { hidden: { steps: [{ title: 'x', state: 'todo', roles: ['human'] }] } } } },
},
bare: { definitions: {}, projects: {} },
};
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'bare', label: 'Bare Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
];
function organizations(reads) {
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: {}, projects: {} },
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: () => ({ bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => [] }, chat: { threads: async () => [], projects: async () => [] } }),
});
return {
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
const workflowsRead = async () => { reads.push(id); return { revision: 'a'.repeat(64), document: structuredClone(DOCS[id] ?? {}), templates: ['writer'] }; };
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true, workflowsRead, packsWrite: async () => ({}), decisionWrite: async () => ({}), send: async () => ({}),
requestRead: async verb => { if (verb === 'packs.console') return { packs: [] }; if (verb === 'autonomy.get.console') return { revision: 'e'.repeat(64), autonomy: {}, levels: {} }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); },
reads: { business: async () => ({ business: { id, projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] },
};
},
};
}
async function setup(t, { path, viewport = [1440, 900], mode = null } = {}) {
const reads = [];
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads) });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(() => b.close());
await b.viewport(...viewport);
// The Console's own mode wins over the OS setting (Harbor in the dark by default), so a theme test sets it per organization.
if (mode) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `try { for (const o of ['acme','bare','gamma']) localStorage.setItem('mosaic-console-appearance:' + o, JSON.stringify({ theme: 'harbor', mode: ${JSON.stringify(mode)} })); } catch {}` });
await b.navigate(`http://127.0.0.1:${server.address().port}/${path}`);
return { b, reads, wait: waitFor(b) };
}
const boxes = `[...document.querySelectorAll('[data-wfg-graph] [data-wfg-step]')]`;
test('the list names each workflow by scope with its steps in order, links to its graph, and keeps titles as text', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows' });
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item="change"]')`, 'workflow list');
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'Workflows');
// Organization first, then each code project; a library project has no workflows.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list]')].map(n=>[n.dataset.wfgScopeList,n.querySelector('h2').textContent])`), [['', 'Organization'], ['site', 'Project site']]);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list=""] [data-wfg-item]')].map(n=>n.dataset.wfgItem)`), ['change', 'intake']);
const change = `[data-wfg-scope-list=""] [data-wfg-item="change"]`;
assert.equal(await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-scope-list="site"] [data-wfg-item="change"] a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change?project=site');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${change} [data-wfg-chain] li')].map(l=>l.textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /4 steps/);
assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /One change from task to merge\./);
// The title is shown as written.
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-item="intake"] a[data-wfg-open]').textContent`), `Intake ${XSS}`);
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view img').length`), 0);
assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true);
assert.equal(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]')`), null, 'a running bus has no unread note');
// The trail names the organization and the page.
await wait(`document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Acme Studio'`, 'org crumb');
// A click opens the graph.
await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').click();true`);
await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph after click');
});
test('the graph draws one box per step in order with its roles and one arrowed edge per hand-off', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change' });
await wait(`${boxes}.length === 4`, 'four boxes');
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'change');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]').tagName.toLowerCase()`), 'svg');
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.dataset.wfgStep)`), ['1', '2', '3', '4']);
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
// One role per line.
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>[...g.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent))`), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]);
// A role that resolves to no definition or template (and is not human) is marked on its box.
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelectorAll('[data-wfg-missing]').length)`), [0, 0, 0, 1]);
// At 1440 the steps run left to right.
const xs = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().left)`);
assert.deepEqual([...xs].sort((p, q) => p - q), xs);
assert.equal(new Set(xs).size, 4);
// One edge per hand-off, from each box to the next, with an arrowhead.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>e.dataset.wfgEdge)`), ['1-2', '2-3', '3-4']);
const touches = await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>{
const [f,t]=e.dataset.wfgEdge.split('-'), box=n=>document.querySelector('[data-wfg-graph] [data-wfg-step="'+n+'"] rect').getBBox();
const p=e.getPointAtLength(0), q=e.getPointAtLength(e.getTotalLength()), a=box(f), z=box(t), near=(pt,r)=>pt.x>=r.x-14&&pt.x<=r.x+r.width+14&&pt.y>=r.y-14&&pt.y<=r.y+r.height+14;
return near(p,a)&&near(q,z)&&!!e.getAttribute('marker-end');})`);
assert.deepEqual(touches, [true, true, true]);
// The graph scrolls inside its own box, never the page.
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
// Nothing running is drawn: no read links a task to a step.
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-running]')`), null);
});
test('selecting a step shows what the data holds for it and survives a repaint', { timeout: 60000 }, async t => {
const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/workflows/change' });
await wait(`${boxes}.length === 4`, 'four boxes');
assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /Select a step/);
await b.evaluate(`document.querySelector('[data-wfg-step="2"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`);
await wait(`/[?&]step=2/.test(location.hash) && document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'detail for step 2');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=2');
const field = name => b.evaluate(`document.querySelector('[data-wfg-detail] [data-wfg-field="${name}"]')?.textContent.replace(/\\s+/g,' ').trim() ?? null`);
assert.equal(await field('position'), 'Step 2 of 4');
assert.equal(await field('roles'), 'coder');
assert.equal(await field('state'), 'In progress');
assert.equal(await field('after'), 'Step 1, Write the task');
assert.equal(await field('next'), 'Step 3, Review the change');
// The workflow file holds no gate or inputs for a step; the panel says so once instead of inventing them.
assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /records no gate or inputs/);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="2"]').getAttribute('aria-current')`), 'step');
assert.equal(await b.evaluate(`document.querySelectorAll('[data-wfg-graph] [aria-current]').length`), 1);
// The last step hands on to nothing; an unknown role is named in the panel too.
await b.evaluate(`document.querySelector('[data-wfg-step="4"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`);
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'detail for step 4');
assert.equal(await field('next'), 'Nothing: this is the last step');
assert.match(await field('roles'), /human \(a person\)/);
assert.match(await field('roles'), /merge-gate names no current role/);
// Keyboard: a step box takes focus and opens on Enter, and keeps focus through the repaint.
await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`);
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-step:1');
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Write the task' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1 and keeps focus');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=1');
await b.evaluate(`document.querySelector('[data-wfg-step="4"]').focus();true`);
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'Enter opens step 4');
// A repaint (the poll) keeps the selection.
const before = reads.length;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'kept after repaint');
assert.ok(reads.length >= before);
// A project's workflow of the same name is its own graph.
await b.evaluate(`location.hash = '#/orgs/acme/workflows/change?project=site'`);
await wait(`${boxes}.length === 2`, 'project graph');
assert.match(await b.evaluate(`document.querySelector('#bus-view').textContent`), /Project site/);
// A name the scope does not hold says so.
await b.evaluate(`location.hash = '#/orgs/acme/workflows/nope'`);
await wait(`/No workflow named nope/.test(document.querySelector('#bus-view').textContent)`, 'unknown workflow');
});
test('a step with more roles than fit shows three lines, the last counting the rest, all inside its box', { timeout: 60000 }, async t => {
// The business-management pack's first step names four long roles.
const roles = ['chief-executive-officer', 'chief-financial-officer', 'chief-information-officer', 'chief-information-security-officer'];
DOCS.acme.workflows.brief = { steps: [{ title: 'Draft the brief', state: 'todo', roles }, { title: 'WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWW', state: 'done', roles: ['human'] }] };
t.after(() => { delete DOCS.acme.workflows.brief; });
for (const viewport of [[1440, 900], [390, 844]]) {
await t.test(String(viewport[0]), async t2 => {
const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/brief', viewport });
await wait(`${boxes}.length === 2`, 'two boxes');
const one = `document.querySelector('[data-wfg-step="1"]')`;
// A role longer than the box is shortened with an ellipsis (the full name is in the box's title and the step panel).
const lines = await b.evaluate(`[...${one}.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent)`);
assert.equal(lines.length, 3);
assert.equal(lines[2], '+2 more');
lines.slice(0, 2).forEach((line, i) => assert.ok(line === roles[i] || (line.endsWith('…') && roles[i].startsWith(line.slice(0, -1))), line));
if (viewport[0] === 390) assert.deepEqual(lines.slice(0, 2), roles.slice(0, 2), 'the 390 column is wide enough for the whole name');
assert.equal(await b.evaluate(`${one}.querySelector('[data-wfg-roles] [data-wfg-more]').textContent`), '+2 more');
// The box names every role for a pointer or a screen reader.
const named = await b.evaluate(`${one}.querySelector('title').textContent + ' ' + (${one}.getAttribute('aria-label') ?? '')`);
for (const r of roles) assert.ok(named.includes(r), r);
// Every text line ends inside its own box.
const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect').getBoundingClientRect();return [...g.querySelectorAll('text tspan, text:not(:has(tspan))')].filter(t=>{const q=t.getBoundingClientRect();const c=g.querySelector('clipPath rect')?.getBoundingClientRect()??r;return Math.min(q.right,c.right)>r.right+0.5||Math.min(q.bottom,c.bottom)>r.bottom+0.5}).map(t=>t.textContent)})`);
assert.deepEqual(out, []);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
});
}
});
test('an organization with no workflows says so on its page and its card, and links to apply a pack', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/bare/workflows' });
await wait(`document.querySelector('[data-wfg-list] [data-wfg-none]')`, 'none note');
assert.match(await b.evaluate(`document.querySelector('[data-wfg-none]').textContent`), /No workflows in Bare Works yet/);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-apply]').getAttribute('href')`), '#/orgs/bare/organization');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-item]')`), null);
// The organization page's card says the same and links the list.
await b.evaluate(`location.hash = '#/orgs/bare/organization'`);
await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card] [data-wfg-none]')`, 'card none');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/bare/workflows');
// Apply a pack takes the person to the pack picker on the same page.
await wait(`document.querySelector('[data-packs]')`, 'packs section');
await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-apply]').click();true`);
await wait(`(()=>{const r=document.querySelector('[data-packs]').getBoundingClientRect();return r.top>=0&&r.top<innerHeight})()`, 'packs in view');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/bare/organization');
});
test('the organization page card lists each workflow as a link to its graph', { timeout: 60000 }, async t => {
const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/organization' });
await wait(`document.querySelectorAll('[data-org-dashboard] [data-wfg-card] [data-wfg-card-item]').length === 3`, 'card items');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-card] [data-wfg-card-item]')].map(a=>[a.getAttribute('href'),a.textContent.replace(/\\s+/g,' ').trim()])`), [
['#/orgs/acme/workflows/change', 'change 4 steps'],
['#/orgs/acme/workflows/intake', `Intake ${XSS} 1 step`],
['#/orgs/acme/workflows/change?project=site', 'Site release 2 steps, project site'],
]);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/acme/workflows');
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-dashboard] img').length`), 0);
assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true);
// The card reuses the page's one workflow read; it sends no second one.
assert.equal(reads.filter(id => id === 'acme').length, 1);
await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-card-item]').click();true`);
await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph from card');
});
test('a stopped organization is not read: the page and card say the bus is stopped with the reason, never "no workflows"', { timeout: 60000 }, async t => {
const { b, wait, reads } = await setup(t, { path: '#/orgs/gamma/workflows' });
await wait(`document.querySelector('[data-wfg-list] [data-ws-unread]')`, 'unread note');
const note = await b.evaluate(`document.querySelector('[data-wfg-list] [data-ws-unread]').textContent`);
assert.match(note, /^Not read: Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflows/);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-none],[data-wfg-item]')`), null);
// The graph address says the same.
await b.evaluate(`location.hash = '#/orgs/gamma/workflows/change'`);
await wait(`document.querySelector('[data-wfg-page] [data-ws-unread]')`, 'graph unread note');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]')`), null);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflow/);
// The organization page card.
await b.evaluate(`location.hash = '#/orgs/gamma/organization'`);
await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card][data-bus-stopped]')`, 'stopped card');
assert.match(await b.evaluate(`document.querySelector('[data-wfg-card]').textContent`), /Not read: the bus is stopped\./);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-none]')`), null);
assert.equal(reads.filter(id => id === 'gamma').length, 0, 'a stopped organization is not read');
});
test('at 390 the graph runs top to bottom inside its box and the page does not scroll sideways', { timeout: 60000 }, async t => {
for (const mode of ['light', 'dark']) {
const dark = mode === 'dark';
await t.test(mode, async t2 => {
const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/change?step=3', viewport: [390, 844], mode });
await wait(`${boxes}.length === 4 && document.querySelector('[data-wfg-detail-title]')`, 'graph at 390');
assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode, 'the page shows the mode under test');
const ys = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().top)`);
assert.deepEqual([...ys].sort((p, q) => p - q), ys);
assert.equal(new Set(ys).size, 4);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'no sideways page scroll');
// Every box fits the width of its own box.
assert.equal(await b.evaluate(`(()=>{const w=document.querySelector('[data-wfg-graph]').closest('[data-wfg-frame]').getBoundingClientRect();return ${boxes}.every(g=>{const r=g.querySelector('rect').getBoundingClientRect();return r.left>=w.left-1&&r.right<=w.right+1})})()`), true);
// The box text and the edges read in the theme's colours, not the browser default black on dark.
const fill = await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] [data-wfg-title]')).fill`);
const bg = await b.evaluate(`getComputedStyle(document.body).backgroundColor`);
assert.notEqual(fill, bg);
assert.notEqual(fill, dark ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)');
// An SVG rect with no theme fill is drawn black.
assert.notEqual(await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] rect')).fill`), 'rgb(0, 0, 0)');
// The list and the card fit too.
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list at 390');
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'list: no sideways page scroll');
});
}
});