The Tasks board takes the selected organization's board: its labels and column order over the five fixed states, and its default layout unless the viewer saved one. The all-organizations view keeps the fixed labels. The organization page gains the owner's starter pack picker (list, preview with marks, flagged lines, credential lines and noHire marks, apply by digest) and a workflow list and editor that marks a step role naming no current role. Every organization- or pack-supplied string goes through esc or textContent. The workspace read carries each organization's and project's board. GET /api/orgs/<id>/workflows is the owner's read of the workflows, the role names a step may resolve to and the file revision. packsServes pins HEAD and GET /api/board to the Board read, never the pack route.
184 lines
12 KiB
JavaScript
184 lines
12 KiB
JavaScript
// Starter packs SP6 (#1873): board styles on the task board, in a real browser. A board style
|
|
// relabels, reorders and sets the default layout over the five fixed states (Jason 11-12 Q3), and
|
|
// only while the view is narrowed to one organization or project; the all-orgs view keeps the fixed
|
|
// labels. Every bus is a fake in this file.
|
|
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
|
const D = n => String(n).repeat(64);
|
|
const FIXED = ['To do', 'In progress', 'Review', 'Blocked', 'Done'];
|
|
// forge's board: a support desk's words, done first, list by default.
|
|
const FORGE_BOARD = { columns: [
|
|
{ state: 'done', label: 'Solved' }, { state: 'todo', label: 'New ticket' }, { state: 'in-progress', label: 'With an agent' },
|
|
{ state: 'in-review', label: 'Awaiting customer' }, { state: 'blocked', label: 'On hold' },
|
|
], layout: 'list' };
|
|
// forge's billing project has a board of its own, which wins over the org's for that project.
|
|
const BILLING_BOARD = { columns: [
|
|
{ state: 'todo', label: 'Draft' }, { state: 'in-progress', label: 'Invoicing' }, { state: 'in-review', label: 'Checked' },
|
|
{ state: 'blocked', label: 'Disputed' }, { state: 'done', label: 'Paid' },
|
|
], layout: 'kanban' };
|
|
const XSS = '<img src=x onerror=window.pwn=1>';
|
|
|
|
function orgs({ forgeBoard = FORGE_BOARD, billingBoard = BILLING_BOARD } = {}) {
|
|
const ORGS = [
|
|
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
|
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
|
];
|
|
const DOCS = {
|
|
ddigital: { appearance: { theme: 'rosewood' }, roles: { editor: {} } },
|
|
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {} }, ...(forgeBoard ? { board: forgeBoard } : {}),
|
|
projects: { gateway: { root: 'code/gateway', repo: 'gateway' }, billing: { root: 'code/billing', repo: 'billing', ...(billingBoard ? { board: billingBoard } : {}) } } },
|
|
};
|
|
const PROJECTS = {
|
|
ddigital: [{ id: 'spring-launch', repo: 'spring', name: 'Spring launch', at: '2026-11-09T10:00:00.000Z' }],
|
|
forge: [{ id: 'api-gateway', repo: 'gateway', name: 'API gateway', at: '2026-11-09T10:00:00.000Z' }, { id: 'billing', repo: 'billing', name: 'Billing', at: '2026-11-08T10:00:00.000Z' }],
|
|
};
|
|
const STORE = {
|
|
forge: [
|
|
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway' },
|
|
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing' },
|
|
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway' },
|
|
],
|
|
ddigital: [{ n: 7, title: 'Launch email', state: 'in-review', group: 'spring-launch' }],
|
|
};
|
|
const tasks = id => STORE[id].map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: 0, digest: D(t.n % 10), requirement: 'REQ-API-1',
|
|
fields: { title: t.title, state: t.state, group: t.group, assignees: [], due_date: null } }));
|
|
const options = id => ({
|
|
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => tasks(id), messages: async () => ({ messages: [], unread: 0 }) },
|
|
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
|
|
});
|
|
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
|
return {
|
|
list: async () => ORGS,
|
|
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: healthy, options: () => options(o.id) })),
|
|
resolve: async id => {
|
|
if (!ORGS.some(o => o.id === id)) return null;
|
|
return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: async (verb, args) => ({ task_ref: args.task_ref, digest: D(9) }) };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function open(t, fixture = orgs()) {
|
|
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture, send: async () => ({}), log: () => {} });
|
|
t.after(() => close(server));
|
|
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
|
|
await b.viewport(1440, 900);
|
|
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
|
|
await b.navigate(url);
|
|
const wait = waitFor(b);
|
|
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');
|
|
return { b, wait, url };
|
|
}
|
|
const heads = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] .tb-col h3")].map(h => h.textContent)');
|
|
const layout = b => b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout');
|
|
const pick = (b, wait, org) => b.evaluate(`document.querySelector("[data-tb-org='${org}']").click()`)
|
|
.then(() => wait(`document.querySelector("[data-tb-org='${org}']").getAttribute("aria-pressed") === "true"`));
|
|
const project = async (b, wait, value, count) => {
|
|
await b.evaluate(`(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = ${JSON.stringify(value)}; s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
|
|
await wait(`document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === ${count}`);
|
|
};
|
|
|
|
// The red-before test: on BASE the view filtered to forge showed the fixed labels.
|
|
test('a view filtered to an organization with a board shows its labels', async t => {
|
|
const { b, wait } = await open(t, orgs({ forgeBoard: { ...FORGE_BOARD, layout: 'kanban' } }));
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
await pick(b, wait, 'forge');
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
});
|
|
|
|
test('a filtered view uses the board\'s labels and order in the list, the filters and the dialog', async t => {
|
|
const { b, wait } = await open(t);
|
|
await pick(b, wait, 'forge');
|
|
// forge's board says list; no viewer choice is saved, so the board's layout is the default.
|
|
assert.equal(await layout(b), 'list');
|
|
// The list orders rows by the board's columns (Solved first) and labels each state cell with its label.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-list] tbody tr")].map(r => r.dataset.tbTask)'), ['forge|task:43', 'forge|task:41', 'forge|task:42']);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-list] select.tb-move")].map(s => s.selectedOptions[0].textContent)'), ['Solved', 'New ticket', 'With an agent']);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-list] select.tb-move").options].map(o => [o.value, o.textContent])'),
|
|
[['done', 'Solved'], ['todo', 'New ticket'], ['in-progress', 'With an agent'], ['in-review', 'Awaiting customer'], ['blocked', 'On hold']]);
|
|
// The state filter speaks the board's words too; its values stay the five fixed states.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => [o.value, o.textContent])'),
|
|
[['', 'Any'], ['done', 'Solved'], ['todo', 'New ticket'], ['in-progress', 'With an agent'], ['in-review', 'Awaiting customer'], ['blocked', 'On hold']]);
|
|
// In Kanban the columns are the board's labels, in the board's order.
|
|
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-kanban] [data-tb-col]")].map(c => c.dataset.tbCol)'), ['done', 'todo', 'in-progress', 'in-review', 'blocked']);
|
|
// The edit dialog's state picker uses the same labels.
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:41\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] select[name=state]")');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelector("#tb-dialog select[name=state]").options].map(o => o.textContent)'), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog select[name=state]").value'), 'todo');
|
|
});
|
|
|
|
test('a project filter uses the project\'s board, and the org\'s when the project has none', async t => {
|
|
const { b, wait } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
|
await project(b, wait, 'forge:billing', 1);
|
|
assert.deepEqual(await heads(b), ['Draft', 'Invoicing', 'Checked', 'Disputed', 'Paid']);
|
|
await project(b, wait, 'forge:api-gateway', 2);
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
// ddigital holds no board: its project keeps the fixed labels.
|
|
await project(b, wait, 'ddigital:spring-launch', 1);
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
});
|
|
|
|
test('the all-orgs view keeps today\'s labels, and so does an org without a board', async t => {
|
|
const { b, wait } = await open(t);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => o.textContent)'), ['Any', ...FIXED]);
|
|
await pick(b, wait, 'ddigital');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
// Back to every organization after a narrowed view: the fixed labels return.
|
|
await pick(b, wait, 'forge');
|
|
await pick(b, wait, '');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
});
|
|
|
|
test('a saved viewer layout wins over the board\'s default', async t => {
|
|
const { b, wait, url } = await open(t);
|
|
await pick(b, wait, 'forge');
|
|
assert.equal(await layout(b), 'list');
|
|
// The viewer picks Kanban, which is saved for this width; after a reload it still wins over forge's list.
|
|
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
|
assert.match(await b.evaluate('localStorage.getItem("mosaic-tasks-layout-v1")'), /"wide":"kanban"/);
|
|
await b.navigate(url.replace('/#', '/?reload=1#'));
|
|
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');
|
|
await pick(b, wait, 'forge');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
});
|
|
|
|
test('a board label holding markup renders as text', async t => {
|
|
const board = { columns: [{ state: 'todo', label: XSS }, ...FORGE_BOARD.columns.filter(c => c.state !== 'todo')], layout: 'kanban' };
|
|
const { b, wait } = await open(t, orgs({ forgeBoard: board }));
|
|
await pick(b, wait, 'forge');
|
|
assert.deepEqual((await heads(b)).slice(0, 2), [XSS, 'Solved']);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-col=todo]").getAttribute("aria-label")'), XSS);
|
|
assert.ok((await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => o.textContent)')).includes(XSS));
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:41\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] select[name=state]")');
|
|
assert.ok((await b.evaluate('[...document.querySelector("#tb-dialog select[name=state]").options].map(o => o.textContent)')).includes(XSS));
|
|
assert.equal(await b.evaluate('document.querySelectorAll("img[src=x]").length'), 0);
|
|
await new Promise(r => setTimeout(r, 200));
|
|
assert.equal(await b.evaluate('window.pwn === undefined'), true);
|
|
});
|
|
|
|
test('a board that is not exactly the five states is ignored, never half applied', async t => {
|
|
const broken = { columns: FORGE_BOARD.columns.slice(0, 4), layout: 'list' };
|
|
const { b, wait } = await open(t, orgs({ forgeBoard: broken, billingBoard: null }));
|
|
await pick(b, wait, 'forge');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
});
|