// Single pane SP9 (#1873): an organization's workflows, read only. #/orgs//workflows lists them, // #/orgs//workflows/ 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//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 = ''; 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 { 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'); }); } });