289 lines
23 KiB
JavaScript
289 lines
23 KiB
JavaScript
// 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');
|
|
});
|
|
}
|
|
});
|