Refactor live browser script assembly (#235)

This commit is contained in:
Paul Bakaus
2026-06-09 19:31:50 +02:00
committed by GitHub
parent f636bd065a
commit f24f9fca8b
5 changed files with 156 additions and 26 deletions
+1
View File
@@ -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',
+27 -26
View File
@@ -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 });
@@ -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;
}
+66
View File
@@ -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\)/);
});
});
+14
View File
@@ -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,