// F3: the owner restarts the Console from Settings > Console. The real supervisor in // `mosaic console start` relaunches the Console on the same address, and the open page shows the // restart and comes back by itself, with no manual reload. A page holding an unsaved draft keeps // it and offers the reload instead of taking it. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs'; import { AGENT_MARKERS } from '../../cli/src/transport.mjs'; import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs'; import { browser } from './browser.mjs'; const REPO = new URL('../../../', import.meta.url).pathname; const sleep = ms => new Promise(r => setTimeout(r, ms)); // The page reloads on its own, so a check can land between two documents; it is retried. async function until(b, expression, ms = 30000) { const deadline = Date.now() + ms; for (;;) { try { if (await b.evaluate(`!!(${expression})`)) return; } catch { /* between documents */ } if (Date.now() > deadline) throw new Error(`timed out: ${expression}`); await sleep(50); } } // Every banner the page shows is kept in sessionStorage, which outlives the page's own reload. const WATCH = `(() => { const seen = JSON.parse(sessionStorage.getItem('f3-seen') || '[]'); new MutationObserver(() => { const box = document.getElementById('console-reconnect'); if (box && !box.hidden && seen.at(-1) !== box.textContent) { seen.push(box.textContent); sessionStorage.setItem('f3-seen', JSON.stringify(seen)); } }).observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }); return true; })()`; test('Settings > Console restarts the Console under its supervisor and the open page reconnects by itself', { timeout: 120000 }, async t => { const home = mkdtempSync(join(tmpdir(), 'f3-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-f3test/config.json') }; for (const k of AGENT_MARKERS) delete env[k]; delete env.NODE_TEST_CONTEXT; const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'f3test'], { env, encoding: 'utf8' }); assert.equal(setup.status, 0, setup.stderr); const ctx = { configDir: join(home, '.config/mosaic-f3test'), dataRoot: join(home, '.mosaic-f3test'), instance: 'f3test', env }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); const warnings = []; const run = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) }); t.after(async () => { run.stop(); await run.done; try { await consoleStop(ctx, { warn: () => {} }); } catch {} rmSync(home, { recursive: true, force: true }); }); const stateFile = join(ctx.dataRoot, 'console/console.json'); const pidNow = () => JSON.parse(readFileSync(stateFile, 'utf8')).pid; const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(1440, 900); await b.navigate(`${run.url}#/settings/console`); await until(b, 'document.getElementById("console-restart")'); const boot1 = await b.evaluate('window.MosaicConsole.config.boot'); assert.equal(typeof boot1, 'string'); assert.match(await b.evaluate('document.getElementById("console-restart-title").closest("section").textContent'), /Organization buses are separate processes and keep running/); await b.evaluate(`window.__f3 = 'first'`); await b.evaluate(WATCH); // The button asks first; Cancel restarts nothing. await b.evaluate('document.getElementById("console-restart").click()'); await until(b, 'document.getElementById("console-restart-cancel")'); await b.evaluate('document.getElementById("console-restart-cancel").click()'); await until(b, '!document.getElementById("console-restart-go") && !document.getElementById("console-restart").hidden'); assert.equal(pidNow(), run.pid); await b.evaluate('document.getElementById("console-restart").click()'); await until(b, 'document.getElementById("console-restart-go")'); await b.evaluate('document.getElementById("console-restart-go").click()'); // The page reloads itself once the new Console answers: the marker is gone, the boot is new. await until(b, `window.__f3 === undefined && window.MosaicConsole?.config?.boot && window.MosaicConsole.config.boot !== ${JSON.stringify(boot1)}`); const seen1 = JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)); assert.equal(seen1[0], 'Console restarting. This page reconnects when it is back.'); assert.equal(await b.evaluate('location.hash'), '#/settings/console'); await until(b, 'document.getElementById("console-restart")'); const pid2 = pidNow(); assert.notEqual(pid2, run.pid); assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).url, run.url); assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']); const trail = readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); assert.deepEqual(trail.map(e => [e.action, e.outcome, e.by.provider, e.boot]), [['console.restart', 'restarting', 'local-dev', boot1]]); // An unsaved draft (a reply being written) is not thrown away: the page says the Console is // back and offers the reload. The textarea stands in for the chat reply box. const boot2 = await b.evaluate('window.MosaicConsole.config.boot'); await b.evaluate(`(() => { window.__f3 = 'second'; sessionStorage.removeItem('f3-seen'); const t = document.createElement('textarea'); t.id = 'reply'; t.value = 'half a reply'; document.body.append(t); return true; })()`); await b.evaluate(WATCH); await b.evaluate('document.getElementById("console-restart").click()'); await until(b, 'document.getElementById("console-restart-go")'); await b.evaluate('document.getElementById("console-restart-go").click()'); await until(b, 'document.getElementById("console-reconnect-reload")'); assert.equal(await b.evaluate('window.__f3'), 'second'); assert.equal(await b.evaluate('document.getElementById("reply").value'), 'half a reply'); assert.deepEqual(JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)), ['Console restarting. This page reconnects when it is back.', 'The Console is back. Your unsaved draft is kept on this page. Reload now']); assert.notEqual(await b.evaluate('window.MosaicConsole.config.boot'), boot2); assert.notEqual(pidNow(), pid2); await b.evaluate('document.getElementById("console-reconnect-reload").click()'); await until(b, `window.__f3 === undefined && document.getElementById("console-restart")`); assert.equal(readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).length, 2); });