Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ These exist on `devsuitup/switchboard` main but not on `doctly/switchboard` main

- `node:test` runner via `npm test` / `task test`.
- Renderer tests use jsdom via `test/dom-setup.js` + `vm.runInContext` to evaluate `public/*.js` in isolation.
- `public/app.js` cannot be evaluated whole. To test one of its functions, load the shipped source with `loadAppFunctions` from `test/app-source.js` into the window `setupSidebarDom()` returns, and stub only its outside edges. Never copy the function into the test.
- Pitfall: `installSpies: false` is required when the eval defines functions you also spy on — function declarations from eval overwrite property spies.
- Always test in the **primary checkout** (`C:\Serveur\switchboard` on this machine), not inside `.claude/worktrees/agent-*`. Worktrees may have incomplete `node_modules` and produce false negatives on tests that require native modules (e.g. `morphdom`).

Expand Down
111 changes: 111 additions & 0 deletions test/app-source.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use strict';

// Loads functions out of public/app.js into a jsdom window's VM context.
// app.js cannot be evaluated whole (module scope builds real panels and
// terminals), so a test names the top-level functions and one-line
// declarations it needs and gets the shipped source of exactly those.

const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');

const APP_PATH = path.join(__dirname, '..', 'public', 'app.js');

function readAppSource() {
return fs.readFileSync(APP_PATH, 'utf8');
}

function skipString(src, i) {
const quote = src[i];
i++;
while (i < src.length) {
const c = src[i];
if (c === '\\') { i += 2; continue; }
if (quote === '`' && c === '$' && src[i + 1] === '{') {
i = skipBalanced(src, i + 1, '{', '}') + 1;
continue;
}
if (c === quote) return i;
i++;
}
throw new Error('unterminated string literal in app.js');
}

function skipRegex(src, start) {
let i = start + 1;
let inClass = false;
while (i < src.length) {
const c = src[i];
if (c === '\n') break;
if (c === '\\') { i += 2; continue; }
if (c === '[') inClass = true;
else if (c === ']') inClass = false;
else if (c === '/' && !inClass) return i;
i++;
}
throw new Error('unterminated regular expression literal in app.js');
}

function skipBalanced(src, open, openCh, closeCh) {
let depth = 0;
let prev = '';
for (let i = open; i < src.length; i++) {
const c = src[i];
if (c === "'" || c === '"' || c === '`') { i = skipString(src, i); prev = c; continue; }
if (c === '/' && src[i + 1] === '/') { i = src.indexOf('\n', i); if (i === -1) break; continue; }
if (c === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i) + 1; continue; }
if (c === '/' && (/[(,=:[!&|?{};]/.test(prev) || /\b(?:return|typeof|case|throw|void|delete|in|of)\s*$/.test(src.slice(Math.max(0, i - 10), i)))) {
i = skipRegex(src, i);
prev = '/';
continue;
}
if (c === openCh) depth++;
else if (c === closeCh) { depth--; if (depth === 0) return i; }
if (!/\s/.test(c)) prev = c;
}
throw new Error(`unbalanced ${openCh}${closeCh} in app.js`);
}

function extractFunction(src, name) {
const re = new RegExp(`^(async )?function ${name}\\(`, 'm');
const m = re.exec(src);
if (!m) throw new Error(`public/app.js must define a top-level function ${name}`);
const paramsOpen = m.index + m[0].length - 1;
const paramsClose = skipBalanced(src, paramsOpen, '(', ')');
const bodyOpen = src.indexOf('{', paramsClose);
const bodyClose = skipBalanced(src, bodyOpen, '{', '}');
const slice = src.slice(m.index, bodyClose + 1);
const next = src[bodyClose + 1];
if (next !== undefined && next !== '\n' && next !== '\r') {
throw new Error(`extraction of ${name} from app.js ended mid-line; the brace scanner lost track`);
}
try {
new vm.Script(slice);
} catch (err) {
throw new Error(`extraction of ${name} from app.js does not compile: ${err.message}`);
}
return slice;
}

function extractDeclaration(src, name) {
const re = new RegExp(`^(let|const) ${name}\\b[^\\n]*;[ \\t]*$`, 'm');
const m = re.exec(src);
if (!m) throw new Error(`public/app.js must declare ${name} on a single line`);
return m[0];
}

// Evaluates the named declarations and functions of public/app.js in `context`
// (a jsdom VM context) and returns the functions by name.
function loadAppFunctions(context, { functions, declarations = [] }) {
const src = readAppSource();
const parts = [
...declarations.map((n) => extractDeclaration(src, n)),
...functions.map((n) => extractFunction(src, n)),
];
vm.runInContext(parts.join('\n'), context, { filename: APP_PATH });
const out = {};
for (const n of functions) out[n] = vm.runInContext(n, context);
return out;
}

module.exports = { loadAppFunctions, extractFunction, extractDeclaration, readAppSource };
48 changes: 48 additions & 0 deletions test/app-source.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { extractFunction, extractDeclaration } = require('./app-source');

test('extractFunction: braces inside strings, templates, comments and regexes do not end the body', () => {
const src = [
'function a(x = { k: 1 }) {',
" const s = '}' + \"}\" + `${ {a: 1}.a }}`; // }",
' /* } */',
' return /}/.test(s);',
'}',
'function b() {}',
].join('\n');
assert.equal(extractFunction(src, 'a'), src.split('\nfunction b')[0]);
});

test('extractFunction: a slash inside a regex class does not end the regex', () => {
const src = 'function a() {\n return /[/]}/.test("x");\n}\nfunction b() {}';
assert.equal(extractFunction(src, 'a'), 'function a() {\n return /[/]}/.test("x");\n}');
});

test('extractFunction: a regex the scanner mistakes for a division is refused, not silently truncated', () => {
const src = 'function a(x) {\n if (x) /}/.test("y");\n return 1;\n}\n';
assert.throws(() => extractFunction(src, 'a'), /extraction of a /);
});

test('extractFunction: an unterminated regex throws instead of looping', () => {
assert.throws(() => extractFunction('function a() {\n return /abc;\n}\n', 'a'), /unterminated regular expression/);
});

test('extractFunction: a body that does not close throws', () => {
assert.throws(() => extractFunction('function a() {\n return 1;\n', 'a'), /unbalanced/);
});

test('extractFunction: a slice that ends mid-line is refused and names the function', () => {
assert.throws(() => extractFunction('function a() { return 1; } trailing();\n', 'a'), /extraction of a .*mid-line/);
});

test('extractFunction: a missing function is reported by name', () => {
assert.throws(() => extractFunction('function a() {}\n', 'zzz'), /zzz/);
});

test('extractDeclaration: takes the single-line declaration, refuses a missing one', () => {
const src = "let x = 1;\nconst MIN = 3;\n";
assert.equal(extractDeclaration(src, 'MIN'), 'const MIN = 3;');
assert.throws(() => extractDeclaration(src, 'nope'), /nope/);
});
Loading
Loading