// Starter packs SP6 (#1873): the owner's pack picker and workflow editor on an organization's page, // driven in Chromium against stand-in pack verbs and a stand-in workflow read. It checks what the page // shows from what the bus returns: the preview's marks, flagged lines, credential lines and noHire mark; // a step role that no longer resolves; an organization-supplied title or label rendered as text; and // the save and delete requests the editor sends. 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 waitFor = (b, expression, what) => 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,2000))})()`); const REV = n => String(n).repeat(64); const PACKS = [ { id: 'software-team', origin: 'shipped', pack: { label: `Software team ${XSS}`, description: 'Roles, a board and a review workflow.', version: '1.0.0', scope: 'organization' } }, { id: 'web-project', origin: 'installed', pack: { label: 'Web project', description: 'A project board.', version: '0.2.0', scope: 'project' } }, ]; const PREVIEW = { pack: { id: 'software-team', version: '1.0.0', label: `Software team ${XSS}`, origin: 'shipped', scope: 'organization' }, project: null, revision: REV('c'), items: [ { item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }, { item: 'definition', name: 'reviewer', target: 'reviewer', path: 'definitions.reviewer', mark: 'kept', diff: { primitives: ['tools', 'instructions'] } }, { item: 'workflow', name: 'review', target: 'review', path: 'workflows.review', mark: 'same' }, { item: 'workflow', name: 'ship', target: 'ship', path: 'workflows.ship', mark: 'removed' }, ], hires: [ { name: 'lead-reviewer', definition: 'org:reviewer', mark: 'propose', pin: null, flagged: [{ field: 'authority.withinRole', value: 'decision.resolve.technical', reasons: ['above-chief', 'resolve-right'] }], credentialLines: [{ service: 'gitea', scope: 'repo:write' }, { service: 'matrix' }], noHire: true }, { name: 'builder', definition: 'coder', mark: 'dropped', pin: null, flagged: null, credentialLines: [], noHire: false }, ], skills: { notEnabled: ['lint-fix'] }, chief: true, plan: {}, digest: REV('d'), }; async function fixture(t, { send = true } = {}) { const verbs = []; let flows = { revision: REV('a'), document: { workflows: { intake: { title: `Intake ${XSS}`, description: `Sort ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['triage', 'human'] }, { title: 'Hand off', state: 'in-progress', roles: ['ghost'] }] }, }, projects: { site: { workflows: { ship: { steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }] } } } }, definitions: { triage: {} }, }, templates: ['coder', 'reviewer'], }; const read = async (verb, args) => { verbs.push([verb, args]); if (verb === 'packs.console') return { packs: PACKS }; if (verb === 'pack.preview.console') return { ...PREVIEW, project: args.project ?? null }; if (verb === 'autonomy.get.console') return { revision: REV('e'), autonomy: {}, levels: {} }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); }; const write = async (verb, args) => { verbs.push([verb, args]); if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board', 'definitions.reviewer'], pack: { id: args.pack, version: '1.0.0' }, hires: [{ name: 'lead-reviewer', state: 'refused', code: 'definition-not-hireable' }] }; if (verb === 'workflow.set.console') { assert.equal(args.revision, flows.revision); const doc = structuredClone(flows.document); const scope = args.project ? doc.projects[args.project] : doc; scope.workflows ??= {}; if (args.workflow === null) delete scope.workflows[args.name]; else scope.workflows[args.name] = args.workflow; flows = { ...flows, revision: REV(flows.revision[0] === 'a' ? 'b' : '9'), document: doc }; return { revision: flows.revision, added: [] }; } throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); }; const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => ({ busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: send ? write : null, workflowsRead: async () => structuredClone(flows), send: send ? async () => ({}) : null, reads: { business: async () => ({ business: { id: 'acme', 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 () => [] }, }), }, }); t.after(() => close(server)); const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close()); await b.viewport(1440, 900); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`); return { b, verbs, flows: () => flows }; } test('SP6 pack picker: list, preview with marks, flagged, credential and noHire lines, apply by digest; pack text stays text', { timeout: 60000 }, async t => { const { b, verbs } = await fixture(t); await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list'); assert.equal(await b.evaluate(`document.querySelector('[data-pack="software-team"] strong').textContent`), `Software team ${XSS}`); // The project pack offers the code projects the workflow read lists. assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack="web-project"] [data-pack-project] option')].map(o=>o.value)`), ['site']); await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`); await waitFor(b, `document.querySelector('[data-pack-preview]')`, 'preview'); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-item]')].map(r=>[r.dataset.packItem,r.querySelector('[data-pack-mark]').dataset.packMark,r.querySelector('[data-pack-mark]').textContent])`), [ ['board', 'add', 'will be added'], ['definitions.reviewer', 'kept', 'kept: the organization has its own version differs in tools, instructions'], ['workflows.review', 'same', 'already the same'], ['workflows.ship', 'removed', 'stays removed: you removed it after an earlier apply'], ]); const lead = '[data-pack-hire="lead-reviewer"]'; assert.match(await b.evaluate(`document.querySelector('${lead} [data-pack-flagged]').textContent`), /authority\.withinRole decision\.resolve\.technical above the chief, a resolve right/); // The bus sends each credential line as {service, scope} (hire-decisions credentialLines); scope is absent // when the definition names a service without scopes. assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${lead} [data-pack-credentials] li')].map(l=>l.textContent)`), ['gitea repo:write', 'matrix']); assert.match(await b.evaluate(`document.querySelector('${lead} [data-pack-nohire]').textContent`), /no-hire list/); assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire="builder"] [data-pack-nohire]')`), null); assert.ok(await b.evaluate(`document.querySelector('[data-pack-hire="builder"] [data-pack-noflag]')!==null`)); assert.match(await b.evaluate(`document.querySelector('[data-pack-hire="builder"]').textContent`), /Not proposed/); assert.match(await b.evaluate(`document.querySelector('[data-pack-skills]').textContent`), /lint-fix/); assert.equal(await b.evaluate(`document.querySelectorAll('[data-packs] img').length`), 0, 'a pack label never becomes markup'); assert.equal(await b.evaluate(`window.__sp6pwn===undefined`), true); assert.equal(verbs.filter(([v]) => v.startsWith('pack.apply')).length, 0, 'preview writes nothing'); await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`); await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]').textContent)`, 'apply status'); assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board, definitions.reviewer. Not hired: lead-reviewer, its role is on the no-hire list.'); assert.deepEqual(verbs.filter(([v]) => v === 'pack.apply.console').map(([, a]) => a), [{ pack: 'software-team', digest: REV('d') }]); assert.equal(await b.evaluate(`document.querySelector('[data-pack-preview]')`), null); // A project pack previews against the chosen project. await b.evaluate(`document.querySelector('[data-pack="web-project"] [data-pack-preview-button]').click();true`); await waitFor(b, `/for project site/.test(document.querySelector('[data-pack-preview] h4')?.textContent??'')`, 'project preview'); assert.deepEqual(verbs.filter(([v]) => v === 'pack.preview.console').at(-1)[1], { pack: 'web-project', project: 'site' }); }); test('SP6 pack picker without send previews but offers no apply', { timeout: 60000 }, async t => { const { b } = await fixture(t, { send: false }); await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list'); await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`); await waitFor(b, `document.querySelector('[data-pack-preview]')`, 'preview'); assert.equal(await b.evaluate(`document.querySelector('[data-pack-apply]')`), null); assert.match(await b.evaluate(`document.querySelector('[data-pack-preview]').textContent`), /started without send/); assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit],[data-wf-new]')`), null, 'no workflow change controls without send'); }); test('SP6 workflows: list by scope, a step naming no current role, titles as text, edit, new and delete', { timeout: 60000 }, async t => { const { b, verbs, flows } = await fixture(t); await waitFor(b, `document.querySelector('[data-wf="intake"]')`, 'workflow list'); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wf-scope-list]')].map(n=>n.dataset.wfScopeList)`), ['', 'site']); assert.equal(await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-title]').textContent`), `Intake ${XSS}`); assert.equal(await b.evaluate(`document.querySelectorAll('[data-workflows] img').length`), 0, 'a workflow title never becomes markup'); assert.equal(await b.evaluate(`window.__sp6pwn===undefined`), true); // triage (an org definition), human and coder (a template) resolve; ghost names no current role. assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wf="intake"] [data-wf-step]')].map(s=>s.querySelectorAll('[data-wf-missing]').length)`), [0, 1]); assert.match(await b.evaluate(`document.querySelectorAll('[data-wf="intake"] [data-wf-step]')[1].textContent`), /ghost names no current role/); assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf="ship"] [data-wf-missing]').length`), 0); // Edit: the ghost step shows the mark in the editor; fixing the role clears it; save sends the revision. await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`); await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor'); assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit-step="1"] [data-wf-step-missing]').textContent`), 'ghost names no current role'); assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), `Intake ${XSS}`); await b.evaluate(`{const r=document.querySelector('[data-wf-edit-step="1"] [name=roles]');r.value='reviewer, x';r.dispatchEvent(new Event('input',{bubbles:true}))}true`); assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit-step="1"] [data-wf-step-missing]').textContent`), 'x names no current role'); assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf-edit-step="1"] b').length`), 0); await b.evaluate(`{const r=document.querySelector('[data-wf-edit-step="1"] [name=roles]');r.value='reviewer';r.dispatchEvent(new Event('input',{bubbles:true}))}true`); assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit-step="1"] [data-wf-step-missing]').textContent`), ''); await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value='Intake';document.querySelector('[data-wf-editor]').requestSubmit();true`); await waitFor(b, `document.querySelector('[data-workflows-status]').textContent==='Saved intake.'`, 'save status'); const saved = verbs.filter(([v]) => v === 'workflow.set.console').at(-1)[1]; assert.deepEqual(saved, { revision: REV('a'), name: 'intake', workflow: { title: 'Intake', description: `Sort ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['triage', 'human'] }, { title: 'Hand off', state: 'in-progress', roles: ['reviewer'] }] } }); assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf="intake"] [data-wf-missing]').length`), 0, 'the re-read shows the fixed step'); // New, in the project scope: a bad name is refused on the page and sends nothing. const sets = () => verbs.filter(([v]) => v === 'workflow.set.console').length; const before = sets(); await b.evaluate(`document.querySelector('[data-wf-new]').click();true`); await waitFor(b, `document.querySelector('[data-wf-editor] select[name=scope]')`, 'new editor'); await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');f.elements.scope.value='site';f.elements.name.value='Bad Name';f.querySelector('[data-wf-edit-step="0"] [name=title]').value='Plan';f.querySelector('[data-wf-edit-step="0"] [name=roles]').value='human';f.requestSubmit()}true`); await waitFor(b, `/lowercase/.test(document.querySelector('[data-workflows-status]').textContent)`, 'name refusal'); assert.equal(sets(), before); // The writer's name rule (PLAN_NAME): no underscore, dot or leading digit. Each is refused here, not at the bus. for (const bad of ['release_1', 'v2.ship', '2nd-review']) { await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');f.elements.name.value=${JSON.stringify(bad)};f.requestSubmit()}true`); assert.equal(await b.evaluate(`document.querySelector('[data-workflows-status]').textContent`), 'A workflow name is 1 to 64 lowercase letters, digits or dashes, starting with a letter.', bad); assert.equal(sets(), before, `${bad} sends nothing`); } assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=name]').placeholder`), 'lowercase letters, digits, dashes'); // A new workflow under a name the scope already holds would replace it: refused, nothing sent. await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');f.elements.name.value='ship';f.requestSubmit()}true`); assert.equal(await b.evaluate(`document.querySelector('[data-workflows-status]').textContent`), 'A workflow named ship already exists here. Edit it instead.'); assert.equal(sets(), before, 'an existing name sends nothing'); assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] select[name=scope]').value`), 'site', 'the editor keeps the chosen scope'); await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');f.elements.name.value='plan';f.requestSubmit()}true`); await waitFor(b, `document.querySelector('[data-workflows-status]').textContent==='Saved plan.'`, 'new save'); assert.deepEqual(verbs.filter(([v]) => v === 'workflow.set.console').at(-1)[1], { revision: REV('b'), name: 'plan', workflow: { steps: [{ title: 'Plan', state: 'todo', roles: ['human'] }] }, project: 'site' }); assert.ok(await b.evaluate(`document.querySelector('[data-wf-scope-list="site"] [data-wf="plan"]')!==null`)); // Delete asks first; Keep sends nothing; Delete sends the project and the revision. const del = `[data-wf-scope-list="site"] [data-wf="ship"]`; await b.evaluate(`document.querySelector('${del} [data-wf-delete]').click();true`); await waitFor(b, `document.querySelector('${del} [data-wf-delete-no]')`, 'delete confirm'); await b.evaluate(`document.querySelector('${del} [data-wf-delete-no]').click();true`); await waitFor(b, `document.querySelector('${del} [data-wf-delete]')`, 'kept'); const n = sets(); await b.evaluate(`document.querySelector('${del} [data-wf-delete]').click();true`); await waitFor(b, `document.querySelector('${del} [data-wf-delete-yes]')`, 'delete confirm again'); assert.equal(sets(), n); const rev = flows().revision; await b.evaluate(`document.querySelector('${del} [data-wf-delete-yes]').click();true`); await waitFor(b, `document.querySelector('[data-workflows-status]').textContent==='Deleted ship.'`, 'delete status'); assert.deepEqual(verbs.filter(([v]) => v === 'workflow.set.console').at(-1)[1], { revision: rev, name: 'ship', workflow: null, project: 'site' }); assert.equal(await b.evaluate(`document.querySelector('${del}')`), null); assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true); });