From f24f9fca8b9653f1b597cf6cc9b83f064a6ed8fa Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 9 Jun 2026 10:31:50 -0700 Subject: [PATCH] Refactor live browser script assembly (#235) --- scripts/test-suites.mjs | 1 + skill/scripts/live-server.mjs | 53 +++++++++-------- skill/scripts/live/browser-script-parts.mjs | 48 +++++++++++++++ tests/live-browser-script-parts.test.mjs | 66 +++++++++++++++++++++ tests/live-server.test.mjs | 14 +++++ 5 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 skill/scripts/live/browser-script-parts.mjs create mode 100644 tests/live-browser-script-parts.test.mjs diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index e4df4c9ff..56d0154eb 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -106,6 +106,7 @@ export const SUITES = { files: [ 'tests/live-accept.test.mjs', 'tests/live-accept-scrub.test.mjs', + 'tests/live-browser-script-parts.test.mjs', 'tests/live-browser-regression.test.mjs', 'tests/live-browser-session.test.mjs', 'tests/live-browser-source.test.mjs', diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 68d547026..0fc4d61ba 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -22,6 +22,12 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; +import { + assembleLiveBrowserScript, + assertLiveBrowserScriptParts, + readLiveBrowserScriptParts, + resolveLiveBrowserScriptParts, +} from './live/browser-script-parts.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; @@ -347,19 +353,18 @@ function loadBrowserScripts() { try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } } - // live-browser.js: DO NOT cache. Return the path so the /live.js handler - // can re-read on every request. Editing the browser script during iteration - // should land on the next tab reload, not require a server restart. - const sessionPath = path.join(__dirname, 'live-browser-session.js'); - const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { - if (!fs.existsSync(p)) { - process.stderr.write('Error: live browser script not found at ' + p + '\n'); - process.exit(1); - } + // Browser script parts: DO NOT cache. Return paths so the /live.js handler + // can re-read every part on each request. Editing browser code during + // iteration should land on the next tab reload, not require a server restart. + const liveScriptParts = resolveLiveBrowserScriptParts(__dirname); + try { + assertLiveBrowserScriptParts(liveScriptParts); + } catch (err) { + process.stderr.write('Error: ' + err.message + '\n'); + process.exit(1); } - return { detectScript, sessionPath, livePath }; + return { detectScript, liveScriptParts }; } function hasProjectContext() { @@ -379,7 +384,7 @@ function statOrNull(filePath) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -395,24 +400,20 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // the next tab reload. No-store headers prevent browser caching across // sessions — during iteration, a cached old script silently breaks // every subsequent session. - let sessionScript; - let liveScript; + let parts; try { - sessionScript = fs.readFileSync(sessionPath, 'utf-8'); - liveScript = fs.readFileSync(livePath, 'utf-8'); + parts = readLiveBrowserScriptParts(liveScriptParts); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Error reading live browser scripts: ' + err.message); return; } - const body = - `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + - `window.__IMPECCABLE_PORT__ = ${state.port};\n` + - // Canonical command vocabulary (values + labels + icons). live-browser.js - // builds its action picker from this instead of an inline copy. - `window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(LIVE_COMMANDS)};\n` + - sessionScript + '\n' + - liveScript; + const body = assembleLiveBrowserScript({ + token: state.token, + port: state.port, + vocabulary: LIVE_COMMANDS, + parts, + }); res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', @@ -1116,8 +1117,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, liveScriptParts } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptParts })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/skill/scripts/live/browser-script-parts.mjs b/skill/scripts/live/browser-script-parts.mjs new file mode 100644 index 000000000..f99704aff --- /dev/null +++ b/skill/scripts/live/browser-script-parts.mjs @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ + Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), + Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), +]); + +export function resolveLiveBrowserScriptParts(scriptsDir, parts = LIVE_BROWSER_SCRIPT_PARTS) { + if (!scriptsDir) throw new Error('scriptsDir is required'); + return parts.map((part, index) => ({ + ...part, + index, + path: path.join(scriptsDir, part.file), + })); +} + +export function assertLiveBrowserScriptParts(parts, exists = fs.existsSync) { + for (const part of parts) { + if (!exists(part.path)) { + throw new Error(`Live browser script part missing: ${part.name} (${part.path})`); + } + } + return parts; +} + +export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.readFileSync(filePath, 'utf-8')) { + return parts.map((part) => ({ + ...part, + source: readFile(part.path), + })); +} + +export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) { + const prelude = + `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + + `window.__IMPECCABLE_PORT__ = ${port};\n` + + // Canonical command vocabulary (values + labels + icons). live-browser.js + // builds its action picker from this instead of an inline copy. + `window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`; + + const body = parts.map((part) => { + const file = part.file || path.basename(part.path || ''); + return `// --- impeccable live script part: ${part.name} (${file}) ---\n${part.source}`; + }).join('\n'); + + return prelude + body; +} diff --git a/tests/live-browser-script-parts.test.mjs b/tests/live-browser-script-parts.test.mjs new file mode 100644 index 000000000..023da7619 --- /dev/null +++ b/tests/live-browser-script-parts.test.mjs @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + assembleLiveBrowserScript, + assertLiveBrowserScriptParts, + readLiveBrowserScriptParts, + resolveLiveBrowserScriptParts, +} from '../skill/scripts/live/browser-script-parts.mjs'; + +describe('live browser script parts', () => { + it('resolves the canonical browser script order', () => { + const parts = resolveLiveBrowserScriptParts('/repo/skill/scripts'); + + assert.deepEqual(parts.map((part) => part.name), ['session-state', 'browser-ui']); + assert.equal(parts[0].file, 'live-browser-session.js'); + assert.equal(parts[1].file, 'live-browser.js'); + assert.equal(parts[0].path, path.join('/repo/skill/scripts', 'live-browser-session.js')); + assert.equal(parts[1].path, path.join('/repo/skill/scripts', 'live-browser.js')); + }); + + it('asserts missing script parts by name', () => { + const parts = resolveLiveBrowserScriptParts('/repo/skill/scripts'); + + assert.throws( + () => assertLiveBrowserScriptParts(parts, (filePath) => !filePath.endsWith('live-browser.js')), + /Live browser script part missing: browser-ui/, + ); + }); + + it('reads each part with an injected reader', () => { + const parts = resolveLiveBrowserScriptParts('/repo/skill/scripts'); + const loaded = readLiveBrowserScriptParts(parts, (filePath) => `source:${path.basename(filePath)}`); + + assert.deepEqual(loaded.map((part) => part.source), [ + 'source:live-browser-session.js', + 'source:live-browser.js', + ]); + }); + + it('assembles prelude, session helper, and browser UI in order', () => { + const script = assembleLiveBrowserScript({ + token: 'token-a', + port: 8421, + vocabulary: [{ value: 'shape', label: 'Shape' }], + parts: [ + { name: 'session-state', file: 'live-browser-session.js', source: 'window.__SESSION_PART__ = true;' }, + { name: 'browser-ui', file: 'live-browser.js', source: 'window.__BROWSER_PART__ = true;' }, + ], + }); + + const tokenIndex = script.indexOf('window.__IMPECCABLE_TOKEN__'); + const portIndex = script.indexOf('window.__IMPECCABLE_PORT__'); + const vocabIndex = script.indexOf('window.__IMPECCABLE_VOCAB__'); + const sessionIndex = script.indexOf('window.__SESSION_PART__'); + const browserIndex = script.indexOf('window.__BROWSER_PART__'); + + assert.ok(tokenIndex !== -1); + assert.ok(tokenIndex < portIndex); + assert.ok(portIndex < vocabIndex); + assert.ok(vocabIndex < sessionIndex); + assert.ok(sessionIndex < browserIndex); + assert.match(script, /impeccable live script part: session-state \(live-browser-session\.js\)/); + assert.match(script, /impeccable live script part: browser-ui \(live-browser\.js\)/); + }); +}); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 57380ab7b..278ef2032 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -228,10 +228,24 @@ describe('live-server integration', () => { assert.ok(text.includes('__IMPECCABLE_TOKEN__')); assert.ok(text.includes(server.token)); assert.ok(text.includes('__IMPECCABLE_PORT__')); + const preludeIndex = text.indexOf('window.__IMPECCABLE_VOCAB__'); + const sessionPartIndex = text.indexOf('impeccable live script part: session-state (live-browser-session.js)'); + const browserPartIndex = text.indexOf('impeccable live script part: browser-ui (live-browser.js)'); const sessionHelperIndex = text.indexOf('__IMPECCABLE_LIVE_SESSION__'); const browserInitIndex = text.indexOf('__IMPECCABLE_LIVE_INIT__'); + assert.ok(preludeIndex !== -1); + assert.ok(sessionPartIndex !== -1); + assert.ok(browserPartIndex !== -1); assert.ok(sessionHelperIndex !== -1); assert.ok(browserInitIndex !== -1); + assert.ok( + preludeIndex < sessionPartIndex, + 'event=live_server.browser_script_order actor=browser operation=load_live_js risk=prelude_after_script_part expected=prelude before parts actual=' + preludeIndex + ':' + sessionPartIndex, + ); + assert.ok( + sessionPartIndex < browserPartIndex, + 'event=live_server.browser_script_order actor=browser operation=load_live_js risk=browser_part_before_session_helper expected=session part before browser part actual=' + sessionPartIndex + ':' + browserPartIndex, + ); assert.ok( sessionHelperIndex < browserInitIndex, 'event=live_server.browser_helper_order actor=browser operation=load_live_js risk=session_helper_missing_before_browser_init expected=session helper before live init actual=' + sessionHelperIndex + ':' + browserInitIndex,