// U5 (#1873, sample 07): Side by side shows two panes, each holding one page of any organization. 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)})})()`); // Three organizations: two running buses and one stopped (home). `state.flip` changes what the // running organizations report, so a refresh repaints both panes. function orgs(state = { flip: false, seen: [] }) { 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 PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] }; const INSTANCES = { ddigital: ['chief', 'copywriter', 'producer', 'editor', 'researcher', 'designer', 'analyst', 'scheduler'], forge: ['chief', 'builder', 'tester', 'reviewer', 'operator', 'scribe'] }; const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'Launch email', project: 'spring-launch', instance: 'copywriter' }], forge: [{ id: 'f0', name: 'Gateway auth', project: 'api-gateway', instance: 'chief' }] }; const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: Object.fromEntries(INSTANCES[id].map((n, i) => [n, { definition: n, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [`r${i}`], alive: [] }])) }); const agents = id => INSTANCES[id].map((role, i) => ({ role, holder_run: `r${i}`, state: (i % 2 === 0) === state.flip ? 'working' : 'waiting' })); const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: {}, instances: INSTANCES[id].map(n => ({ instance: n, vars: {} })), appearance: { theme: ORGS.find(o => o.id === id).theme } } }); return { list: async () => ORGS, resolve: async id => { state.seen.push(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 () => [] } }; return { busHealthy: true, reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', launch: { limits: { total: 4 } } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => agents(id), 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, chat: { projects: async () => PROJECTS[id], threads: async () => THREADS[id] }, projectFiles: async () => ({ head: 'a'.repeat(40), files: [] }), }; }, }; } async function open(t, { width = 1440, height = 900, hash = '#/orgs/ddigital/side-by-side', state } = {}) { const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs(state), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(width, height); const base = `http://127.0.0.1:${server.address().port}/`; await b.navigate(base + hash); return { b, base, wait: waitFor(b) }; } const text = key => `(document.querySelector("#pane-${key} [data-pane-body]")?.innerText || "")`; const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.textContent || "")`; const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()'; const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`); test('side by side stays dark by default under light media, and applying system or light really draws light', async t => { const { b, wait, base } = await open(t); await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] }); const drawn = () => b.evaluate(`({ mode: document.documentElement.getAttribute('data-mode'), palette: document.documentElement.dataset.palette, canvas: getComputedStyle(document.body).backgroundColor, source: window.MosaicAppearance.state().modeSource })`); assert.deepEqual(await drawn(), { mode: 'dark', palette: 'rosewood', canvas: 'rgb(26, 20, 23)', source: 'default' }); for (const mode of ['system', 'light']) { // U5's corrected shot recipe seeds the per-org key, then loads a fresh document. const before = await drawn(); await b.evaluate(`localStorage.setItem('mosaic-console-appearance:ddigital', JSON.stringify({ mode: ${JSON.stringify(mode)} }))`); assert.deepEqual(await drawn(), before, 'writing storage alone does not apply an override to this document'); await b.navigate('about:blank'); await b.navigate(`${base}?appearance=${mode}#/orgs/ddigital/side-by-side`); await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); const result = await drawn(); assert.deepEqual(result, { mode: mode === 'system' ? null : 'light', palette: 'rosewood', canvas: 'rgb(246, 243, 245)', source: 'browser' }); t.diagnostic(JSON.stringify({ scheme: 'light', requested: mode, ...result })); } }); test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => { const state = { flip: false, seen: [] }; const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state }); await wait('document.querySelector("[data-org-dashboard]")'); await b.evaluate('[...document.querySelectorAll("#sections > li > a")].find(a => a.textContent.trim() === "Side by side").click()'); await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("Forge")`); assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /Side by side is not built yet/i); assert.equal(await b.evaluate('document.querySelector("#bus-view h1").textContent'), 'Side by side'); assert.equal(await b.evaluate('document.title'), 'Side by side · Mosaic Console'); assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/organization', w: '50', show: 'a' }); await wait(`${text('a')}.includes("Distinctly Digital") && ${text('b')}.includes("Forge")`); // Each pane's links name its own organization. assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/forge/"))'), true); assert.equal(await b.evaluate('document.querySelectorAll("#pane-a [data-pane-body] h1[aria-level=\\"2\\"]").length > 0'), true); // The picker lists every organization and its projects. const groups = await b.evaluate('[...document.querySelectorAll("#pane-b-pick optgroup")].map(g => g.label)'); assert.deepEqual(groups, ['Distinctly Digital', 'Forge', 'Home · bus unavailable']); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#pane-b-pick option")].map(o => o.value).filter(Boolean)'), ['ddigital/organization', 'ddigital/agents', 'ddigital/projects/spring-launch', 'ddigital/projects/brand-refresh', 'forge/organization', 'forge/agents', 'forge/projects/api-gateway', 'home/organization', 'home/agents']); // Pick a project in the right pane; the left keeps its page. await pick(b, 'b', 'forge/projects/api-gateway'); await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway") && location.hash.includes("b=forge/projects/api-gateway")`); assert.match(await b.evaluate(title('a')), /^Dashboard/); // A project tab opens in the pane, not the full view. await b.evaluate('[...document.querySelectorAll("#pane-b nav.project-tabs a")].find(a => /Tasks/.test(a.textContent)).click()'); await wait(`location.hash.includes("b=forge/projects/api-gateway/tasks") && ${title('b')}.includes("Tasks")`); assert.match(await b.evaluate('location.hash'), /^#\/orgs\/ddigital\/side-by-side\?/); // Reload: both panes come back from the URL. await b.call('Page.reload'); await wait('document.readyState === "complete"'); await wait(`${title('a')}.startsWith("Dashboard") && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("API gateway") && ${title('b')}.includes("Tasks")`); assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/projects/api-gateway/tasks', w: '50', show: 'a' }); // A shared link opens exactly what it names, whatever this browser remembered. await b.navigate(`${base}#/orgs/forge/side-by-side?a=forge/agents&b=ddigital/projects/spring-launch&w=40&show=b`); await wait(`${title('a')}.startsWith("Agents") && ${title('a')}.includes("Forge") && ${title('b')}.startsWith("Spring launch")`); assert.equal(await b.evaluate('document.querySelector("[data-sbs-divider]").getAttribute("aria-valuenow")'), '40'); // A link with a route a pane does not draw falls back to the remembered layout or the default. await b.navigate(`${base}#/orgs/ddigital/side-by-side?a=ddigital/settings&b=nope!/organization`); await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.length > 0 && !location.hash.includes("settings")`); // Leaving the page stops both panes reading. await b.evaluate('location.hash = "#/orgs/ddigital/agents"'); await wait('!document.querySelector("[data-side-by-side]") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Agents")'); assert.ok(state.seen.includes('forge') && state.seen.includes('ddigital')); }); test('the divider resizes the panes by mouse and by keyboard', async t => { const { b, wait } = await open(t); await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); const sep = '[role=separator][data-sbs-divider]'; assert.deepEqual(await b.evaluate(`(() => { const d = document.querySelector("${sep}"); return [d.getAttribute("aria-valuenow"), d.getAttribute("aria-valuemin"), d.getAttribute("aria-valuemax"), d.getAttribute("aria-orientation"), d.tabIndex]; })()`), ['50', '25', '75', 'vertical', 0]); const ratio = `(() => { const a = document.getElementById("pane-a").getBoundingClientRect().width, b = document.getElementById("pane-b").getBoundingClientRect().width; return Math.round(a / (a + b) * 100); })()`; // Mouse: drag the divider to 30% of the panes' width. const box = await b.evaluate(`(() => { const p = document.querySelector("[data-sbs-panes]").getBoundingClientRect(), d = document.querySelector("${sep}").getBoundingClientRect(); return { left: p.left, width: p.width, x: d.left + d.width / 2, y: d.top + 200 }; })()`); const target = box.left + box.width * 0.3; await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y }); await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', buttons: 1, clickCount: 1 }); for (const step of [0.25, 0.5, 0.75, 1]) await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x + (target - box.x) * step, y: box.y, button: 'left', buttons: 1 }); await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target, y: box.y, button: 'left', buttons: 0, clickCount: 1 }); await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "30" && location.hash.includes("w=30")`); assert.ok(Math.abs(await b.evaluate(ratio) - 30) <= 1); // Keyboard: the separator has focus after the drag; arrows step by 5, Home and End reach the limits. assert.equal(await b.evaluate('document.activeElement.getAttribute("role")'), 'separator'); await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "35"`); await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "25"`); await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); assert.equal(await b.evaluate(`document.querySelector("${sep}").getAttribute("aria-valuenow")`), '25'); await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'End', code: 'End', windowsVirtualKeyCode: 35 }); await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'End', code: 'End', windowsVirtualKeyCode: 35 }); await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "75" && location.hash.includes("w=75")`); assert.ok(Math.abs(await b.evaluate(ratio) - 75) <= 1); // Closing a pane goes back to one; the divider leaves with it. Add pane brings a second back. await b.evaluate('document.querySelector("#pane-a [data-pane-close]").click()'); await wait(`document.getElementById("pane-b").hidden && document.querySelector("${sep}").hidden && !new URLSearchParams(location.hash.split("?")[1]).has("b") && ${title('a')}.includes("Forge")`); assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-pick'); assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-close]").hidden'), true); assert.match(await b.evaluate('document.querySelector("[data-sbs-note]").textContent'), /^1 pane ·/); await b.evaluate('document.querySelector("[data-pane-add]").click()'); await wait(`!document.getElementById("pane-b").hidden && ${title('b')}.length > 0 && new URLSearchParams(location.hash.split("?")[1]).has("b")`); assert.equal(await b.evaluate('document.querySelector("[data-pane-add]").disabled'), true); }); test('each pane keeps its own focus and scroll through a refresh', async t => { const state = { flip: false, seen: [] }; const { b, wait } = await open(t, { height: 520, state, hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' }); await wait(`${text('a')}.includes("copywriter") && ${text('b')}.includes("builder")`); const scrollable = await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollHeight - el.clientHeight)'); assert.ok(scrollable.every(n => n > 60), `both panes scroll: ${scrollable}`); const focused = await b.evaluate('(() => { const a = document.querySelector("#pane-a [data-pane-body] a[href]"); a.focus(); return a.getAttribute("href"); })()'); await b.evaluate('document.querySelector("#pane-a [data-pane-body]").scrollTop = 40; document.querySelector("#pane-b [data-pane-body]").scrollTop = 60;'); const before = await b.evaluate(`[${text('a')}, ${text('b')}]`); state.flip = true; await b.evaluate('document.getElementById("refresh").click()'); await wait(`${text('a')} !== ${JSON.stringify(before[0])} && ${text('b')} !== ${JSON.stringify(before[1])}`); assert.equal(await b.evaluate('document.querySelector("#pane-a").contains(document.activeElement) && document.activeElement.getAttribute("href")'), focused); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]); // Focus in the right pane stays there while the left repaints. const right = await b.evaluate('(() => { const a = document.querySelector("#pane-b [data-pane-body] a[href]"); a.focus({ preventScroll: true }); return a.getAttribute("href"); })()'); state.flip = false; await b.evaluate('document.getElementById("refresh").click()'); await wait(`${text('a')} === ${JSON.stringify(before[0])}`); assert.equal(await b.evaluate('document.querySelector("#pane-b").contains(document.activeElement) && document.activeElement.getAttribute("href")'), right); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]); }); test('a pane whose organization is down shows its state and the other pane still works', async t => { const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=home/organization&b=forge/organization' }); await wait(`${title('a')}.includes("Home") && ${title('b')}.includes("Forge") && ${text('b')}.includes("Forge")`); await wait('/Home: bus unavailable/.test(document.querySelector("#pane-a [data-pane-line]").textContent)'); assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-line]").getAttribute("role")'), 'status'); assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-state]").innerText.trim().length > 0'), true); assert.equal(await b.evaluate('document.querySelector("#pane-b [data-pane-line]").hidden'), true); await pick(b, 'b', 'forge/agents'); await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`); await pick(b, 'b', 'forge/projects/api-gateway'); await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway")`); assert.match(await b.evaluate(title('a')), /Home/); // An organization that is not on the list is one honest line; nothing is read for it. await b.evaluate('location.hash = "#/orgs/ddigital/side-by-side?a=ghost/organization&b=forge/agents"'); await wait(`/ghost is not on your organization list/.test(document.querySelector("#pane-a [data-pane-line]").textContent) && ${text('b')}.includes("builder")`); }); test('on a phone one pane shows at a time, switched from the bar, with no horizontal scroll', async t => { const { b, wait } = await open(t, { width: 390, height: 844 }); await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0 && ${text('a')}.length > 0`); const visible = '[...document.querySelectorAll(".panes > .pane")].filter(p => p.getBoundingClientRect().height > 0).map(p => p.id)'; assert.deepEqual(await b.evaluate(visible), ['pane-a']); assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-tabs]")).display'), 'none'); assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-divider]")).display'), 'none'); assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-pane-add]")).display'), 'none'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['true', 'false']); assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll'); await b.evaluate('document.querySelector("[data-sbs-tabs] [data-pane-show=b]").click()'); await wait(`JSON.stringify(${visible}) === '["pane-b"]' && location.hash.includes("show=b")`); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['false', 'true']); assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on the second pane'); await pick(b, 'b', 'ddigital/projects/spring-launch'); await wait(`${text('b')}.includes("Spring launch")`); assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on a project page'); }); // Review fixes (#1873 U5 review, findings 1-4). `hold(url)` makes the page's next read of that URL // wait until `release()`; the test then changes the pane while the read is pending. const hold = url => `(() => { const real = window.fetch; window.held = { url: ${JSON.stringify(url)}, pending: false }; window.fetch = async (u, o) => { if (window.held?.url === u && !window.held.done) { window.held.pending = true; await new Promise(r => { window.held.release = () => { window.held.done = true; r(); }; }); } return real(u, o); }; })()`; const settled = b => b.evaluate('new Promise(r => setTimeout(r, 400))'); test('a pane closed and opened again rewrites new links for its current organization only', async t => { const { b, wait } = await open(t); await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`); await b.evaluate('document.querySelector("#pane-b [data-pane-close]").click()'); await wait('document.querySelector("#pane-b").hidden && !document.querySelector("[data-pane-add]").disabled'); await b.evaluate('document.querySelector("[data-pane-add]").click()'); await wait(`!document.querySelector("#pane-b").hidden && ${title('b')}.length > 0`); await pick(b, 'b', 'ddigital/organization'); await wait(`${title('b')}.includes("Distinctly Digital") && location.hash.includes("b=ddigital/organization")`); // Content a page appends after its own reads (projects.js does this) gets the pane's organization. await b.evaluate(`document.querySelector("#pane-b [data-pane-body]").insertAdjacentHTML("beforeend", 'latefile')`); await wait('document.querySelector("#pane-b [data-late]").getAttribute("href").startsWith("#/orgs/")'); assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late]").getAttribute("href")'), '#/orgs/ddigital/docs/example'); assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late-api]").getAttribute("href")'), '/api/orgs/ddigital/files/x'); // Leaving the page stops every pane observer: later content in the old element is left alone. await b.evaluate('location.hash = "#/orgs/ddigital/agents"'); await wait('!document.querySelector("[data-side-by-side]")'); }); test('a slow project read never paints over the page or organization chosen since', async t => { const { b, wait } = await open(t); await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`); // Page change while the project's thread read is pending. await b.evaluate(hold('/api/orgs/forge/chat/threads')); await pick(b, 'b', 'forge/projects/api-gateway'); await wait('window.held.pending'); await pick(b, 'b', 'forge/agents'); await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`); await b.evaluate('window.held.release()'); await settled(b); assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old project did not replace Agents'); assert.match(await b.evaluate(title('b')), /^Agents/); // Organization change while the read is pending: the old organization's project is not painted. await b.evaluate(hold('/api/orgs/forge/chat/threads')); await pick(b, 'b', 'forge/projects/api-gateway'); await wait('window.held.pending'); await pick(b, 'b', 'ddigital/projects/spring-launch'); await wait(`${title('b')}.startsWith("Spring launch") && ${text('b')}.includes("Spring launch")`); await b.evaluate('window.held.release()'); await settled(b); assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old organization did not replace Spring launch'); assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/orgs/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/ddigital/"))'), true); }); test('the same project in both panes keeps a request choice per pane', async t => { const { b, wait } = await open(t); await wait(`${title('b')}.includes("Forge")`); const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); // The fixture answers the project reads of both organizations; the organization list stays real. await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); await b.evaluate('window.MosaicPanes.refresh()'); await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`); const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`; await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']); await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); await wait(`${values}[0] === 'request-2'`); // The redrawn select gets focus back through the pane's own id. assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select'); await b.evaluate('window.MosaicPanes.refresh()'); await settled(b); await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']); }); test('a pane keeps its request choice and PRD disclosure when a new conversation repaints the project', async t => { const { b, wait } = await open(t); await wait(`${title('b')}.includes("Forge")`); const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); await b.evaluate('window.MosaicPanes.refresh()'); await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); // The same organization and project in both panes. await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`); await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`); const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`; await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`); await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`); await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`); // A new conversation changes the header's count, so each pane replaces its project content. const before = await b.evaluate(`document.querySelector('#pane-a .project-header p')?.textContent || ''`); await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`); await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`); await settled(b); await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`); assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] }); }); test('a pane navigation that finishes late does not take focus from the other pane', async t => { const { b, wait } = await open(t); await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]') && document.querySelector('#pane-b-pick')?.options.length > 4`); await b.evaluate(hold('/api/orgs/ddigital/bus/instances')); await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`); await wait('window.held.pending'); await b.evaluate(`document.querySelector('#pane-b-pick').focus(); window.held.release()`); await wait(`${title('a')}.startsWith('Agents')`); await settled(b); assert.equal(await b.evaluate('document.activeElement.id'), 'pane-b-pick'); // Without a move elsewhere, the same navigation still lands on the new page's heading. await pick(b, 'a', 'ddigital/organization'); await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]')`); await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`); await wait(`${title('a')}.startsWith('Agents') && document.activeElement?.tagName === 'H1' && document.querySelector('#pane-a').contains(document.activeElement)`); }); // A1's "Available in a project" chooser, with an Agents pane per organization: each pane's choice // redraws its own table and survives a refresh, keyed by that pane's organization. test('each Agents pane keeps its own project choice for its organization', async t => { const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' }); const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] }); const set = agents => ({ state: 'ok', agents, excluded: [] }); const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [ { id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { 'spring-launch': [{ name: 'social-scheduler', form: 'define' }] }, projects: { 'spring-launch': set([a('chief'), a('social-scheduler', 'project', 'coder')]), 'brand-refresh': set([a('chief')]) } }, { id: 'forge', state: 'ok', base: set([a('chief')]), defined: { 'api-gateway': [{ name: 'load-tester', form: 'define' }] }, projects: { 'api-gateway': set([a('chief'), a('load-tester', 'project', 'coder')]), ops: set([a('chief')]) } }, ] }; await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`); await b.evaluate('window.MosaicPanes.refresh()'); const select = key => `document.querySelector('#pane-${key} [data-agents-project]')`; const rows = key => `[...document.querySelectorAll('#pane-${key} [data-agents-available] tr[data-agent]')].map(r => r.dataset.agent)`; await wait(`${select('a')} && ${select('b')} && ${rows('a')}.includes('social-scheduler') && ${rows('b')}.includes('load-tester')`); await b.evaluate(`(() => { const s = ${select('b')}; s.value = 'ops'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`); await wait(`!${rows('b')}.includes('load-tester')`); assert.equal(await b.evaluate(`${rows('a')}.includes('social-scheduler')`), true); assert.equal(await b.evaluate(`${rows('a')}.includes('load-tester')`), false); await b.evaluate('window.MosaicPanes.refresh()'); await settled(b); await wait(`${select('a')} && ${select('b')}`); assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']); assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true); }); // Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id // appears twice, and every label, ARIA reference and in-page link resolves inside its own pane. const idAudit = `(() => { const count = new Map(); for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1); const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort(); const stray = []; for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) { for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) { for (const el of pane.querySelectorAll('[' + attr + ']')) { for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) { const target = document.getElementById(id); if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id); } } } for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) { const id = decodeURIComponent(a.getAttribute('href').slice(1)); const target = id && document.getElementById(id); if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id); } } return { duplicates, stray: stray.sort() }; })()`; test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => { const { b, wait } = await open(t); await wait(`${title('b')}.includes("Forge")`); const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] }); const set = agents => ({ state: 'ok', agents, excluded: [] }); const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [ { id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] }, projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } }, ] }; await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`); await b.evaluate('window.MosaicPanes.refresh()'); await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`; const pages = [ ['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-system-types-heading]').length === 2`], ['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`], [`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], [`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`], [`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], [`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`], [`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], ]; const found = {}, drawn = {}; for (const [route, ready] of pages) { await b.evaluate(both(route)); await wait(ready); await settled(b); const page = route.replace(P, 'P'); found[page] = await b.evaluate(idAudit); drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`); } assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }]))); // The audit is not vacuous: the dashboard draws its system types heading and the requirements page // its request select, north star and PRD headings, each with the pane's prefix. assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why', 'pane-a-ws-types-h']); assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']); });