// 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. // 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 = ''; 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.xe.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'); }); } }); // ---------- 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); });