Files
stack/packages/webui/tests/system-tasks-browser.test.mjs

331 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SP4 (#1873, single pane slice 4): one task board for the whole system at #/tasks. Every
// organization that answers adds its tasks to one board, in the per-organization page's columns,
// each card with its organization chip and its project chip. The organization and project filters
// live in the hash query. A move goes to the row's own organization, offered only where that
// organization's own config takes task changes, never judged by the organization opened last.
// A stopped organization is named at the top with its reason. #/orgs/<id>/tasks is the same board,
// scoped to one organization, and a card opened from the system board opens there.
// Every bus is a fake: acme and beta keep their tasks in the built-in store, gamma's bus is stopped.
// The real GET /api/workspace and the real POST /api/workspace/tasks serve the page.
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<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const D = n => String(n).repeat(64);
function fixture({ empty = false, archived = [], done = false } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const PROJECTS = {
acme: [{ id: 'site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
beta: [{ id: 'shop', repo: 'shop', name: 'Shop', archived: false, at: at(60) }, { id: 'ads', repo: 'ads', name: 'Ads', archived: false, at: at(90) }],
};
const STORE = {
acme: [{ n: 1, title: 'Hero copy', state: 'todo', group: 'site', assignees: ['coder'] }],
beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }],
};
if (empty) { STORE.acme = []; STORE.beta = []; }
// `done`: one task finished two days ago and one ten days ago; only the first is done this week.
if (done) { STORE.acme.push({ n: 4, title: 'Old logo', state: 'done', group: 'site', assignees: [], ago: 60 * 24 * 10 }); STORE.beta.push({ n: 5, title: 'Launch email', state: 'done', group: 'shop', assignees: [], ago: 60 * 24 * 2 }); }
for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`);
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(t.ago ?? 10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1',
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null, ...(t.state === 'done' ? { done: true } : {}) } }));
const writes = [];
const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; };
const opts = 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 member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: { coder: {} } },
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: () => opts(org.id),
});
return {
writes,
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: PROJECTS[id].map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
...opts(id),
bus: { ...opts(id).bus, instances: async () => ({ business: id, instances: {} }) },
taskWrite: taskWrite(id),
};
},
};
}
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL.
// Every organization's config says this Console takes task changes, except those named in `noWrite`.
// The real server sets orgSetup for the whole Console, so this per-organization difference is faked:
// it proves the board asks each row's own organization, which matters where one config read fails.
const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)};
(() => {
const real = window.fetch.bind(window);
window.fetch = async (url, init = {}) => {
const u = String(url);
if (!/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) return real(url, init);
const c = await (await real(url, init)).json();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return new Response(JSON.stringify({ ...c, orgSetup: c.orgSetup === true && !window.__noWrite.includes(org) }), { status: 200, headers: { 'content-type': 'application/json' } });
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [], done = false } = {}) {
const orgs = fixture({ empty, archived, done });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
await b.viewport(...viewport);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB(noWrite) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())';
const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3']));
const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3']));
test('#/tasks shows both organizations\' tasks on one board, each card with its organization and project chips', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
// A system route: the address keeps no organization and the sidebar Tasks is this page.
assert.equal(await b.evaluate('location.hash'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
assert.equal(await text('#bus-view h1'), 'Tasks');
// SP7: the page name shows once, as the heading.
assert.equal(await b.evaluate(trail), null);
// The per-organization page's columns.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']);
// One chip names the organization, with its avatar in place of the dot.
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="acme|task:1"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Acme Studio');
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="beta|task:2"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Beta Works');
assert.equal(await text('[data-tb-task="acme|task:1"] [data-ws-projchip]'), 'Website');
assert.equal(await text('[data-tb-task="beta|task:3"] [data-ws-projchip]'), 'Ads');
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
// The first load draws the board once, with no "last read failed" banner from a render the next one replaced.
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null);
});
// SP11 (#1873): the Dashboard's Done this week tile opens the board on the tasks it counted: done, and
// changed in the last seven days. The bus keeps no time a task was done, so the filter says it reads
// the last change, the time the tile counts by. The filter lives in the address and clears.
test('#/tasks?done=week shows only the tasks Done this week counted, says what it counts, and clears', async t => {
const { b, base, wait, text } = await setup(t, { hash: '#/tasks?done=week', done: true });
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
assert.match(await text('[data-tb-done]'), /^Done this week\s+×$/);
assert.match(await b.evaluate('document.querySelector("[data-tb-done]").getAttribute("aria-label")'), /^Clear Done this week: done tasks last changed in the past 7 days$/);
assert.match(await text('[data-tasks-board] .tb-count'), /^Showing 1 of 5 tasks\. Done this week counts done tasks by their last change; the bus records no time a task was done\.$/);
// It survives a reload and joins the organization filter.
await b.navigate(`${base}/#/tasks?org=acme&done=week`);
await wait('document.querySelector("[data-tb-done]") && document.querySelector("[data-tasks-board] .ws-panel")?.textContent.includes("No tasks match these filters")');
await b.evaluate('location.hash = "#/tasks?done=week"');
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
// Clearing it drops it from the address and shows every task again.
await b.evaluate('document.querySelector("[data-tb-done]").click()');
await wait(`location.hash === "#/tasks" && ${cards} === ${JSON.stringify(JSON.stringify(['acme|task:1', 'acme|task:4', 'beta|task:2', 'beta|task:3', 'beta|task:5']))}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-done]")'), null);
});
test('the organization and project filters live in the address and survive a reload', async t => {
const { b, base, wait } = await setup(t);
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-org=beta]").click()');
await wait(`location.hash === "#/tasks?org=beta" && ${cards} === ${BETA}`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "beta:shop"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`location.hash === "#/tasks?org=beta&project=beta%3Ashop" && ${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await b.navigate(`${base}/?reload=1#/tasks?org=beta&project=beta%3Ashop`);
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=beta]").getAttribute("aria-pressed")'), 'true');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
// A hand-edited value that is not an organization id or a project is ignored, not read as a filter.
await b.navigate(`${base}/?reload=2#/tasks?org=Not%20An%20Id&project=nope`);
await wait(`${cards} === ${ALL}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true');
// An organization or project that is not on the list falls back to all, and the address drops it.
await b.navigate(`${base}/?reload=3#/tasks?org=gone&project=beta%3Agone`);
await wait(`${cards} === ${ALL} && location.hash === "#/tasks"`);
});
test('a move on beta\'s row while acme was opened last reaches beta, and acme\'s own config keeps its rows read only', async t => {
const { b, wait, orgs } = await setup(t, { hash: '#/orgs/acme/organization', noWrite: ['acme'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
// Acme's config takes no task changes, so its row has no State control and its card does not drag.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=acme]")'), null);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/moved to Review in Beta Works/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [['beta', 'tasks.edit', { task_ref: 'task:2', expect: D(2), state: 'in-review' }]]);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('a stopped organization is named at the top of the board with its reason', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
const note = await text('[data-ws-unread]');
assert.match(note, /Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector(".tb-toolbar")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
});
test('with no tasks anywhere the board says so and still names the stopped organization', async t => {
const { b, wait, text } = await setup(t, { empty: true });
await wait('/No tasks yet/.test(document.querySelector("[data-tb-results]")?.textContent || "")');
assert.match(await text('[data-tb-results]'), /Gamma Home could not be read/);
assert.match(await text('[data-ws-unread]'), /Gamma Home/);
});
test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organization', async t => {
const { b, base, wait } = await setup(t, { hash: '#/orgs/beta/tasks' });
await wait(`${cards} === ${BETA}`);
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks');
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta');
// SP7: a drill-in keeps its trail.
await wait(`${trail} === 'Dashboard / Beta Works / Tasks'`);
// No filter is on, so no "match the filters" line: the scope is not a filter.
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /Tasks from Beta Works only/);
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .sub a")?.getAttribute("href")'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
// The sidebar Tasks link opens the system board.
await b.evaluate('document.querySelector("#sections a[href=\'#/tasks\']").click()');
await wait(`location.hash === "#/tasks" && ${cards} === ${ALL}`);
// An address that names no possible organization is not the system board.
await b.navigate(`${base}/?reload=1#/orgs/Not%20An%20Id/tasks`);
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
assert.equal(await b.evaluate(cards), '[]');
});
test('a card opened from the system board opens the task in its organization', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-open=\'beta|task:3\']").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-goto]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").getAttribute("href")'), '#/orgs/beta/tasks?task=task%3A3');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").textContent'), 'Open in Beta Works');
await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").click()');
await wait('location.hash.startsWith("#/orgs/beta/tasks") && window.MosaicOrg.id === "beta" && document.querySelector("[data-tasks-board]")?.dataset.tbScope === "beta"');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")?.dataset.tbKey === "beta|task:3"');
// The scoped page offers no second hop to the organization it is already in.
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]")'), null);
// Closing the task takes it off the address, so a reload does not open it again.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('location.hash === "#/orgs/beta/tasks" && !document.querySelector("#tb-dialog").open');
});
// Holds each task board read for ?hold= ms (or window.__hold, set later) and counts the reads that
// started and the ones that came back, so a test can act while a read is in flight. A task write is
// held for window.__holdW ms and counted the same way.
const HOLD = `window.__hold = Number(new URLSearchParams(location.search).get('hold')) || 0; window.__started = 0; window.__landed = 0;
window.__holdW = 0; window.__wStarted = 0; window.__wLanded = 0;
(() => {
const real = window.fetch.bind(window);
window.fetch = async (url, init = {}) => {
if (String(url) === '/api/workspace/tasks' && init.method === 'POST' && window.__holdW) {
window.__wStarted++;
await new Promise(r => setTimeout(r, window.__holdW));
try { return await real(url, init); } finally { window.__wLanded++; }
}
if (!/^\\/api\\/workspace\\?sections=tasks(?:%2C|,)projects/.test(String(url))) return real(url, init);
window.__started++;
if (window.__hold) await new Promise(r => setTimeout(r, window.__hold));
try { return await real(url, init); } finally { window.__landed++; }
};
})();`;
const settle = b => b.evaluate('new Promise(r => setTimeout(r, 200))');
test('a project filter stays in the address while its organization does not answer', async t => {
const { b, orgs, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop' });
const SHOP = JSON.stringify(JSON.stringify(['beta|task:2']));
await wait(`${cards} === ${SHOP}`);
const list = await orgs.list();
list[1].busHealthy = false;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
list[1].busHealthy = true;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait(`${cards} === ${SHOP} && !/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")`);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
});
test('a gone filter is cleaned from the tasks address only, never from a page opened while it was read', async t => {
const { b, base, wait } = await setup(t);
await wait(`${cards} === ${ALL}`);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?hold=800#/tasks?org=gone`);
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/inbox?org=acme"');
await wait('window.__landed >= 1');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/inbox?org=acme');
});
test('a read in flight does not paint the board over the page for an address no organization matches', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('window.__hold = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "todo"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('a move still writing when the not-on-your-list page opens does not paint the board over it', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
await b.evaluate('window.__holdW = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__wStarted >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__wLanded >= 1 && window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('an archived project keeps its filter while it has tasks, and while another filter matches none of them', async t => {
const { b, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop', archived: ['beta:shop'] });
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
// No shop task is done: the read comes back empty because of the state filter, not because shop is gone.
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`${cards} === "[]" && /No tasks match/.test(document.querySelector("[data-tb-results]")?.textContent || "")`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
});