The second review found two paths the first fix round left open.
- The project-file save captures orgEpoch before its POST. When a switch
happened meanwhile, it neither paints nor announces the old file's
outcome on the new organization's page. An opaqueredirect still signs out.
- A live read (reader, get.api), the palette's inbox and tasks reads and
the board poll keep their answer in `last` only while the organization
they were sent for is still open. A later cached render on the new
organization no longer draws the old one's rows.
single-pane test 11 holds acme's file save across a switch to beta and
asserts nothing is announced: red on the previous bus.js ("Saved as commit
cccccccccccc."), green now. The kept-read guard has no browser test.
313 lines
25 KiB
JavaScript
313 lines
25 KiB
JavaScript
// SP1 (#1873, single pane slice 1): #/dashboard is the system landing route, an organization page is a
|
||
// drill-in route inside the same shell (no reload, the sidebar and top bar stay), and an organization
|
||
// whose bus is down says so with its reason. Every bus is a fake: acme and beta answer, gamma's bus is
|
||
// stopped. The real GET /api/orgs, /api/workspace and /api/agents/levels serve the page.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { fileURLToPath } from 'node:url';
|
||
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();
|
||
|
||
async function fixture({ empty = false, bootless = false } = {}) {
|
||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
|
||
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, busStopped: true },
|
||
];
|
||
const ROLES = {
|
||
acme: { chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } },
|
||
beta: { chief: { definition: 'chief-of-staff' } },
|
||
gamma: { chief: { definition: 'chief-of-staff' }, researcher: { definition: 'research' } },
|
||
};
|
||
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) }],
|
||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||
inbox: [{ id: 'dec-acme-1', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', at: at(10), options: [{ key: 'yes', text: 'Ship it' }] }],
|
||
},
|
||
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
|
||
};
|
||
// Launch settings differ per organization so a value carried from one to the other shows.
|
||
const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } });
|
||
const value = (id, name) => 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 }, roles: ROLES[org.id], projects: {} },
|
||
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
|
||
options: () => opts(org.id),
|
||
});
|
||
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
|
||
const organizations = {
|
||
list: async () => (empty ? [] : LIST),
|
||
members: async () => (empty ? [] : 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 () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, '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, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
|
||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||
};
|
||
},
|
||
};
|
||
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
|
||
}
|
||
|
||
async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
|
||
const { organizations, agentLevels } = await fixture({ empty, bootless });
|
||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
|
||
t.after(() => close(server));
|
||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||
await b.viewport(...viewport);
|
||
// `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL.
|
||
if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init });
|
||
const base = `http://127.0.0.1:${server.address().port}`;
|
||
await b.navigate(`${base}${path}`);
|
||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||
}
|
||
|
||
const rowText = org => `document.querySelector("[data-ws-orgrow=${org}]")?.innerText.replace(/\\s+/g, " ").trim() ?? ""`;
|
||
|
||
test('the bare address lands on #/dashboard without a reload and lists every organization with only the figures read', async t => {
|
||
const { b, wait, text } = await setup(t);
|
||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||
// One page load: the landing route is set in place, never by location.reload().
|
||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation")[0].type'), 'navigate');
|
||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/dashboard');
|
||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(r => r.dataset.wsOrgrow)'), ['acme', 'beta', 'gamma']);
|
||
// Agents from the levels read, projects and needs-you from the workspace read.
|
||
await wait(`${rowText('acme')}.includes("3 agents")`);
|
||
const acme = await b.evaluate(rowText('acme'));
|
||
assert.match(acme, /Acme Studio/);
|
||
assert.match(acme, /bus running/);
|
||
assert.match(acme, /2 projects/);
|
||
assert.match(acme, /1 needs you/);
|
||
assert.match(await b.evaluate(rowText('beta')), /1 agent\b/);
|
||
// gamma: down with its reason; its agent count comes from its file, the bus figures are not read.
|
||
const gamma = await b.evaluate(rowText('gamma'));
|
||
assert.match(gamma, /bus stopped/);
|
||
assert.match(gamma, /stopped by the owner/);
|
||
assert.match(gamma, /2 agents/);
|
||
assert.doesNotMatch(gamma, /\d+ projects?|\d+ needs you/);
|
||
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a")?.getAttribute("href")'), '#/orgs/beta/organization');
|
||
// A system route marks no organization in the sidebar and shows no breadcrumb.
|
||
assert.equal(await b.evaluate('document.querySelectorAll("#workspace-nav .sel, #workspace-nav [aria-current]").length'), 0);
|
||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")'), null);
|
||
// The top bar names no organization.
|
||
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
|
||
});
|
||
|
||
test('an organization opens inside the same shell with a breadcrumb, and its pages keep reading that organization', async t => {
|
||
const { b, wait, text } = await setup(t);
|
||
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||
await b.evaluate('window.__marker = 1; window.__bar = document.querySelector(".cmdbar"); window.__side = document.getElementById("workspace-nav")');
|
||
await b.evaluate('document.querySelector("[data-ws-orgrow=acme] a").click()');
|
||
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||
assert.equal(await b.evaluate('window.__bar === document.querySelector(".cmdbar") && window.__side === document.getElementById("workspace-nav")'), true);
|
||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")?.innerText.replace(/\\s+/g, " ").trim()'), 'Dashboard › Acme Studio');
|
||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
|
||
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
|
||
// The sidebar marks the open organization.
|
||
await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
|
||
|
||
// The sidebar opens another organization in place; its page reads that organization's routes.
|
||
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
|
||
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
|
||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||
// A page inside beta scopes its reads to beta.
|
||
await b.evaluate('location.hash = "#/agents"');
|
||
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
|
||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||
// The organization's control board is named as such after the organization, not as a second Dashboard.
|
||
await b.evaluate('location.hash = "#/orgs/beta/board"');
|
||
await wait('/^Dashboard\\/.+\\/Control board$/.test(document.getElementById("breadcrumbs")?.textContent ?? "")');
|
||
assert.equal(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]")?.getAttribute("href")'), '#/orgs/beta/organization');
|
||
|
||
// The breadcrumb goes back to the system dashboard, still without a reload.
|
||
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||
await wait('document.querySelector(".ws-crumb a")');
|
||
await b.evaluate('document.querySelector(".ws-crumb a").click()');
|
||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||
});
|
||
|
||
test('an old #/orgs/<id>/... link still opens that page', async t => {
|
||
const { b, wait } = await setup(t, { path: '/#/orgs/beta/agents' });
|
||
await wait('document.querySelector("[data-org-agents]")');
|
||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
|
||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||
});
|
||
|
||
test('a stopped organization says so on its page and on its agents, with the reason', async t => {
|
||
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization' });
|
||
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
|
||
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
|
||
assert.match(down, /The bus is not running/);
|
||
assert.match(down, /stopped by the owner/);
|
||
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
|
||
await wait('document.querySelector("[data-org-agents] [data-org=gamma] [data-agents-why]")');
|
||
assert.equal(await b.evaluate('document.querySelector("[data-org=gamma] [data-agents-why]").innerText.trim()'), 'Agents not read: the bus is stopped');
|
||
const agents = await b.evaluate('document.querySelector("[data-org-agents]").innerText.replace(/\\s+/g, " ")');
|
||
assert.doesNotMatch(agents, /Agents unavailable/);
|
||
});
|
||
|
||
test('at 390 px the dashboard lists the organizations without a horizontal scroll', async t => {
|
||
const { b, wait } = await setup(t, { viewport: [390, 844] });
|
||
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
|
||
});
|
||
|
||
// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the
|
||
// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for.
|
||
test('first run: the wordmark opens the first-run board, not a Not found page', async t => {
|
||
const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true });
|
||
await wait('document.querySelector("#bus-view h1")');
|
||
await b.evaluate('document.querySelector(".wordmark").click()');
|
||
// The board route empties the page view; give the router and render time to settle before checking.
|
||
await wait('location.hash !== "#/settings/appearance"');
|
||
await new Promise(r => setTimeout(r, 800));
|
||
assert.equal(await b.evaluate('location.hash'), '#/');
|
||
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false);
|
||
assert.equal(await b.evaluate('window.MosaicOrg'), undefined);
|
||
});
|
||
|
||
test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => {
|
||
const ID = '11111111-2222-4333-8444-555555555555';
|
||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||
const init = `window.__reads = []; window.__posts = []; const original = window.fetch;
|
||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
|
||
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; }
|
||
if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; }
|
||
return original(url, options); };`;
|
||
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init });
|
||
const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]';
|
||
await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`);
|
||
await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`);
|
||
await wait('window.__reads.length >= 1');
|
||
assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']);
|
||
assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/);
|
||
await b.evaluate('location.hash = "#/orgs/beta/settings/agents"');
|
||
await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")');
|
||
const seen = await b.evaluate('window.__reads.length');
|
||
// Two poll periods pass on beta's page.
|
||
await new Promise(r => setTimeout(r, 4500));
|
||
const reads = await b.evaluate('window.__reads');
|
||
assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' '));
|
||
assert.equal(reads.length, seen, 'the poll stopped at the switch');
|
||
assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), '');
|
||
assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false);
|
||
});
|
||
|
||
test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => {
|
||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||
// acme's settings POST is held until the test releases it, then answers with acme's saved values.
|
||
const init = `window.__posts = []; const original = window.fetch;
|
||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
|
||
if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
|
||
x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; }
|
||
return original(url, options); };`;
|
||
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
|
||
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
|
||
const submit = '#launch-settings-form button[type=submit]';
|
||
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`);
|
||
await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`);
|
||
await wait('typeof window.__release === "function"');
|
||
assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64));
|
||
await b.evaluate('location.hash = "#/orgs/beta/settings/limits"');
|
||
await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`);
|
||
// beta's Save is its own: not held by acme's write.
|
||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
|
||
await b.evaluate('window.__release()');
|
||
await new Promise(r => setTimeout(r, 700));
|
||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
|
||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7');
|
||
assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), '');
|
||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
|
||
});
|
||
|
||
test('a down banner does not repeat its heading as the reason', async t => {
|
||
// No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words.
|
||
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true });
|
||
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
|
||
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
|
||
assert.match(down, /The bus is not running\./);
|
||
assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down);
|
||
assert.doesNotMatch(down, /Reason:|No reason was read/);
|
||
});
|
||
|
||
test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => {
|
||
const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1';
|
||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||
// Every agent request read and write is recorded, whichever organization it names; the bus answers
|
||
// are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and
|
||
// recorded (the session gone): beta's own page reads beta's instances, which is not the chain.
|
||
const init = `window.__chain = []; const original = window.fetch;
|
||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||
if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; }
|
||
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; }
|
||
if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id');
|
||
x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; }
|
||
if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; }
|
||
return original(url, options); };`;
|
||
const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init });
|
||
await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")');
|
||
// The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page.
|
||
await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()');
|
||
await wait('window.__chain.length >= 1');
|
||
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||
await wait('location.hash === "#/orgs/beta/organization"');
|
||
// Done when the chain has launched and read the launch request back, or as soon as any of it names beta.
|
||
await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000);
|
||
const chain = await b.evaluate('window.__chain');
|
||
assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' '));
|
||
// beta's page draws nothing of acme's chain.
|
||
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
|
||
});
|
||
|
||
test('a file save still in flight at a switch: its outcome is not announced on the next organization', async t => {
|
||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||
// acme's file reads are stubbed; its save POST is held until the test releases it, then answers with a commit.
|
||
const init = `window.__posts = []; const original = window.fetch;
|
||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true, edit: true }; return ${json}; }
|
||
if (s.startsWith('/api/orgs/acme/project-file?')) { x = { path: 'docs/a.md', commit: 'a'.repeat(40), content: '# A\\n', at: new Date().toISOString() }; return ${json}; }
|
||
if (options.method === 'POST' && s === '/api/orgs/acme/project-file') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
|
||
x = { path: 'docs/a.md', commit: 'c'.repeat(40) }; return ${json}; }
|
||
return original(url, options); };`;
|
||
const { b, wait } = await setup(t, { path: `/#/orgs/acme/docs/site/${encodeURIComponent('docs/a.md')}`, init });
|
||
await wait('document.querySelector("#doc-form")');
|
||
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
|
||
await wait('document.querySelector("#doc-text")?.value === "# A\\n"');
|
||
await b.evaluate('(() => { const a = document.querySelector("#doc-text"); a.value = "# A\\n\\nmore\\n"; a.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#doc-form").requestSubmit(); })()');
|
||
await wait('typeof window.__release === "function"');
|
||
assert.equal(await b.evaluate('window.__posts[0]?.base'), 'a'.repeat(40));
|
||
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
|
||
await b.evaluate('window.__release()');
|
||
await new Promise(r => setTimeout(r, 700));
|
||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization');
|
||
assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/);
|
||
});
|