import { mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { performHeapDump } from './memory.js' const ENV_KEYS = ['HERMES_AUTO_HEAPDUMP', 'HERMES_HEAPDUMP_DIR', 'HERMES_HEAPDUMP_MAX_BYTES'] as const describe('performHeapDump auto opt-in gate (#21767)', () => { let saved: Record let dir: string beforeEach(() => { saved = {} for (const k of ENV_KEYS) { saved[k] = process.env[k] delete process.env[k] } dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-test-')) process.env.HERMES_HEAPDUMP_DIR = dir }) afterEach(() => { for (const k of ENV_KEYS) { if (saved[k] === undefined) { delete process.env[k] } else { process.env[k] = saved[k] } } rmSync(dir, { force: true, recursive: true }) }) it('writes diagnostics only for auto-high without HERMES_AUTO_HEAPDUMP', async () => { const result = await performHeapDump('auto-high') expect(result.success).toBe(true) expect(result.suppressed).toBe(true) expect(result.diagPath).toBeDefined() expect(result.heapPath).toBeUndefined() const files = readdirSync(dir) expect(files.some(f => f.endsWith('.diagnostics.json'))).toBe(true) expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false) }) it('writes diagnostics only for auto-critical without HERMES_AUTO_HEAPDUMP', async () => { const result = await performHeapDump('auto-critical') expect(result.success).toBe(true) expect(result.suppressed).toBe(true) expect(result.heapPath).toBeUndefined() const files = readdirSync(dir) expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false) }) it('writes both diagnostics and snapshot for auto-high when HERMES_AUTO_HEAPDUMP=1', async () => { process.env.HERMES_AUTO_HEAPDUMP = '1' const result = await performHeapDump('auto-high') expect(result.success).toBe(true) expect(result.suppressed).toBeUndefined() expect(result.diagPath).toBeDefined() expect(result.heapPath).toBeDefined() const files = readdirSync(dir) expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true) }) it('accepts truthy spellings (true|yes|on, case-insensitive) as opt-in', async () => { for (const value of ['true', 'YES', 'On']) { process.env.HERMES_AUTO_HEAPDUMP = value const result = await performHeapDump('auto-high') expect(result.success).toBe(true) expect(result.heapPath).toBeDefined() } }) it('treats other values (0, off, garbage) as opt-out for auto triggers', async () => { for (const value of ['0', 'off', 'nope']) { process.env.HERMES_AUTO_HEAPDUMP = value const result = await performHeapDump('auto-high') expect(result.success).toBe(true) expect(result.suppressed).toBe(true) expect(result.heapPath).toBeUndefined() } }) it('writes both for manual triggers regardless of HERMES_AUTO_HEAPDUMP', async () => { const result = await performHeapDump('manual') expect(result.success).toBe(true) expect(result.suppressed).toBeUndefined() expect(result.heapPath).toBeDefined() const files = readdirSync(dir) expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true) }) }) describe('heapdump retention guard (#21767)', () => { let savedDir: string | undefined let savedMax: string | undefined let dir: string beforeEach(() => { savedDir = process.env.HERMES_HEAPDUMP_DIR savedMax = process.env.HERMES_HEAPDUMP_MAX_BYTES delete process.env.HERMES_AUTO_HEAPDUMP dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-prune-')) process.env.HERMES_HEAPDUMP_DIR = dir }) afterEach(() => { if (savedDir === undefined) { delete process.env.HERMES_HEAPDUMP_DIR } else { process.env.HERMES_HEAPDUMP_DIR = savedDir } if (savedMax === undefined) { delete process.env.HERMES_HEAPDUMP_MAX_BYTES } else { process.env.HERMES_HEAPDUMP_MAX_BYTES = savedMax } rmSync(dir, { force: true, recursive: true }) }) it('evicts oldest files when total bytes exceed the cap, retaining the newest', async () => { // 4 pre-existing dumps, 1KB each, with ascending mtimes (oldest first). const blob = 'x'.repeat(1024) const now = Date.now() for (let i = 0; i < 4; i++) { const p = join(dir, `old-${i}.heapsnapshot`) writeFileSync(p, blob) const t = (now - (4 - i) * 60_000) / 1000 utimesSync(p, t, t) } // Cap at 2KB → a fresh diagnostics write should trigger a prune down to ~cap. process.env.HERMES_HEAPDUMP_MAX_BYTES = String(2 * 1024) const result = await performHeapDump('auto-high') expect(result.success).toBe(true) const remaining = readdirSync(dir) const totalBytes = remaining.reduce((acc, f) => acc + statSync(join(dir, f)).size, 0) // Contract: prune evicts oldest-first until total <= cap, but always keeps // the single newest file even if it alone exceeds the cap. So either the // total is under cap, or exactly one (newest) file remains. expect(totalBytes <= 2 * 1024 || remaining.length === 1).toBe(true) // The old 1KB dumps must have been pruned down from the original four. expect(remaining.length).toBeLessThan(5) // The brand-new diagnostics sidecar must survive the prune. expect(remaining.some(f => f.endsWith('.diagnostics.json'))).toBe(true) }) })