import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { compactStatePath, isCompactEnabled, maybeCompactOnStop, readLastCompactTs, resolveWindowMs, writeLastCompactTs, } from '../src/compact-on-stop.js'; import { makeMockBridge } from './hooks/_test-helpers.js'; import type { HookContext } from '@waggle/hive-mind-hooks-core'; import type { Logger } from '@waggle/hive-mind-shim-core'; import type { MockBridge } from './hooks/_test-helpers.js'; const FLAG = 'WAGGLE_HERMES_COMPACT_ON_STOP'; const WINDOW_ENV = 'WAGGLE_HERMES_COMPACT_WINDOW_MIN'; const MINUTE_MS = 60_000; function makeLogger(): Logger { return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, } as unknown as Logger; } function makeCtx(bridge: MockBridge): HookContext { return { bridge, logger: makeLogger() }; } describe('compact-on-stop unit', () => { let home: string; beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'hmher-compact-')); }); afterEach(async () => { vi.unstubAllEnvs(); await rm(home, { recursive: true, force: true }); }); // case 1 it('flag off → cleanupFrames NOT called and no state file written', async () => { vi.stubEnv(FLAG, ''); const bridge = makeMockBridge(); await maybeCompactOnStop(makeCtx(bridge), { now: () => 1_000_000, home }); expect(bridge.cleanupFrames).not.toHaveBeenCalled(); const last = await readLastCompactTs(compactStatePath(home)); expect(last).toBeUndefined(); }); // case 2 it('flag on, no prior timestamp → cleanupFrames called once and state file holds now', async () => { vi.stubEnv(FLAG, '1'); const bridge = makeMockBridge(); const now = 5_000_000; await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home }); expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1); const last = await readLastCompactTs(compactStatePath(home)); expect(last).toBe(now); }); // case 3 it('flag on, last = now - 1min, window 10min → NOT called (inside window)', async () => { vi.stubEnv(FLAG, '1'); const bridge = makeMockBridge(); const now = 10_000_000; await writeLastCompactTs(compactStatePath(home), now - 1 * MINUTE_MS); await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home, windowMs: 10 * MINUTE_MS }); expect(bridge.cleanupFrames).not.toHaveBeenCalled(); // timestamp untouched const last = await readLastCompactTs(compactStatePath(home)); expect(last).toBe(now - 1 * MINUTE_MS); }); // case 4 it('flag on, last = now - 11min, window 10min → called and timestamp updated to now', async () => { vi.stubEnv(FLAG, '1'); const bridge = makeMockBridge(); const now = 20_000_000; await writeLastCompactTs(compactStatePath(home), now - 11 * MINUTE_MS); await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home, windowMs: 10 * MINUTE_MS }); expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1); const last = await readLastCompactTs(compactStatePath(home)); expect(last).toBe(now); }); // case 5 it('flag on, cleanupFrames rejects → resolves (no throw) and state file NOT updated', async () => { vi.stubEnv(FLAG, '1'); const bridge = makeMockBridge(); bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable')); const now = 30_000_000; await expect( maybeCompactOnStop(makeCtx(bridge), { now: () => now, home }), ).resolves.toBeUndefined(); const last = await readLastCompactTs(compactStatePath(home)); expect(last).toBeUndefined(); }); // case 6 it('flag on, state-file write fails (home is a FILE) → resolves, no throw, cleanupFrames still attempted', async () => { vi.stubEnv(FLAG, '1'); const homeFile = join(home, 'home-as-file'); await writeFile(homeFile, 'not a dir'); const bridge = makeMockBridge(); const now = 40_000_000; await expect( maybeCompactOnStop(makeCtx(bridge), { now: () => now, home: homeFile }), ).resolves.toBeUndefined(); expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1); }); // case 7 it('WAGGLE_HERMES_COMPACT_WINDOW_MIN=5 honored; compactWindowMs opt overrides env', () => { vi.stubEnv(WINDOW_ENV, '5'); expect(resolveWindowMs(process.env)).toBe(5 * MINUTE_MS); // opt override wins over env expect(resolveWindowMs(process.env, 2 * MINUTE_MS)).toBe(2 * MINUTE_MS); }); it('resolveWindowMs default is 10min when env unset/invalid', () => { expect(resolveWindowMs({})).toBe(600_000); expect(resolveWindowMs({ [WINDOW_ENV]: 'garbage' })).toBe(600_000); expect(resolveWindowMs({ [WINDOW_ENV]: '0' })).toBe(600_000); expect(resolveWindowMs({ [WINDOW_ENV]: '-3' })).toBe(600_000); }); // case 8 it('isCompactEnabled truth table', () => { expect(isCompactEnabled({})).toBe(false); expect(isCompactEnabled({ [FLAG]: '' })).toBe(false); expect(isCompactEnabled({ [FLAG]: '0' })).toBe(false); expect(isCompactEnabled({ [FLAG]: 'false' })).toBe(false); expect(isCompactEnabled({ [FLAG]: 'FALSE' })).toBe(false); expect(isCompactEnabled({ [FLAG]: '1' })).toBe(true); expect(isCompactEnabled({ [FLAG]: 'true' })).toBe(true); expect(isCompactEnabled({ [FLAG]: 'yes' })).toBe(true); }); it('compactStatePath lives under the hermes dir', () => { const p = compactStatePath(home); expect(p).toBe(join(home, '.hermes', '.hive-mind-last-compact')); }); it('readLastCompactTs returns undefined on garbage content', async () => { const p = compactStatePath(home); await writeLastCompactTs(p, 123); // overwrite with garbage await writeFile(p, 'not-a-number'); expect(await readLastCompactTs(p)).toBeUndefined(); }); it('readLastCompactTs returns undefined when file is missing', async () => { expect(await readLastCompactTs(join(home, 'does-not-exist'))).toBeUndefined(); }); it('writeLastCompactTs mkdirs the parent recursively then writes', async () => { const p = compactStatePath(home); // parent .hermes does not exist yet await writeLastCompactTs(p, 777); const raw = await readFile(p, 'utf-8'); expect(raw).toBe('777'); }); });