The workspace nav repaints after async organization and project reads. Under load (gate at load 14) a repaint landed between the flat-grouping wait and the org click, and the click found no button. The click now retries inside the wait until the page shows org grouping.
370 lines
31 KiB
JavaScript
370 lines
31 KiB
JavaScript
// U3 (#1873, PRD 1.20 REQ-WS-1..6): the cross-organization Dashboard, Projects and Inbox pages
|
|
// (samples 02, 04 and 05). Every bus here is a fake: two running organizations and one stopped,
|
|
// read through the real GET /api/workspace (workspace-read.mjs) and the real page code.
|
|
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();
|
|
|
|
// acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show.
|
|
const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10';
|
|
const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d';
|
|
function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
|
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 DATA = {
|
|
acme: {
|
|
projects: [
|
|
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) },
|
|
{ id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) },
|
|
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: at(60 * 24 * 20) },
|
|
],
|
|
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }],
|
|
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
|
claims: [], threads: [],
|
|
inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }],
|
|
decisions: [],
|
|
},
|
|
beta: {
|
|
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
|
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], claims: [], threads: [],
|
|
inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }] }],
|
|
decisions: [],
|
|
},
|
|
};
|
|
// fail: { org: [read names] } makes those reads refuse the way a disabled chat channel does.
|
|
const value = (id, name) => fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
|
const opts = id => ({
|
|
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
|
|
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
|
|
});
|
|
const member = org => ({
|
|
id: org.id, doc: { appearance: { theme: org.theme } },
|
|
// down: the cross-organization read finds these buses stopped; the page's own reads still answer.
|
|
status: async () => (org.busHealthy && !down.includes(org.id) ? { 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 {
|
|
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 () => [] } };
|
|
return {
|
|
busHealthy: true,
|
|
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
|
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
|
|
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
|
};
|
|
},
|
|
...(pollMs ? { pollMs } : {}),
|
|
};
|
|
}
|
|
|
|
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) {
|
|
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
|
|
t.after(() => close(server));
|
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
|
await b.viewport(...viewport);
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
await b.navigate(`${base}/#/orgs/acme${hash}`);
|
|
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
|
}
|
|
|
|
test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => {
|
|
const { b, wait, text } = await setup(t);
|
|
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3');
|
|
// The Dashboard nav item is this page.
|
|
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard');
|
|
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
|
|
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
|
// Tiles: needs you, sessions, open tasks, projects, and an honest line for spend.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']);
|
|
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
|
|
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i);
|
|
assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/);
|
|
assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/);
|
|
// Organization cards: two running with their read, the stopped one with its state and the time it was read.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
|
|
assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/);
|
|
assert.match(await text('[data-ws-orgcard=gamma]'), /down/);
|
|
assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
|
|
assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/);
|
|
assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/);
|
|
// Recent project activity across both running organizations, newest first, each linking into its org.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-recent] a")].map(a => a.getAttribute("href"))'), ['#/orgs/acme/projects/p-site', '#/orgs/beta/projects/p-shop', '#/orgs/acme/projects/p-zeta']);
|
|
assert.match(await text('[data-ws-states]'), /Doing\s*1/);
|
|
assert.match(await text('[data-ws-states]'), /Done this week\s*0/);
|
|
// Needs you lists both organizations' decisions, newest raised first as the read orders them.
|
|
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])');
|
|
assert.equal(needs.length, 2);
|
|
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
|
|
assert.ok(needs.some(([q]) => q === 'Pick a launch price'));
|
|
// Sessions now: the working and the waiting role, each with its organization.
|
|
assert.match(await text('[data-ws-sessions]'), /coder/);
|
|
assert.match(await text('[data-ws-sessions]'), /pm/);
|
|
// The sidebar Inbox count follows the read across organizations.
|
|
assert.equal(await b.evaluate('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent'), '2');
|
|
// The per-organization board moved to #/board; the processing link goes there.
|
|
assert.equal(await b.evaluate('document.getElementById("processing-link").getAttribute("href")'), '#/board');
|
|
});
|
|
|
|
test('Projects sorts and filters by each key, remembers the grouping, and rows open the project in its organization', async t => {
|
|
const { b, wait, text } = await setup(t, { hash: '/projects' });
|
|
const names = 'JSON.stringify([...document.querySelectorAll("[data-ws-project]")].map(r => r.dataset.wsProject))';
|
|
const settle = async (hash, expected) => { await wait(`location.hash === ${JSON.stringify(hash)} && ${names} === ${JSON.stringify(JSON.stringify(expected))}`); };
|
|
await wait('document.querySelector("[data-ws-projects] [data-ws-table]")');
|
|
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Projects');
|
|
// Default: active only, newest activity first, grouped by organization with the stopped one marked.
|
|
assert.equal(await b.evaluate(names), JSON.stringify(['acme:p-site', 'acme:p-zeta', 'beta:p-shop']));
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-group-org]")].map(r => r.dataset.wsGroupOrg)'), ['acme', 'beta', 'gamma']);
|
|
assert.match(await text('[data-ws-table] tr.ws-unread'), /Not read.*the bus/);
|
|
// Rows open the existing project page in their own organization.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").getAttribute("href")'), '#/orgs/beta/projects/p-shop');
|
|
// Sort by name (ascending), then again to reverse it.
|
|
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
|
|
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
|
|
await settle('#/orgs/acme/projects?sort=name', ['beta:p-shop', 'acme:p-site', 'acme:p-zeta']);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-table] th[aria-sort]").textContent.trim()'), 'Project↑');
|
|
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
|
|
await settle('#/orgs/acme/projects?sort=name&order=desc', ['acme:p-zeta', 'acme:p-site', 'beta:p-shop']);
|
|
// Sort by organization and by status.
|
|
await b.evaluate('document.querySelector("[data-ws-sort=org]").click()');
|
|
await wait('location.hash === "#/orgs/acme/projects?sort=org" && document.querySelector("[data-ws-sort=org]").getAttribute("aria-pressed") === "true"');
|
|
assert.deepEqual(JSON.parse(await b.evaluate(names)).map(k => k.split(':')[0]), ['acme', 'acme', 'beta']);
|
|
await b.evaluate('document.querySelector("[data-ws-sort=status]").click()');
|
|
await wait('location.hash === "#/orgs/acme/projects?sort=status" && document.querySelector("[data-ws-sort=status]").getAttribute("aria-pressed") === "true"');
|
|
// Filter by status: archived and both.
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await settle('#/orgs/acme/projects?sort=status&status=archived', ['acme:p-old']);
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "active,archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
|
|
// Filter by session state: only Website has a decision waiting.
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = "waiting"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-site']))}`);
|
|
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
|
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
|
|
// Filter by organization chip, then back to all.
|
|
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
|
|
await wait(`${names} === ${JSON.stringify(JSON.stringify(['beta:p-shop']))} && document.querySelector("[data-ws-org=beta]").getAttribute("aria-pressed") === "true"`);
|
|
assert.match(location(await b.evaluate('location.hash')), /org=beta/);
|
|
await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()');
|
|
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
|
|
// Filter by name: typing keeps focus and the caret in the box while the list narrows.
|
|
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "zet"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
|
|
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-zeta']))}`);
|
|
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.value, document.activeElement.selectionStart]'), ['proj-name', 'zet', 3]);
|
|
// Flat mode shows an Organization column and names the stopped organization; the choice is the
|
|
// sidebar's remembered one and survives a reload.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-table]").dataset.grouping'), 'flat');
|
|
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
|
|
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=flat]").getAttribute("aria-pressed")'), 'true');
|
|
assert.equal(await b.evaluate('localStorage.getItem("mosaic-workspace-project-grouping-v1")'), 'flat');
|
|
await b.evaluate('location.reload()');
|
|
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "flat" && document.querySelectorAll("[data-ws-project]").length === 1 && document.querySelector("#workspace-nav [data-group=flat]")?.getAttribute("aria-pressed") === "true"');
|
|
// The sidebar's toggle switches this page back.
|
|
// The nav repaints after async reads, so the click retries until the page switches.
|
|
await wait('(document.querySelector("#workspace-nav [data-group=org]")?.click(), document.querySelector("[data-ws-table]")?.dataset.grouping === "org")');
|
|
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "org" && document.querySelector("[data-ws-group=org]").getAttribute("aria-pressed") === "true"');
|
|
});
|
|
const location = hash => decodeURIComponent(hash);
|
|
|
|
test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => {
|
|
const { b, base, wait, text } = await setup(t, { hash: '/inbox' });
|
|
await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")');
|
|
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox');
|
|
assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null);
|
|
const items = await b.evaluate('[...document.querySelectorAll("[data-ws-item]")].map(r => [r.dataset.wsItem, r.innerText])');
|
|
assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]);
|
|
// Each row names its organization, project and session.
|
|
const acme = items.find(([k]) => k.startsWith('acme'))[1];
|
|
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/);
|
|
const beta = items.find(([k]) => k.startsWith('beta'))[1];
|
|
assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/);
|
|
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
|
|
// Tabs the data can't back say so.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]);
|
|
assert.match(await text('[data-ws-inbox]'), /not built yet/);
|
|
// Select the acme decision; the inspector shows it, and the selection stays in the hash.
|
|
// A pointer click focuses the link before it activates; .click() alone does not.
|
|
await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`);
|
|
await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === ${JSON.stringify(`acme:${ACME_DECISION}`)}`);
|
|
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), `item:acme:${ACME_DECISION}`);
|
|
const cmds = await b.evaluate('[...document.querySelectorAll("[data-ws-cmds] [data-copy]")].map(x => [x.dataset.copy, x.previousElementSibling.tagName, x.previousElementSibling.textContent])');
|
|
assert.deepEqual(cmds, [[`mosaic decide ${ACME_DECISION} yes --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} yes --org acme`], [`mosaic decide ${ACME_DECISION} no --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} no --org acme`]]);
|
|
// Copy puts the full command on the clipboard (or selects it when there is none) and says so.
|
|
await b.evaluate('document.querySelector("[data-ws-cmds] [data-copy]").click()');
|
|
await wait('document.getElementById("copy-status")?.textContent.length > 0');
|
|
const status = await text('#copy-status');
|
|
assert.ok(status.includes(`mosaic decide ${ACME_DECISION} yes --org acme`) || /clipboard is unavailable/.test(status), status);
|
|
// An answer opens the existing per-organization decision page.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] .ws-opts a").getAttribute("href")'), `#/orgs/acme/inbox/${ACME_DECISION}`);
|
|
// Without a signed-in human session and its proof, the per-organization decide path refuses.
|
|
const refused = await b.evaluate(`(async () => { const r = await fetch('/api/orgs/acme/decisions/${ACME_DECISION}/confirmation', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }) }); return [r.status, (await r.json().catch(() => ({}))).error ?? null]; })()`);
|
|
assert.ok(refused[0] >= 400 && refused[0] < 500, JSON.stringify(refused));
|
|
// The decision page itself offers no answer controls without that proof.
|
|
await b.navigate(`${base}/#/orgs/acme/inbox/${ACME_DECISION}`);
|
|
await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")');
|
|
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null);
|
|
// Filter to one organization.
|
|
await b.navigate(`${base}/#/orgs/acme/inbox`);
|
|
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
|
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
|
|
await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`);
|
|
});
|
|
|
|
test('A poll repaints in place: focus, caret and scroll stay where they were', async t => {
|
|
const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] });
|
|
await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');
|
|
assert.ok(await b.evaluate('window.MosaicWorkspace.pollMs') >= 1000);
|
|
// Focus a row link, scroll the page, and repaint as a poll does.
|
|
await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").focus({ preventScroll: true }); (document.scrollingElement.scrollTop = 120); document.getElementById("main").scrollTop = 120;');
|
|
const before = await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]');
|
|
assert.ok(before.some(v => v > 0), `nothing scrolled: ${before}`);
|
|
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
|
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
|
|
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'prow:beta:p-shop');
|
|
assert.deepEqual(await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]'), before);
|
|
// The search box keeps its caret across a repaint.
|
|
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "shop"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
|
|
await wait('location.hash.includes("name=shop") && document.querySelectorAll("[data-ws-project]").length === 1 && document.activeElement?.dataset.focusKey === "proj-name"');
|
|
await b.evaluate('document.activeElement.setSelectionRange(2, 2)');
|
|
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
|
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
|
|
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]);
|
|
// The Dashboard and the Inbox repaint the same way.
|
|
await b.evaluate('location.hash = "#/orgs/acme/"');
|
|
await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3');
|
|
await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()');
|
|
await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
|
await wait('document.querySelector("[data-ws-orgs]") !== window.__marker');
|
|
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta');
|
|
// No loading view replaced the page during that repaint.
|
|
assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null);
|
|
});
|
|
|
|
test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => {
|
|
const { b, wait } = await setup(t, { viewport: [390, 844] });
|
|
for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
|
|
await b.evaluate(`location.hash = "#/orgs/acme${hash}"`);
|
|
await wait(`document.querySelector(${JSON.stringify(ready)})`);
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash);
|
|
}
|
|
await b.evaluate('document.getElementById("nav-open").click()');
|
|
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
|
|
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility !== "hidden" && document.getElementById("console-nav").getBoundingClientRect().right > 0'), true);
|
|
await b.evaluate('document.getElementById("nav-close").click()');
|
|
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "false"');
|
|
// A sidebar link closes the drawer and opens its page.
|
|
await b.evaluate('document.getElementById("nav-open").click()');
|
|
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
|
|
await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()');
|
|
await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")');
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
|
// An inbox row at phone width puts its source on its own line above the question.
|
|
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
|
await wait('document.querySelector("[data-ws-item] .ws-msrc")');
|
|
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question');
|
|
});
|
|
|
|
test('A read that an answering organization could not make says not read, never zero', async t => {
|
|
// beta answers, but its project and inbox reads refuse.
|
|
const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } });
|
|
await wait('document.querySelector("[data-ws-tiles]")');
|
|
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/);
|
|
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/);
|
|
assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/);
|
|
assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/);
|
|
assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed');
|
|
// Projects, grouped: beta's group says why and shows no count or "No projects match".
|
|
await b.evaluate('location.hash = "#/orgs/acme/projects"');
|
|
await wait('document.querySelector("[data-ws-group-org=beta]")');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-ws-group-org=beta] .ws-n')`), null);
|
|
const betaRow = await b.evaluate(`document.querySelector('[data-ws-group-org=beta]').nextElementSibling.innerText`);
|
|
assert.match(betaRow, /Projects not read: the chat channel is off/);
|
|
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
|
|
// Flat: the note names it.
|
|
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
|
|
await wait('document.querySelector("[data-ws-miss=projects]")');
|
|
assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/);
|
|
// Inbox: the note names it, and the chip carries no count.
|
|
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
|
await wait('document.querySelector("[data-ws-miss=inbox]")');
|
|
assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/);
|
|
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
|
|
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-item]").length'), 1);
|
|
});
|
|
|
|
test('When every answering organization failed a read, its tile says Not read instead of a number', async t => {
|
|
const { wait, text } = await setup(t, { fail: { acme: ['projects'], beta: ['projects'] } });
|
|
await wait('document.querySelector("[data-ws-tiles]")');
|
|
assert.match(await text('[data-ws-tile=projects]'), /Not read/i);
|
|
assert.equal(await text('[data-ws-tile=projects] .ws-big'), null);
|
|
});
|
|
|
|
// Review P2 (org-u3): a project row's figures come from reads beyond the project list.
|
|
test('Project rows and per-organization figures say not read when the reads behind them failed', async t => {
|
|
const { b, wait, text } = await setup(t, { fail: { beta: ['inbox', 'tasks', 'agents', 'claims', 'threads'] }, hash: '/projects' });
|
|
await wait('document.querySelector("[data-ws-project=\'beta:p-shop\']")');
|
|
const cells = await b.evaluate(`[...document.querySelector("[data-ws-project='beta:p-shop']").cells].map(c => c.innerText.trim())`);
|
|
// Grouped columns: Project, Status, Sessions, Open tasks, Needs you, Last activity, Git.
|
|
assert.deepEqual(cells.slice(2, 5), ['not read', 'not read', 'not read'], 'sessions, open tasks and needs you');
|
|
assert.match(cells[5], /some reads missing/);
|
|
const acme = await b.evaluate(`[...document.querySelector("[data-ws-project='acme:p-site']").cells].map(c => c.innerText.trim())`);
|
|
assert.equal(acme[3], '1'); assert.equal(acme[4], '1');
|
|
assert.match(await text('[data-ws-miss=figures]'), /Some figures not read in Beta Works/);
|
|
// Dashboard: the per-organization figures leave Beta out instead of showing its 0.
|
|
await b.evaluate('location.hash = "#/orgs/acme/"');
|
|
await wait('document.querySelector("[data-ws-tiles]")');
|
|
for (const tile of ['needs', 'sessions', 'tasks']) {
|
|
const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`);
|
|
assert.doesNotMatch(by, /Beta Works/, tile);
|
|
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile);
|
|
}
|
|
assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/);
|
|
});
|
|
|
|
test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => {
|
|
const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] });
|
|
await wait('document.querySelector("[data-ws-tiles]")');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0);
|
|
assert.match(await text('[data-ws-tiles]'), /No organization answered/);
|
|
assert.equal(await text('[data-ws-needs]'), 'Decisions not read');
|
|
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
|
|
assert.equal(await text('[data-ws-recent]'), 'Projects not read');
|
|
assert.equal(await text('[data-ws-sessions]'), 'Sessions not read');
|
|
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
|
await wait('document.querySelector("[data-ws-items]")');
|
|
assert.equal(await text('[data-ws-items]'), 'Decisions not read');
|
|
await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"');
|
|
await wait('document.querySelector("[data-ws-table]")');
|
|
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
|
|
await wait('document.querySelector("[data-ws-table][data-grouping=flat]")');
|
|
assert.match(await text('[data-ws-table] tbody'), /Projects not read/);
|
|
});
|
|
|
|
test('When no organization could read its tasks, Tasks by state shows no Done this week figure', async t => {
|
|
const { wait, text } = await setup(t, { fail: { acme: ['tasks'], beta: ['tasks'] } });
|
|
await wait('document.querySelector("[data-ws-states]")');
|
|
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
|
|
assert.match(await text('[data-ws-tile=tasks]'), /Not read/);
|
|
});
|
|
|
|
// Review P3: the session's organization is in the accessible text, not only in the hidden avatar.
|
|
test('Sessions now names each session\'s organization for a screen reader', async t => {
|
|
const { b, wait } = await setup(t);
|
|
await wait('document.querySelector("[data-ws-sessions] td.who")');
|
|
const who = await b.evaluate('[...document.querySelectorAll("[data-ws-sessions] td.who")].map(c => c.textContent)');
|
|
assert.ok(who.some(w => /Acme Studio: coder/.test(w)), who.join('|'));
|
|
assert.ok(who.some(w => /Beta Works: pm/.test(w)), who.join('|'));
|
|
});
|