483 lines
37 KiB
JavaScript
483 lines
37 KiB
JavaScript
// U4b (#1873, PRD 1.20): the task board across organizations (sample 06) in a real browser.
|
|
// Three orgs: forge keeps its tasks in the built-in store, ddigital in Vikunja, home's bus is down.
|
|
// Every bus is a fake in this file; each org's taskWrite records what reached that org only.
|
|
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);
|
|
|
|
function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
|
|
const ORGS = [
|
|
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
|
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
|
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
|
];
|
|
const DOCS = {
|
|
ddigital: { appearance: { theme: 'rosewood' }, tracker: { kind: 'vikunja' }, roles: { copywriter: { tracker: { botId: 7 } }, editor: { tracker: { botId: 8 } } } },
|
|
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {}, reviewer: {} } },
|
|
home: { appearance: { theme: 'grove' } },
|
|
};
|
|
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 = [
|
|
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' },
|
|
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null },
|
|
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null },
|
|
].map(t => ({ ...t, rev: 0 }));
|
|
// A task's digest changes with every write to it, as the store's does.
|
|
const digest = t => D((t.n + t.rev) % 10);
|
|
const TASKS = {
|
|
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: t.readOnly ? undefined : digest(t), requirement: 'REQ-API-1',
|
|
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
|
|
ddigital: () => [
|
|
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
|
|
{ task_ref: 'vikunja:3/118', project_key: 'spring', priority: 0, at: '2026-11-08T11:00:00.000Z', fields: { title: 'Teaser reel', done: true, assignees: [8], bucket: 14, labels: [] } },
|
|
],
|
|
};
|
|
const writes = [];
|
|
const taskWrite = org => async (verb, args) => {
|
|
writes.push([org, verb, args]);
|
|
if (refuse) throw Object.assign(new Error('refused'), { code: refuse });
|
|
const t = store.find(s => `task:${s.n}` === args.task_ref);
|
|
if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' });
|
|
if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; }
|
|
return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) };
|
|
};
|
|
const options = id => ({
|
|
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) },
|
|
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
|
|
});
|
|
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
|
const down = async () => ({ healthy: false, pid: null, boot: null });
|
|
return {
|
|
writes, store,
|
|
list: async () => ORGS,
|
|
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })),
|
|
resolve: async id => {
|
|
const org = ORGS.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, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: taskWrite(id) };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) {
|
|
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} });
|
|
t.after(() => close(server));
|
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
|
await b.viewport(width, height);
|
|
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=home]")');
|
|
return { b, wait, orgs, url };
|
|
}
|
|
const cards = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)');
|
|
|
|
test('the board merges every organization\'s tasks, tags each with its org, and a down org keeps its state', async t => {
|
|
const { b } = await open(t);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'kanban');
|
|
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118', 'forge|task:41', 'forge|task:42', 'forge|task:43']);
|
|
// Each card carries its org's mark and the id in its tracker's form.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-card")].map(c => [c.dataset.org, c.querySelector(".id").textContent, !!c.querySelector(".ws-av"), c.querySelector(".tb-trk").textContent]).sort()'),
|
|
[['ddigital', 'V-112', true, 'Vikunja'], ['ddigital', 'V-118', true, 'Vikunja'], ['forge', '#41', true, 'built-in'], ['forge', '#42', true, 'built-in'], ['forge', '#43', true, 'built-in']]);
|
|
// Columns: the store's states, plus one for the Vikunja task whose bucket the bus does not name.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done', 'State not read']);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-col=todo] [data-tb-task=\'forge|task:41\'] .bot").children].map(e => e.textContent.trim())'), ['API gateway', 'coder', 'P4', 'due 20 Nov']);
|
|
// Only built-in store cards can be dragged; a Vikunja card is read only.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'forge|task:41\']").draggable'), true);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'ddigital|vikunja:3/112\']").draggable'), false);
|
|
// home's bus is down: it shows its state and reason, and the other orgs still render.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-tracker]")].map(e => [e.dataset.tbTracker, e.dataset.state])'), [['ddigital', 'ok'], ['forge', 'ok'], ['home', 'down']]);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=home]").textContent'), /Home.*bus down.*the bus is not running/s);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tb-org=home]").textContent'), /Home\s*unavailable/);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /5 tasks from 2 of 3 organizations.*Vikunja tasks are read only here/);
|
|
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /not built yet/);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), false);
|
|
});
|
|
|
|
test('org and project filters narrow the board', async t => {
|
|
const { b, wait } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-org=forge]").click()');
|
|
await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"');
|
|
assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']);
|
|
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.');
|
|
// The project list follows the org filter.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']);
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
|
assert.deepEqual(await cards(b), ['forge|task:42']);
|
|
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.');
|
|
// Back to every org; the project filter stays on forge's project.
|
|
await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()');
|
|
await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"');
|
|
assert.deepEqual(await cards(b), ['forge|task:42']);
|
|
// A project of another org, then the assignee filter on a Vikunja role.
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "ddigital:spring-launch"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 2');
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=assignee]"); s.value = "copywriter"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
|
assert.deepEqual(await cards(b), ['ddigital|vikunja:3/112']);
|
|
});
|
|
|
|
test('the layout toggle is remembered across a reload', async t => {
|
|
const { b, wait, url } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
|
|
// Load the page again through navigate(), which waits for the load. A location.reload() from evaluate
|
|
// returns before the reload commits, so the next wait ran on the old page and the reload killed it
|
|
// (as instances-browser found). The same URL with only a hash would not load, so add a query.
|
|
await b.navigate(url.replace('/#', '/?reload=1#'));
|
|
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
|
|
});
|
|
|
|
test('a phone opens on List with no sideways scroll, and Kanban is one tap away', async t => {
|
|
const { b, wait } = await open(t, { width: 390, height: 844 });
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
|
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
|
// One column on a phone.
|
|
assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".tb-board")).gridTemplateColumns.split(" ").length'), 1);
|
|
});
|
|
|
|
test('a move writes only to its own org, and a polled repaint keeps focus', async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
|
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
|
// Vikunja rows have no State control; a store row does.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=ddigital]")'), null);
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.focus(); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('/moved to Review in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
|
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:41\']").value'), 'in-review');
|
|
// The search keeps focus and its caret through the poll's repaint.
|
|
await b.evaluate('(() => { const q = document.querySelector("[data-tb-q]"); q.focus(); q.value = "inv"; q.dispatchEvent(new Event("input", { bubbles: true })); q.setSelectionRange(1, 1); })()');
|
|
await wait('document.querySelectorAll("[data-tb-list] tr.tb-row").length === 1');
|
|
// bus.js repaints the view on its own 10 s poll; wait for that repaint to replace the board.
|
|
assert.equal(await b.evaluate('(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()'), true);
|
|
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement?.selectionStart, document.querySelector("[data-tb-q]").value]'), ['tb-q', 1, 'inv']);
|
|
});
|
|
|
|
test('a write the bus refuses for want of a person is shown, and nothing else is written', async t => {
|
|
const { b, wait, orgs } = await open(t, { refuse: 'human-required' });
|
|
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
|
await wait('document.querySelector("[data-tb-move=\'forge|task:42\']")');
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:42\']"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('document.querySelector("[data-tb-notice][role=alert]")');
|
|
assert.match(await b.evaluate('document.querySelector("[data-tb-notice]").textContent'), /#42 was not moved\. The bus takes task changes only from a person at the Console\. Nothing was written\./);
|
|
assert.deepEqual(orgs.writes.map(w => w[0]), ['forge']);
|
|
// The board still shows the task where the bus has it.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:42\']").value'), 'in-progress');
|
|
// The edit dialog shows the same refusal.
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
|
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
|
await wait('/only from a person/.test(document.querySelector("#tb-dialog [data-tb-form-status]").textContent)');
|
|
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]);
|
|
});
|
|
|
|
test('a Console without --send shows a read-only board and the write route refuses', async t => {
|
|
const { b, orgs } = await open(t, { send: false });
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
|
|
assert.equal(await b.evaluate('document.querySelectorAll(".tb-card[draggable=true]").length'), 0);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /board is read only/);
|
|
const status = await b.evaluate('fetch("/api/workspace/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "edit", org: "forge", task_ref: "task:41", state: "done" }) }).then(r => r.status)');
|
|
assert.equal(status, 403);
|
|
assert.deepEqual(orgs.writes, []);
|
|
});
|
|
|
|
// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board.
|
|
const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()';
|
|
|
|
test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
|
// Another writer renames the task and assigns it; a poll then reads the new version.
|
|
const t42 = orgs.store.find(s => s.n === 42);
|
|
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
|
|
assert.equal(await b.evaluate(POLLED), true);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
|
|
// Save sends the digest and the changes against the task as the dialog showed it.
|
|
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
|
await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)');
|
|
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
|
|
assert.equal(t42.title, 'Invoice export (renamed elsewhere)');
|
|
// The dialog reopens on the current task, so the next save is checked against what it now shows.
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
|
|
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
|
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
|
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
|
|
});
|
|
|
|
// Polish 1 (#1873): after a conflict the dialog reads the current task before it reopens. This holds
|
|
// that read: the first workspace read after the write's answer waits until window.__release().
|
|
const HOLD_CONFLICT_READ = `(() => {
|
|
const real = window.fetch;
|
|
window.__armed = false; window.__held = false;
|
|
window.fetch = (u, o) => {
|
|
const url = String(u);
|
|
if (url === '/api/workspace/tasks') return real(u, o).then(r => { window.__armed = true; return r; });
|
|
if (url.startsWith('/api/workspace?') && window.__armed && !window.__held) {
|
|
window.__held = true;
|
|
return new Promise(go => { window.__release = () => real(u, o).then(go); });
|
|
}
|
|
return real(u, o);
|
|
};
|
|
})()`;
|
|
const SAVE_AS = title => `(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = ${JSON.stringify(title)}; f.requestSubmit(); })()`;
|
|
const STATUS = 'document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent';
|
|
|
|
test('an Archive conflict reopens on the current task and never sends the old digest again', async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
const t42 = orgs.store.find(s => s.n === 42);
|
|
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
|
|
await b.evaluate(HOLD_CONFLICT_READ);
|
|
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()');
|
|
await wait('window.__held');
|
|
assert.equal(await b.evaluate(STATUS), 'Reading the current task…');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").disabled'), true);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true);
|
|
await b.evaluate(SAVE_AS('A stale Save while Archive refreshes'));
|
|
// Even a synthetic click past the disabled control cannot send another stale write.
|
|
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").dispatchEvent(new MouseEvent("click", { bubbles: true }))');
|
|
assert.deepEqual(orgs.writes, [['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }]]);
|
|
await b.evaluate('window.__release()');
|
|
await wait('/changed after the board read it/.test(' + STATUS + ')');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").textContent'), 'Archive');
|
|
// The refreshed task must be confirmed anew; its archive uses only the current version.
|
|
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()');
|
|
assert.equal(orgs.writes.length, 1);
|
|
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()');
|
|
await wait('/#42 archived in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
|
assert.deepEqual(orgs.writes, [
|
|
['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }],
|
|
['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(3) }],
|
|
]);
|
|
});
|
|
|
|
for (const operation of ['Save', 'Archive']) {
|
|
test(`${operation}: a conflict onto a read-only task explains the refusal only until the dialog closes`, async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
const t42 = orgs.store.find(s => s.n === 42);
|
|
Object.assign(t42, { title: 'Invoice export (now read only)', readOnly: true, rev: t42.rev + 1 });
|
|
await b.evaluate(operation === 'Save' ? SAVE_AS('Invoice export, CSV')
|
|
: 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form")'), null);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog h2").textContent'), 'Invoice export (now read only)');
|
|
const status = await b.evaluate(`(() => { const s = document.querySelector('#tb-dialog [data-tb-form-status]');
|
|
return s && { role: s.getAttribute('role'), text: s.textContent, styled: s.classList.contains('tb-form-status'), visible: s.getClientRects().length > 0 }; })()`);
|
|
assert.ok(status, 'a refused write needs a status in the read-only view');
|
|
assert.equal(status.role, 'status');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), 'tb-dialog-status');
|
|
assert.equal(await b.evaluate('document.getElementById("tb-dialog-status").textContent'), status.text);
|
|
assert.equal(status.styled, true); assert.equal(status.visible, true);
|
|
assert.equal(status.text, 'The task changed after the board read it. Nothing was written; the board now shows the current task.');
|
|
assert.equal(orgs.writes.length, 1);
|
|
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
|
|
await wait('!document.querySelector("#tb-dialog").open && !document.querySelector("#tb-dialog").innerHTML');
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]")'), null, 'the next ordinary open has no refusal line');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), null);
|
|
});
|
|
}
|
|
|
|
test('after a conflict the dialog shows the refusal only with the current task, and takes no save while it reads it', async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
|
const t42 = orgs.store.find(s => s.n === 42);
|
|
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
|
|
await b.evaluate(HOLD_CONFLICT_READ);
|
|
await b.evaluate(SAVE_AS('Invoice export, CSV'));
|
|
await wait('window.__held');
|
|
// The write was refused and the current task is still being read: the form still holds the old task,
|
|
// so it does not show the refusal and its Save is off.
|
|
assert.equal(await b.evaluate(STATUS), 'Reading the current task…');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export, CSV');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true);
|
|
// A save in this window, even one sent past the disabled button, goes nowhere.
|
|
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
|
|
await b.evaluate('new Promise(r => setTimeout(r, 200))');
|
|
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
|
|
// Once the read answers, the refusal and the current task appear together.
|
|
await b.evaluate('window.__release()');
|
|
await wait('/changed after the board read it/.test(' + STATUS + ')');
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), false);
|
|
await b.evaluate(SAVE_AS('Invoice export, CSV'));
|
|
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
|
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
|
assert.equal(orgs.writes.length, 2);
|
|
});
|
|
|
|
for (const operation of ['Save', 'Archive']) {
|
|
const send = operation === 'Save' ? SAVE_AS('Invoice export, CSV') : 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()';
|
|
test(`${operation}: after a conflict a task that can no longer be read leaves the dialog unable to write`, async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
|
const t42 = orgs.store.find(s => s.n === 42);
|
|
t42.rev++;
|
|
await b.evaluate(HOLD_CONFLICT_READ);
|
|
await b.evaluate(send);
|
|
await wait('window.__held');
|
|
// The task leaves the store before the dialog's read answers, so neither read finds it.
|
|
orgs.store.splice(orgs.store.indexOf(t42), 1);
|
|
await b.evaluate('window.__release()');
|
|
await wait('/could not be read/.test(' + STATUS + ')');
|
|
assert.match(await b.evaluate(STATUS), /^The task changed after the board read it\. Nothing was written, and the current task could not be read; close this form and open the task again\.$/);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog button")].map(x => [x.textContent, x.disabled])'), [[operation === 'Archive' ? 'Archive: press again to confirm' : 'Archive', true], ['Cancel', false], ['Save', true]]);
|
|
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
|
|
await b.evaluate('new Promise(r => setTimeout(r, 200))');
|
|
assert.equal(orgs.writes.length, 1);
|
|
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
|
|
await wait('!document.querySelector("#tb-dialog").open');
|
|
});
|
|
|
|
test(`${operation}: a dialog closed just before the conflict read answers stays closed`, async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
|
orgs.store.find(s => s.n === 42).rev++;
|
|
// Hold the read's body, not the request, so the close and the read's answer land in one task:
|
|
// close() clears `open` at once, but the event that empties the dialog runs after the read resumes.
|
|
await b.evaluate(`(() => {
|
|
const real = window.fetch;
|
|
let armed = false, held = false;
|
|
window.fetch = (u, o) => {
|
|
if (String(u) === '/api/workspace/tasks') return real(u, o).then(r => { armed = true; return r; });
|
|
if (String(u).startsWith('/api/workspace?') && armed && !held) {
|
|
held = true;
|
|
return real(u, o).then(async r => {
|
|
const body = await r.json();
|
|
r.json = () => new Promise(go => { window.__releaseJson = () => go(structuredClone(body)); });
|
|
return r;
|
|
});
|
|
}
|
|
return real(u, o);
|
|
};
|
|
})()`);
|
|
await b.evaluate(send);
|
|
await wait('typeof window.__releaseJson === "function"');
|
|
await b.evaluate('document.querySelector("#tb-dialog").close(); window.__releaseJson()');
|
|
await b.evaluate('new Promise(r => setTimeout(r, 250))');
|
|
assert.deepEqual(await b.evaluate('[document.querySelector("#tb-dialog").open, document.querySelector("#tb-dialog").innerHTML]'), [false, '']);
|
|
assert.equal(orgs.writes.length, 1);
|
|
});
|
|
|
|
}
|
|
|
|
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
|
|
const { b, wait } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
|
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
|
// Hold the next workspace read until focus has moved from the search to a row's State control.
|
|
await b.evaluate(`(() => {
|
|
const real = window.fetch;
|
|
window.__held = null;
|
|
window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held
|
|
? new Promise(go => { window.__held = () => real(u, o).then(go); })
|
|
: real(u, o);
|
|
document.querySelector("[data-tb-q]").focus();
|
|
})()`);
|
|
// bus.js polls every 10 s, longer than waitFor allows.
|
|
assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true);
|
|
await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())');
|
|
await b.evaluate('window.__held()');
|
|
await wait('document.querySelector("[data-tasks-board]") !== window.__board');
|
|
await b.evaluate('new Promise(r => setTimeout(r, 100))');
|
|
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43');
|
|
});
|
|
|
|
test('an org whose tasks read fails shows its tasks as not read, not as none', async t => {
|
|
const { b } = await open(t, { tasksFail: ['forge'] });
|
|
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/);
|
|
// No org that can take a write is left, so New task is off.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
|
|
});
|
|
|
|
test('a board with no tasks read at all says so instead of showing an empty board', async t => {
|
|
const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] });
|
|
assert.deepEqual(await cards(b), []);
|
|
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./);
|
|
});
|
|
|
|
test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
|
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
|
// The write reaches the bus; a proxy then replaces the answer with a page that is not JSON.
|
|
await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('<html>Bad gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`);
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)');
|
|
const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent');
|
|
assert.doesNotMatch(text, /was not moved|Nothing was written/);
|
|
assert.match(text, /^#41: /);
|
|
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
|
|
// The board reads again and shows where the bus has the task.
|
|
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"');
|
|
});
|
|
|
|
test('a filter change asks the server for the matching tasks', async t => {
|
|
const { b, wait } = await open(t);
|
|
await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`);
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('window.__reads.length');
|
|
const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]'));
|
|
assert.equal(q.get('project'), 'forge:billing');
|
|
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
|
assert.deepEqual(await cards(b), ['forge|task:42']);
|
|
});
|
|
|
|
test('a conflict on a task another writer moved out of the board\'s filter reopens on the current task', async t => {
|
|
const { b, wait, orgs } = await open(t);
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "in-progress"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
|
// Another writer finishes the task, so the board's In progress read no longer holds it.
|
|
const t42 = orgs.store.find(s => s.n === 42);
|
|
Object.assign(t42, { state: 'done', rev: t42.rev + 1 });
|
|
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
|
await wait('document.querySelector("#tb-dialog form")?.state.value === "done"');
|
|
assert.match(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]").textContent'), /changed after the board read it/);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), true);
|
|
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
|
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
|
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
|
});
|
|
|
|
test('an empty result names the organizations whose tasks were not read', async t => {
|
|
const { b, wait } = await open(t, { tasksFail: ['ddigital'] });
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "blocked"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('/No tasks match/.test(document.querySelector("[data-tb-results]").textContent)');
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-results] .ws-panel").textContent'),
|
|
'No tasks match these filters in the organizations that answered. Tasks from Distinctly Digital, Home could not be read.');
|
|
});
|