- A system page with nothing above it in the trail hides the trail that repeats its h1; drill-in trails stay. - A project's thread link and each open-in-chat link add that thread as a pane on #/assistant. With three open, the first pane without an unsent message is replaced; when all three hold one, nothing is replaced and the page says why. Per-org chat addresses still work typed or pasted. - "Messages in <org>" moves from the bar into each pane header as a link. - The pane composer's Resume assistant is a small secondary control; behaviour unchanged. - Inbox class chips read the one map of decision classes to words. - Removes the unused pane, idPrefix and views params from projects.js and workspace.js; fixes the stale comment on the held-message status line. - While an organization's bus is down, the header draws no bus control; the down banner's restart is the one.
309 lines
21 KiB
JavaScript
309 lines
21 KiB
JavaScript
// 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 = [] } = {}) {
|
|
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 = []; }
|
|
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(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 } }));
|
|
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 = [] } = {}) {
|
|
const orgs = fixture({ empty, archived });
|
|
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);
|
|
});
|
|
|
|
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');
|
|
});
|