import { describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { moduleStateDir } from '@celilo/capabilities'; import { classifyModulePath } from '../module/packaging/package-rules'; import { invokeHook } from './executor'; import type { HookLogger } from './types'; /** * celilo#1000's FIRST acceptance criterion, which #1091 did not meet: * "a hook has a documented, framework-provided place to write." * * The classification landed without the surface, so `module audit` tolerated * `state/` while a hook had no way to ask where it was. Every assertion below * is about the hook LEARNING the path, never about the path's spelling. A test * that asserts `ctx.stateDir === join(root, 'state')` would pass just as well * with no surface at all, because it computes the answer it is checking. */ const silent: HookLogger = { info() {}, warn() {}, error() {}, success() {}, }; /** The workspace copy of the package every module bundles. */ const CAPABILITIES_PACKAGE = resolve(import.meta.dir, '../../../../packages/capabilities'); function scratchModule(script: string): string { const root = mkdtempSync(join(tmpdir(), 'celilo-statedir-')); Bun.write(join(root, 'hook.ts'), script); // A real module in the store carries its OWN bundled @celilo/capabilities // (modules run their bundled copy, celilo#173), and the hook jail binds the // module's tree and nothing above it. This fixture used to resolve the // import through the test runner's own tree, which worked only because hooks // ran unjailed; `--chdir` into the module root removed that accident. // Linking it here is what makes the fixture a module rather than a loose // script. mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true }); symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities')); return root; } describe('celilo#1000: a hook is TOLD where its state directory is', () => { test('the hook receives a usable stateDir it did not construct', async () => { const root = scratchModule(` import { defineHook } from '@celilo/capabilities'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; export default defineHook({ requires: [] as const, handler: async (ctx) => { // The hook builds NOTHING. It writes where it was told. writeFileSync(join(ctx.stateDir as string, 'cursor'), 'run-1'); return { received: ctx.stateDir }; }, }); `); try { const result = await invokeHook( root, 'on_install', '1.0', { script: 'hook.ts' }, {}, {}, {}, silent, ); expect(result.error).toBeUndefined(); expect(result.success).toBe(true); // The framework's own answer, not this test's guess. const expected = moduleStateDir(root); expect(result.outputs.received).toBe(expected); expect(readFileSync(join(expected, 'cursor'), 'utf-8')).toBe('run-1'); } finally { rmSync(root, { recursive: true, force: true }); } }); test('it exists before the hook runs, on a module that never wrote state', async () => { const root = scratchModule(` import { defineHook } from '@celilo/capabilities'; import { existsSync } from 'node:fs'; export default defineHook({ requires: [] as const, handler: async (ctx) => ({ existed: existsSync(ctx.stateDir as string) }), }); `); try { // Nothing has ever written here. A hook must not have to mkdir -p its // own sanctioned location. expect(existsSync(moduleStateDir(root))).toBe(false); const result = await invokeHook( root, 'on_install', '1.0', { script: 'hook.ts' }, {}, {}, {}, silent, ); expect(result.outputs.existed).toBe(true); } finally { rmSync(root, { recursive: true, force: true }); } }); test('what the hook was told to use is what the audit tolerates', () => { // The two halves of celilo#1000 agree by construction rather than by // someone remembering to keep them in step. const root = '/tmp/some-module'; const told = moduleStateDir(root); const relative = told.slice(root.length + 1); expect(classifyModulePath(`${relative}/anything.db`)).toBe('derived'); }); });