Jason's 2026-11-12 ruling: the only deployed system is the dev environment on dragon-lin, so the system-type plug-in kind goes with no retired kind, fence, conversion or new refusal code. - plugins: system-type leaves KINDS; system-types.mjs and the five modules are deleted. An org file naming the kind refuses unknown-kind. - organization: the plugins.system-type check, systemTypesFence and the system-types.console right are removed. - bus: system-types.console, project.system-types[.set], the system-type half of the tester check, the runtime reads and system-type-not-enabled are removed. Step 3v-3w drops project_system_types, its index, its four guards and its rows; SCHEMA_VERSION is 3w (32 tables, 126 triggers, 5 views). - cli: mosaic org system-types and mosaic project system-types[-set] are removed; stack-release COUNTED and the budget supervisor's closed list move to 3w. - channel: the project.system-types verbs are removed. - webui: the System types page, route, dashboard row, chips and project selection are removed. - docs: REQ-SYS-1/2 now describe starter packs; README system-type text and the requirements titles follow.
210 lines
17 KiB
JavaScript
210 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: {} };
|
|
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);
|
|
});
|