tags: [testing, browser, node, scope, syntax, pre-commit, hooks, studio, debugging] related:
- tools/hooks/check-js-syntax.js
- packages/studio/public/studio.js
- .git/hooks/pre-commit status: current —
024 — Your Test Suite Doesn’t Test Your Browser Code
The test suite passed. 1505 tests, all green. The CODEMAP check passed. The contracts sync passed. The commit went through.
The Studio UI was completely broken. Every button was dead. Templates weren’t loading. Nothing worked.
The cause: a function called uid was declared twice in the same file.
What Happened
studio.js already had this near the top:
const uid = () => Math.random().toString(36).slice(2, 8);
New code added at the bottom of the file redeclared it:
function uid() { return Math.random().toString(36).slice(2, 9); }
In Node.js with CommonJS modules, this is fine. Each module has its own scope. The second uid would be local to the bottom of the file and shadow the first. Odd, but not fatal.
In a browser script tag, there is no module boundary. The entire file executes in one shared global scope. Declaring const uid and then later declaring function uid in the same scope is a SyntaxError. The browser throws it before executing a single line of the script. Not a runtime error mid-execution — a parse error that prevents any JavaScript from running at all.
Every button. Every event handler. Every Alpine.js component. All dead, before they could ever fire.
Why the Tests Didn’t Catch It
The test suite runs in Node. Vitest uses CommonJS or ESM modules depending on configuration. Either way, the file is a module — it has its own scope.
studio.js is not a module. It’s a browser script, loaded with a plain <script> tag. It has no import/export. It relies on global variables shared across tags in the same page. It is, by design, not a module.
The test suite never runs studio.js. There are no unit tests for it — it’s a UI controller, and unit testing browser UI controllers against a Node module system is not straightforward. The tests cover the server (endpoints, generators, YAML sync) and the framework (platform-api, core, NLP). The browser JavaScript that wires it all together in the UI lives outside the test boundary.
This is not a gap in the tests. It’s the nature of the deployment target. Node modules and browser scripts are different execution environments. A function that is safe in one may be a fatal error in the other.
The Fix That Already Existed
node --check performs parse-only validation of a JavaScript file. No execution — just the syntax check. It runs in milliseconds and catches every category of parse error, including this one:
$ node --check packages/studio/public/studio.js
SyntaxError: Identifier 'uid' has already been declared
at internalCompileFunction...
It runs studio.js in Node’s module system for parsing purposes, which means it catches global-scope redeclaration errors the same way a browser would, because the rule is the same: you cannot declare the same identifier twice in the same scope.
One command. Catches the exact failure. Already ships with Node.
The pre-commit hook now runs it on every staged .js file:
// tools/hooks/check-js-syntax.js
const staged = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' })
.split('\n').filter(f => f.endsWith('.js'));
for (const file of staged) {
const r = spawnSync(process.execPath, ['--check', file]);
if (r.status !== 0) { /* block commit */ }
}
The Broader Category
The uid collision is one instance of a broader category: things that are safe in Node that are unsafe in a browser script.
Others in the same category:
Global variable leaks. A variable declared without const/let/var in a module is local. In a browser script it’s a property on window. result = something in a script tag silently pollutes the global scope. Node’s module wrapper catches this with strict mode; the browser does not.
Multiple script files sharing globals. Two scripts loaded on the same page share window. A const in one script and a const in another with the same name is the same error. A large Alpine.js application loaded across several script files can hit this on any shared utility name.
const vs var in the same scope. A var declaration and a const declaration with the same name in the same scope is a SyntaxError in both environments. But a var declared twice is silently fine in both. The behaviour differs enough between declaration types to catch developers who mix them.
Load order dependencies. A function defined in script B that calls a function from script A fails if B loads before A. Node’s require makes dependencies explicit and order-safe. Browser script tags are ordered by position in the HTML, and getting it wrong fails at runtime.
None of these show up in a Node test suite. They show up in the browser.
The General Principle
Every deployment target has its own execution model. Code that is valid in one model may be invalid in another. The test suite validates behaviour in the test model. It does not validate behaviour in every deployment model.
For server code, the test model and the deployment model are the same: Node. Tests are reliable.
For browser code served as script tags, the test model (Node module system) and the deployment model (browser global scope) differ in ways that matter. Tests cover behaviour; the deployment model has additional constraints the tests don’t enforce.
The fix is not more tests — it’s a syntax gate that runs in the actual parse context. node --check is not a perfect proxy for the browser parser, but it catches the category of error that killed Studio: global scope redeclarations. That’s enough.
In an agentic workflow, the AI writes and commits code. It cannot open the browser and check the console. The pre-commit hook is the mechanism that catches what the AI cannot see.