#/inbox lists every answering organization's decisions from the one GET /api/workspace read, newest first, each row with its organization chip and a project chip when it has one. Organization and type filters live in the hash query. A stopped organization is named at the top with its reason. An answer from the list reads and posts to the row's own organization (/api/orgs/<org>/...), never the one opened last, and each item can be opened in its own organization. #/orgs/<id>/inbox is the same list filtered to that organization. No server route was added.
374 lines
31 KiB
JavaScript
374 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}`;
|
|
// SP3: a hash that starts with # is a whole address (#/inbox is a system route).
|
|
await b.navigate(`${base}/${hash.startsWith('#') ? hash : `#/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-orgrow]").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")'), '#/dashboard');
|
|
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/);
|
|
// SP1: one organization row each: two running, the stopped one with its state and reason.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(e => [e.dataset.wsOrgrow, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
|
|
assert.match(await text('[data-ws-orgrow=gamma]'), /Gamma Home/);
|
|
assert.match(await text('[data-ws-orgrow=gamma]'), /bus down/);
|
|
assert.match(await text('[data-ws-orgrow=gamma]'), /stopped by the owner/);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=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 === `#/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, /pm/);
|
|
// SP3: a project chip only where the item has a project.
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null);
|
|
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
|
|
// SP3: the prototype's Org and Type filter rows; the Seen and Answered placeholders are gone.
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] .ws-seg")'), null);
|
|
assert.ok(await b.evaluate('document.querySelectorAll("[data-ws-type]").length > 1'));
|
|
// 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}/#/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-orgrow]").length === 3');
|
|
await b.evaluate('document.querySelector("[data-ws-orgrow=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'), 'orgrow: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-orgrow]'], ['/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 organization chip on its own line below the question.
|
|
await b.evaluate('location.hash = "#/inbox"');
|
|
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
|
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('[data-ws-orgchip]').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.top >= q.bottom - 1; })()`), true, 'organization below question');
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'system inbox');
|
|
});
|
|
|
|
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 = "#/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 = "#/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('|'));
|
|
});
|