Files
stack/packages/webui/tests/workflows-browser.test.mjs
T

670 lines
53 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.
// SP10 (#1873): the graph page edits the workflow. The change is held in the page until Save, which sends one
// POST with the revision the page read; a refusal is shown in words and the change kept; the poll never draws
// over a pending change. The fixture's write stub keeps the organization file and its revision, as the bus does.
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 },
];
// The organization files as the bus holds them: each organization's document and revision, the writes the
// page sent, and a refusal to give the next write. A write with another revision is a revision-conflict.
const hex = n => n.toString(16).padStart(64, '0');
function store(writable) {
const st = { writable, docs: structuredClone(DOCS), revs: { acme: 'a'.repeat(64), bare: 'b'.repeat(64), gamma: 'c'.repeat(64) }, writes: [], refuse: null, n: 0 };
const fail = code => Object.assign(new Error(code), { code });
st.write = (id, verb, args) => {
st.writes.push({ id, verb, args: structuredClone(args) });
if (verb !== 'workflow.set.console') throw fail('not-configured');
if (st.refuse) { const code = st.refuse; st.refuse = null; throw fail(code); }
if (args.revision !== st.revs[id]) throw fail('revision-conflict');
const doc = st.docs[id], scope = args.project ? doc.projects[args.project] : doc;
scope.workflows ??= {};
if (args.workflow === null) delete scope.workflows[args.name]; else scope.workflows[args.name] = structuredClone(args.workflow);
st.revs[id] = hex(++st.n);
return { revision: st.revs[id], added: [] };
};
// Another writer changes the file outside the page.
st.change = (id, fn) => { fn(st.docs[id]); st.revs[id] = hex(1000 + ++st.n); };
return st;
}
function organizations(reads, st) {
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); if (st.failRead) throw Object.assign(new Error('org-unavailable'), { code: 'org-unavailable' }); return { revision: st.revs[id], document: structuredClone(st.docs[id] ?? {}), templates: ['writer'] }; };
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true, workflowsRead, packsWrite: st.writable ? async (verb, args) => st.write(id, verb, args) : null, 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, writable = true } = {}) {
const reads = [], st = store(writable);
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads, st) });
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, st, 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]);
// sp9 follow-up: the missing role's whole name shows at 1440, on its own line under "No current role:".
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-missing] tspan')].map(t=>t.textContent)`), ['No current role:', 'merge-gate']);
// 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: its drawn extent, not what the clip path lets show, ends left
// of the clip edge (8px inside the box) and above the box bottom.
const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect'),right=r.x.baseVal.value+r.width.baseVal.value-8,bottom=r.y.baseVal.value+r.height.baseVal.value;
return [...g.querySelectorAll('text')].flatMap(t=>{const spans=[...t.querySelectorAll('tspan')];return (spans.length?spans:[t]).filter(e=>e.getNumberOfChars()>0).filter(e=>{let n=e.getNumberOfChars();while(n>1&&/\\s/.test(e.textContent[n-1]))n--;const z=e.getExtentOfChar(n-1),a=e.getExtentOfChar(0);return z.x+z.width>right+0.5||z.y+z.height>bottom+0.5||a.x<r.x.baseVal.value}).map(e=>e.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');
});
}
});
// ---------- SP10: editing a workflow in its graph ----------
const REV_A = 'a'.repeat(64);
const titles = `${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`;
const press = sel => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});if(!e)throw Error('no '+${JSON.stringify(sel)});if(e.disabled)throw Error('disabled '+${JSON.stringify(sel)});e.click();return true})()`;
const pick = step => `document.querySelector('[data-wfg-step="${step}"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))`;
// Sets a form field as typing would: the value, then the input event the page listens for.
const fill = (sel, value) => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`;
const role = (form, name) => `${form} input[name="role"][value="${name}"]`;
const text = sel => `(document.querySelector(${JSON.stringify(sel)})?.textContent ?? '').replace(/\\s+/g,' ').trim()`;
test('editing a step changes the graph on the page, and Save sends one POST with the revision the page read', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=4' });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge' && document.querySelector('[data-wfg-edit]')`, 'step 4 with Edit');
// Nothing pending: no Unsaved, no Save.
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-unsaved],[data-wfg-save],[data-wfg-discard]')`), null);
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form] [data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'step form');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-f-title');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]').value`), 'Approve the merge');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] select[name="state"]').value`), 'done');
// The picker offers human, the organization's roles and the templates; the step's unknown role is first, checked and marked.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-form] input[name="role"]')].map(i=>[i.value,i.checked])`), [['merge-gate', true], ['human', true], ['coder', false], ['pm', false], ['reviewer', false], ['writer', false]]);
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-form] [data-wfg-pick-missing]')`));
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Approve and merge'));
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
await b.evaluate(press(role('[data-wfg-form]', 'reviewer')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[3] === 'Approve and merge' && document.querySelector('[data-wfg-unsaved]')`, 'pending change drawn');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-roles] tspan')].map(t=>t.textContent)`), ['human', 'reviewer']);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`), null);
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-save');
assert.equal(st.writes.length, 0, 'Apply writes nothing');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')}) && !document.querySelector('[data-wfg-unsaved]')`, 'saved');
assert.equal(st.writes.length, 1);
assert.deepEqual(st.writes[0], { id: 'acme', verb: 'workflow.set.console', args: { revision: REV_A, name: 'change', workflow: {
description: 'One change from task to merge.',
steps: [
{ title: 'Write the task', state: 'todo', roles: ['pm'] },
{ title: 'Implement the change', state: 'in-progress', roles: ['coder'] },
{ title: 'Review the change', state: 'in-review', roles: ['reviewer'] },
{ title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] },
] } } });
// The page now draws the file as saved, read again.
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve and merge'`, 'detail after save');
assert.deepEqual(st.docs.acme.workflows.change.steps[3], { title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] });
});
test('add, move, remove and discard change only the page until Save', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2' });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
// Add after the selected step.
await b.evaluate(press('[data-wfg-add]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 3'`, 'add form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Test the change'));
await b.evaluate(`(()=>{const e=document.querySelector('[data-wfg-form] select[name="state"]');e.value='in-review';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
// Apply with no role is refused in words, and the form stays.
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${text('[data-wfg-why]')} === 'Step 3 needs at least one role.'`, 'no role refused');
await b.evaluate(press(role('[data-wfg-form]', 'coder')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${boxes}.length === 5 && ${titles}[2] === 'Test the change'`, 'step added');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=3');
assert.equal(await b.evaluate(text('[data-wfg-field="state"]')), 'Review');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
// Move earlier and later.
await b.evaluate(press('[data-wfg-up]'));
await wait(`${titles}[1] === 'Test the change' && /step=2/.test(location.hash)`, 'moved earlier');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-up');
await b.evaluate(press('[data-wfg-down]'));
await wait(`${titles}[2] === 'Test the change' && /step=3/.test(location.hash)`, 'moved later');
// Selecting another step goes through a full render (a read); the pending change stays.
await b.evaluate(pick(1));
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Write the task' && ${boxes}.length === 5`, 'step 1, change kept');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-up]').disabled`), true, 'the first step cannot move earlier');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 4`, 'step removed');
assert.deepEqual(await b.evaluate(titles), ['Implement the change', 'Test the change', 'Review the change', 'Approve the merge']);
// With no step selected, Add puts a step at the end.
await b.evaluate(`location.hash = '#/orgs/acme/workflows/change'`);
await wait(`${text('[data-wfg-add]')} === 'Add a step at the end'`, 'add at end');
await b.evaluate(press('[data-wfg-add]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 5'`, 'add-at-end form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Announce it'));
await b.evaluate(press(role('[data-wfg-form]', 'pm')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${boxes}.length === 5 && ${titles}[4] === 'Announce it' && /step=5/.test(location.hash)`, 'added at end');
// Discard drops all of it; nothing was written.
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${boxes}.length === 4 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
assert.deepEqual(await b.evaluate(titles), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
assert.equal(st.writes.length, 0);
});
test('a refused save says why next to Save and keeps the pending change', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=1' });
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 1');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Write the brief'));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[0] === 'Write the brief'`, 'pending');
// Step 4 still names merge-gate: the page refuses before sending, in the same words as the form editor.
await b.evaluate(press('[data-wfg-save]'));
await wait(`${text('[data-wfg-status]')} === 'Step 4: merge-gate names no current role. Use an organization role, a role template or human.'`, 'unknown role refused');
assert.equal(st.writes.length, 0);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
// Fix step 4, then the bus is down.
await b.evaluate(pick(4));
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'step 4');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'form 4');
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`!document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`, 'step 4 fixed');
st.refuse = 'no-bus-host';
await b.evaluate(press('[data-wfg-save]'));
await wait(`/bus is not running/.test(${text('[data-wfg-status]')})`, 'bus down refused');
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept/);
assert.equal(st.writes.length, 1);
assert.equal(st.writes[0].args.revision, REV_A);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]') && !!document.querySelector('[data-wfg-save]')`));
assert.equal(st.docs.acme.workflows.change.steps[0].title, 'Write the task', 'nothing written');
// The status sits in the bar with Save.
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-bar] [data-wfg-status]') && !!document.querySelector('[data-wfg-bar] [data-wfg-save]')`));
// Another writer changes this workflow: the save is refused as stale, the change kept.
st.change('acme', doc => { doc.workflows.change.description = 'Changed elsewhere.'; });
await b.evaluate(press('[data-wfg-save]'));
await wait(`/changed since this page read it/.test(${text('[data-wfg-status]')})`, 'stale refused');
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept\. Discard to load the current version\./);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
assert.equal(st.writes.length, 2);
});
test('the poll does not draw over a pending change, takes a new revision when only other workflows changed, and says when this one changed', { timeout: 60000 }, async t => {
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
// The site project's workflow names only current roles, so a save goes to the bus.
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 2');
const repaint = async () => { const n = reads.length; await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`); await wait(`true`); for (let i = 0; i < 100 && reads.length === n; i++) await new Promise(r => setTimeout(r, 30)); await new Promise(r => setTimeout(r, 150)); };
// A form being typed in survives a repaint as typed.
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
await repaint();
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release');
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[1] === 'Ship the release' && document.querySelector('[data-wfg-unsaved]')`, 'pending');
await repaint();
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release', 'the poll keeps the pending change');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
// Another workflow changes in the file: this one is as read, so the page takes the new revision and no warning shows.
st.change('acme', doc => { doc.workflows.intake.title = 'Intake, renamed'; });
const fresh = st.revs.acme;
await repaint();
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-stale]')`), null);
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved on the new revision');
assert.equal(st.writes.at(-1).args.revision, fresh);
assert.equal(st.docs.acme.workflows.intake.title, 'Intake, renamed', 'the other workflow is untouched');
// Now this workflow changes underneath a pending change: the page says so and offers Discard.
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form again');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Build it'));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[1] === 'Build it'`, 'pending again');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
await repaint();
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
assert.match(await b.evaluate(text('[data-wfg-stale]')), /changed this workflow since the page read it.*Discard to load the current version/);
assert.equal(await b.evaluate(`${titles}[1]`), 'Build it');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-discard]')`));
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${titles}[1] === 'Ship the release' && !document.querySelector('[data-wfg-stale],[data-wfg-unsaved]')`, 'discarded to current');
assert.match(await b.evaluate(`document.querySelector('#bus-view .sub').textContent`), /Changed elsewhere\./);
});
test('a reader who cannot write sees no edit controls and one line saying who can', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2', writable: false });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-bar],[data-wfg-actions],[data-wfg-edit],[data-wfg-add],[data-wfg-save],[data-wfg-delete],[data-wfg-form]')`), null);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#bus-view [data-wfg-readonly]')].map(n=>n.textContent)`), ['Only the owner, on a Console started with send, can change workflows.']);
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view button').length`), 0);
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-new]')`), null);
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-list] [data-wfg-readonly]')`));
});
test('a new workflow starts on the list page, opens in its graph unsaved, saves, and deletes after a confirm', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows' });
await wait(`document.querySelector('[data-wfg-new]')`, 'New workflow');
await b.evaluate(press('[data-wfg-new]'));
await wait(`document.querySelector('[data-wfg-newform]')`, 'new form');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-n-name');
const create = async () => b.evaluate(press('[data-wfg-create]'));
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'Release Plan'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow name is 1 to 64 lowercase letters, digits or dashes, starting with a letter.'`, 'name refused');
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'change'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow named change already exists here. Edit it instead.'`, 'taken refused');
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'release'));
await b.evaluate(fill('[data-wfg-newform] input[name="title"]', 'Cut the release'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'Step 1 needs at least one role.'`, 'role refused');
await b.evaluate(press(role('[data-wfg-newform]', 'human')));
await create();
await wait(`location.hash === '#/orgs/acme/workflows/release?step=1' && ${boxes}.length === 1`, 'opens in the graph');
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved, not in the file yet');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-delete]')`), null, 'nothing to delete yet');
assert.equal(st.writes.length, 0);
// The list shows it, marked.
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-item="release"] [data-wfg-unsaved]')`, 'listed unsaved');
await b.evaluate(press('[data-wfg-item="release"] a[data-wfg-open]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-save]')`, 'back in the graph');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved release\\./.test(${text('[data-wfg-status]')}) && document.querySelector('[data-wfg-delete]')`, 'saved');
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'release', workflow: { steps: [{ title: 'Cut the release', state: 'todo', roles: ['human'] }] } });
// Delete asks first; Keep keeps it.
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-confirm]')`, 'confirm');
assert.match(await b.evaluate(text('[data-wfg-confirm]')), /Delete release from the organization file\?/);
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-delete-no');
await b.evaluate(press('[data-wfg-delete-no]'));
await wait(`!document.querySelector('[data-wfg-confirm]') && document.activeElement?.dataset.focusKey === 'wfg-delete'`, 'kept');
assert.equal(st.writes.length, 1);
const saved = st.revs.acme;
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`location.hash === '#/orgs/acme/workflows' && /Deleted release\\./.test(${text('[data-wfg-list] [data-wfg-status]')}) && !document.querySelector('[data-wfg-item="release"]')`, 'deleted');
assert.deepEqual(st.writes[1].args, { revision: saved, name: 'release', workflow: null });
assert.equal(Object.hasOwn(st.docs.acme.workflows, 'release'), false);
});
test('the keyboard alone opens a step, edits it and saves', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site' });
await wait(`${boxes}.length === 2`, 'graph');
await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`);
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Build' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1');
const tabTo = async (sel, what) => {
for (let i = 0; i < 20; i++) { if (await b.evaluate(`document.activeElement?.matches(${JSON.stringify(sel)}) ?? false`)) return; await b.key('Tab'); }
throw new Error(`Tab never reached ${what}`);
};
await tabTo('[data-wfg-edit]', 'Edit step');
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 1' && document.activeElement?.dataset.focusKey === 'wfg-f-title'`, 'form with focus on the title');
await b.call('Input.insertText', { text: ' now' });
await tabTo(`[data-wfg-form] input[name="role"][value="reviewer"]`, 'the reviewer box');
await b.key(' ', 'Space');
await tabTo('[data-wfg-apply]', 'Apply');
await b.key('Enter');
await wait(`${titles}[0] === 'Build now' && document.activeElement?.dataset.focusKey === 'wfg-save'`, 'applied, focus on Save');
await b.key('Enter');
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'change', project: 'site', workflow: { title: 'Site release', steps: [
{ title: 'Build now', state: 'in-progress', roles: ['coder', 'reviewer'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } });
});
test('switching organization with a pending workflow change asks first, through the existing draft guard', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-edit]') && document.querySelector('#breadcrumbs [data-crumb-org]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog');
await b.evaluate(press('dialog [data-stay]'));
await wait(`!document.querySelector('dialog[open]') && /#\\/orgs\\/acme\\/workflows\\/change/.test(location.hash) && ${boxes}.length === 1`, 'stayed with the change');
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog again');
await b.evaluate(press('dialog [data-discard]'));
await wait(`location.hash === '#/orgs/bare/workflows' && document.querySelector('[data-wfg-list][data-wfg-org="bare"]')`, 'switched');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), false);
assert.equal(st.writes.length, 0);
});
test('Save with a step form holding edits not applied refuses in words and sends nothing; after Apply it sends them', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
await wait(`document.querySelector('[data-wfg-up]')`, 'step 2');
// One change pending, so Save shows; then a step form is opened and typed in, not applied.
await b.evaluate(press('[data-wfg-up]'));
await wait(`${titles}[0] === 'Ship' && document.querySelector('[data-wfg-save]')`, 'moved');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Apply or cancel the step first/.test(${text('[data-wfg-status]')})`, 'save refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not saved: step 1 has edits not applied. Apply or cancel the step first.');
assert.equal(st.writes.length, 0);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release', 'the typed title stays');
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[0] === 'Ship the release'`, 'applied');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-status]')`), null, 'Apply clears the refusal');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
assert.equal(st.writes.length, 1);
assert.deepEqual(st.writes[0].args.workflow.steps.map(s => s.title), ['Ship the release', 'Build']);
});
test('Delete on a view another writer has changed is refused, not deleted over', { timeout: 60000 }, async t => {
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
const n = reads.length;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
assert.ok(reads.length > n);
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm');
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'delete refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. Discard to load the current version.');
assert.equal(st.writes.length, 1);
assert.equal(st.writes[0].args.revision, REV_A);
assert.equal(st.writes[0].args.workflow, null);
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed elsewhere.', 'the newer workflow is still in the file');
assert.ok(await b.evaluate(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]')`), 'the pending change stays');
// With no pending change there is no Discard, so the refusal does not point at one.
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${boxes}.length === 2 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed again.'; });
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'second delete refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. The page shows the current version at its next read.');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-discard]')`), null);
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed again.');
});
test('a failed read says a pending change is kept, and it comes back when the read works', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
st.failRead = true;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`!!document.querySelector('[data-wfg-error]') && !!document.querySelector('[data-wfg-held]')`, 'error with kept note');
assert.equal(await b.evaluate(text('[data-wfg-held]')), 'Unsaved change to change is kept on this page. It comes back when the workflows can be read.');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
st.failRead = false;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]') && !document.querySelector('[data-wfg-held]')`, 'back');
assert.equal(st.writes.length, 0);
});