Files
stack/packages/webui/tests/packs-workflows-browser.test.mjs
jason.woltje 991eb8c332 fix(webui): SP6 review round, the writer's workflow name rule, no silent replace, preview redraw (#1873)
The workflow editor checks the writer's PLAN_NAME (lowercase letters, digits
and dashes, starting with a letter), so a name the bus would refuse no longer
reaches it with step guidance. A new workflow under a name its scope already
holds is refused on the page instead of replacing that workflow. A pack
preview the page cannot draw says so rather than leaving Preview disabled.
The browser test launches Chromium with --disable-extensions.
2026-11-12 18:40:34 -06:00

211 lines
17 KiB
JavaScript

// 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 = '<img src=x onerror=window.__sp6pwn=1>';
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: {} };
if (verb === 'system-types.console') return { installed: [], enabled: [], project: null };
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' } };
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. Proposed hires are recorded for the chief to raise as decisions.');
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, <b>x</b>';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`), '<b>x</b> 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);
});