codeburn/tests/cli-node-env.test.ts
Giovannini Barbosa 720824e619 fix(cli): default NODE_ENV to production so Ink's React build stops leaking user-timing entries.
React, loaded by Ink, picks its development build unless NODE_ENV is
exactly "production". Since React 19.2 that build records a
performance.measure() entry on every render (its DevTools "Scheduler"
and "Components" tracks), and Node keeps user-timing marks and measures
in a process-wide buffer that nothing ever trims. The interactive
dashboard re-renders on every progress tick and refresh, so the buffer
grows by roughly 700 entries a minute; after a few days the heap hits
V8's 4 GB default and the process aborts with "Ineffective mark-compacts
near heap limit ... JavaScript heap out of memory".

The launcher (dist/cli.js, the entry every client spawns) now defaults
NODE_ENV to "production" before importing main.js, which selects the
build that records nothing; an explicit NODE_ENV still wins. For that
explicit case, a small guard drops the user-timing buffer every 30 s for
the lifetime of each Ink UI (dashboard, sessions, context, compare).
codeburn records no marks or measures of its own, so nothing is lost.

Tests spawn the real launcher against a stub main.js to pin the default,
and drive a ticking Ink app in a child Node process to show the
development build's backlog, the production build's absence of one, and
the guard keeping it bounded.
2026-09-07 10:27:46 -03:00

54 lines
2.1 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// The launcher (src/cli.ts, copied verbatim to dist/cli.js) is the only place
// that runs before React and Ink are imported. React picks its development
// build unless NODE_ENV is exactly "production", and that build records a
// performance.measure() entry on every render into Node's user-timing buffer,
// which nothing ever trims: an interactive dashboard left open for days grows
// until V8 aborts with "JavaScript heap out of memory". The launcher must
// default NODE_ENV to "production" while leaving an explicit value untouched.
let dir: string
function runLauncher(env: Record<string, string | undefined>): string {
const base = { ...process.env }
delete base['NODE_ENV']
const result = spawnSync(process.execPath, [join(dir, 'cli.js')], {
cwd: dir,
encoding: 'utf8',
env: { ...base, ...env },
})
expect(result.status, result.stderr).toBe(0)
return result.stdout.trim()
}
describe('CLI launcher NODE_ENV default', () => {
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'codeburn-cli-node-env-'))
copyFileSync(join(process.cwd(), 'src', 'cli.ts'), join(dir, 'cli.js'))
// Stand-in for dist/main.js: report the environment the launcher handed us.
writeFileSync(join(dir, 'main.js'), 'process.stdout.write(JSON.stringify(process.env.NODE_ENV ?? null))\n')
writeFileSync(join(dir, 'package.json'), '{"type":"module"}\n')
})
afterAll(() => {
rmSync(dir, { recursive: true, force: true })
})
it('defaults NODE_ENV to production when the shell leaves it unset', () => {
expect(runLauncher({})).toBe('"production"')
})
it('treats an empty NODE_ENV as unset', () => {
expect(runLauncher({ NODE_ENV: '' })).toBe('"production"')
})
it('keeps an explicit NODE_ENV', () => {
expect(runLauncher({ NODE_ENV: 'development' })).toBe('"development"')
})
})