/** * Tests for the `defineHook` and `defineCapabilityFunction` helpers * exported from `@celilo/capabilities`. These exercise both the * runtime wiring (brand symbols, metadata, handler invocation) and the * TypeScript inference (via `expectTypeOf`-style patterns that are * compile-time checks the test file performs on the types). * * The type-level checks are load-bearing: if `defineHook` stops inferring * `capabilities.public_web` as `PublicWebCapability`, this file fails to * compile. That's the regression signal we care about. */ import { describe, expect, test } from 'bun:test'; import { CELILO_CAPABILITY_FACTORY_BRAND, CELILO_HOOK_BRAND, type CreateOidcClientRequest, type CreateOidcClientResult, type CreateUserRequest, type CreateUserResult, type HookContext, type HookLogger, type HookResult, type IdpCapability, type PublicWebCapability, type RegisterHostRequest, type RevokeOidcClientRequest, defineCapabilityFunction, defineHook, isCompiledCapabilityFactory, isCompiledHook, } from '@celilo/capabilities'; import { configStore, secretStore } from './test-fixtures/store-backed'; function makeLogger(): HookLogger { return { info: () => undefined, warn: () => undefined, error: () => undefined, success: () => undefined, }; } function makeContext(overrides: Partial = {}): HookContext { return { config: configStore(), secrets: secretStore(), systems: [], logger: makeLogger(), consumerModuleId: 'test-consumer', debug: false, screenshotDir: '', stateDir: '', capabilities: {}, ...overrides, }; } // Minimal fakes for the capability interfaces — used in both consumer and // provider tests below. const fakePublicWeb: PublicWebCapability = { defaultHostname: 'www.example.com', async publishStaticSite() { return { success: true, path: '/test', filesUploaded: 0, contentHash: 'fake' }; }, async registerReverseProxy() { return { success: true, path: '/test' }; }, async register_route() { return { success: true, caddyPath: '/srv/www/test' }; }, async upload_static_assets() { return { success: true, filesUploaded: 0, contentHash: 'fake' }; }, async getServerIp() { return '10.0.10.10'; }, }; const fakeIdp: IdpCapability = { async create_oidc_client() { return { client_id: 'fake-id', client_secret: 'fake-secret', provider_id: 1, application_slug: 'fake', }; }, async revoke_oidc_client() { return undefined; }, async create_user() { return { user_id: 1, created: true }; }, async create_token() { return { token: 'fake-token', created: true }; }, async list_tokens() { return []; }, async revoke_token() { return { revoked: true }; }, }; describe('defineHook', () => { test('attaches the hook brand symbol so the executor can detect it', () => { const hook = defineHook({ requires: [] as const, handler: async () => undefined, }); expect(Reflect.get(hook, CELILO_HOOK_BRAND)).toBe(true); expect(isCompiledHook(hook)).toBe(true); expect(isCompiledHook(() => undefined)).toBe(false); }); test('preserves the requires array at runtime for pre-flight checks', () => { const hook = defineHook({ requires: ['public_web', 'idp'] as const, handler: async () => undefined, }); expect(hook.requires).toEqual(['public_web', 'idp']); }); test('preserves optional capabilities at runtime as a separate array', () => { const hook = defineHook({ requires: ['public_web'] as const, optional: ['idp'] as const, handler: async () => undefined, }); expect(hook.requires).toEqual(['public_web']); expect(hook.optional).toEqual(['idp']); }); test('defaults optional to [] when omitted', () => { const hook = defineHook({ requires: ['public_web'] as const, handler: async () => undefined, }); expect(hook.optional).toEqual([]); }); test('invokes the handler with the raw hook context', async () => { let seenConfig: Record | undefined; const hook = defineHook({ requires: [] as const, handler: async ({ config }) => { seenConfig = config; }, }); await hook(makeContext({ config: configStore({ foo: 'bar' }) })); expect(seenConfig).toEqual({ foo: 'bar' }); }); test('passes typed capabilities through to the handler', async () => { const ctx = makeContext({ capabilities: { public_web: fakePublicWeb, idp: fakeIdp }, }); let captured: Awaited> | undefined; const hook = defineHook({ requires: ['public_web', 'idp'] as const, handler: async ({ capabilities }) => { // Both should be non-null at the type level because they're in // `requires`. The cast-free access below is the test. captured = await capabilities.public_web.register_route({ type: 'static', path: '/test', }); await capabilities.idp.create_oidc_client({ client_name: 'test', redirect_uris: ['http://localhost/cb'], }); }, }); await hook(ctx); expect(captured).toEqual({ success: true, caddyPath: '/srv/www/test' }); }); test('returns the handler output for typed hooks (on_backup)', async () => { const hook = defineHook({ hook: 'on_backup', requires: [], handler: async () => ({ artifact_count: 5, size_bytes: 12345, schema_version: '1.0.0', }), }); const result = await hook(makeContext()); expect(result).toEqual({ artifact_count: 5, size_bytes: 12345, schema_version: '1.0.0', }); }); test('void hooks (on_install) need no return statement', async () => { let ran = false; const hook = defineHook({ hook: 'on_install', requires: [], handler: async () => { ran = true; }, }); const result = await hook(makeContext()); expect(ran).toBe(true); expect(result).toBeUndefined(); }); test('on_restore typed return enforces { restored_items }', async () => { const hook = defineHook({ hook: 'on_restore', requires: [], handler: async () => ({ restored_items: 42 }), }); const result = await hook(makeContext()); expect(result).toEqual({ restored_items: 42 }); }); test('hooks without a hook field still work (legacy void default)', async () => { let ran = false; const hook = defineHook({ requires: [], handler: async () => { ran = true; }, }); await hook(makeContext()); expect(ran).toBe(true); }); }); describe('defineCapabilityFunction', () => { test('attaches the capability factory brand symbol', () => { const factory = defineCapabilityFunction({ capability: 'idp', handler: () => ({ async create_oidc_client() { return { client_id: 'x', client_secret: 'y', provider_id: 1, application_slug: 'x', }; }, async revoke_oidc_client() { return undefined; }, async create_user() { return { user_id: 1, created: true }; }, async create_token() { return { token: 'fake-token', created: true }; }, async list_tokens() { return []; }, async revoke_token() { return { revoked: true }; }, }), }); expect(Reflect.get(factory, CELILO_CAPABILITY_FACTORY_BRAND)).toBe(true); expect(isCompiledCapabilityFactory(factory)).toBe(true); expect(isCompiledCapabilityFactory(() => undefined)).toBe(false); }); test('preserves the capability name at runtime', () => { const factory = defineCapabilityFunction({ capability: 'public_web', handler: () => ({ defaultHostname: 'www.example.com', async publishStaticSite() { return { success: true, path: '/x', filesUploaded: 0, contentHash: 'x' }; }, async registerReverseProxy() { return { success: true, path: '/x' }; }, async register_route() { return { success: true }; }, async upload_static_assets() { return { success: true, filesUploaded: 0, contentHash: 'x' }; }, async getServerIp() { return '10.0.10.10'; }, }), }); expect(factory.capability).toBe('public_web'); }); test('invokes the handler with the factory context and returns the method table', () => { const factory = defineCapabilityFunction({ capability: 'idp', handler: ({ config, secrets }) => { expect(config).toEqual({ target_ip: '10.0.0.5' }); expect(secrets.token).toBe('abc'); return { async create_oidc_client( request: CreateOidcClientRequest, ): Promise { return { client_id: `id-${request.client_name}`, client_secret: 'secret', provider_id: 1, application_slug: request.client_name, }; }, async revoke_oidc_client(_request: RevokeOidcClientRequest): Promise { return undefined; }, async create_user(_request: CreateUserRequest): Promise { return { user_id: 1, created: true }; }, async create_token(): Promise<{ token: string; created: boolean }> { return { token: 'tok', created: true }; }, async list_tokens() { return []; }, async revoke_token(): Promise<{ revoked: boolean }> { return { revoked: true }; }, }; }, }); const methods = factory({ config: { target_ip: '10.0.0.5' }, secrets: { token: 'abc' }, systems: [], logger: makeLogger(), consumerModuleId: 'test-consumer', }); expect(typeof methods.create_oidc_client).toBe('function'); expect(typeof methods.revoke_oidc_client).toBe('function'); expect(typeof methods.create_user).toBe('function'); }); test('the returned method table implements the declared capability', async () => { const factory = defineCapabilityFunction({ capability: 'dns_registrar', handler: () => ({ async registerHost(request: RegisterHostRequest): Promise { return { success: true, outputs: { registered_fqdn: request.fqdn }, duration: 1, }; }, }), }); const methods = factory({ config: {}, secrets: {}, systems: [], logger: makeLogger(), consumerModuleId: 'test-consumer', }); const result = await methods.registerHost({ fqdn: 'www.example.com' }); expect(result.success).toBe(true); expect(result.outputs.registered_fqdn).toBe('www.example.com'); }); test('works for the firewall capability', async () => { const factory = defineCapabilityFunction({ capability: 'firewall', handler: () => ({ async exposeService(request) { return { externalIp: '203.0.113.1', natIp: request.internalIp }; }, async unexposeService() { return undefined; }, async listExposedServices() { return []; }, }), }); const methods = factory({ config: {}, secrets: {}, systems: [], logger: makeLogger(), consumerModuleId: 'test-consumer', }); const result = await methods.exposeService({ internalIp: '10.0.0.10', ports: [80, 443], description: 'test', }); expect(result.externalIp).toBe('203.0.113.1'); expect(result.natIp).toBe('10.0.0.10'); }); }); // -------------------------------------------------------------------- // Compile-time type checks. // // These aren't runtime assertions — they're TypeScript inference tests // that fail at build time if the helper's generics stop doing their job. // The `_` names and `void` usage ensure the compiler checks the types // without keeping dead runtime code. // -------------------------------------------------------------------- // Check 1: `defineHook` with `requires: ['public_web']` exposes // `capabilities.public_web` as PublicWebCapability (non-null). void defineHook({ requires: ['public_web'] as const, handler: async ({ capabilities }) => { // No null-check needed; if this compiles, the type is non-null. await capabilities.public_web.register_route({ type: 'static', path: '/x', }); }, }); // Check 2: `optional` capabilities are typed as possibly undefined. void defineHook({ requires: [] as const, optional: ['idp'] as const, handler: async ({ capabilities }) => { // Must null-check because `idp` is optional. if (capabilities.idp) { await capabilities.idp.create_oidc_client({ client_name: 'x', redirect_uris: [], }); } }, }); // Check 3: `defineCapabilityFunction` return type must match // `CapabilityRegistry['idp']`. Omitting a method should be a TS error. // (We don't write the "should fail" test here — it can't compile. Instead // this is the positive case showing the inference works.) void defineCapabilityFunction({ capability: 'idp', handler: () => ({ async create_oidc_client() { return { client_id: 'x', client_secret: 'x', provider_id: 1, application_slug: 'x', }; }, async revoke_oidc_client() { return undefined; }, async create_user() { return { user_id: 1, created: true }; }, async create_token() { return { token: 'x', created: true }; }, async list_tokens() { return []; }, async revoke_token() { return { revoked: true }; }, }), }); // Check 4 (HOOK_API_V2 D7 / Phase 7): `hook: 'on_install'` allows the // handler to omit a return statement — the inferred output is `void`. // `hook` doesn't need `as const` thanks to the `const` generic on // defineHook. void defineHook({ hook: 'on_install', requires: [] as const, handler: async () => { // No return — passes because HookOutput<'on_install'> = void }, }); // Check 5: `hook: 'on_backup'` requires the handler to return the // contract's BackupHookOutput shape. Missing or wrong-typed fields // would be a TS error at this call site. void defineHook({ hook: 'on_backup', requires: [] as const, handler: async () => { return { artifact_count: 5, size_bytes: 12345, schema_version: '1.0.0', }; }, }); // Check 6: `hook: 'on_restore'` requires `{ restored_items: number }`. void defineHook({ hook: 'on_restore', requires: [] as const, handler: async () => { return { restored_items: 42 }; }, }); // Check 7: hooks WITHOUT a `hook` field still typecheck and default to // `void` return — the legacy backwards-compat path stays open. void defineHook({ requires: ['public_web'] as const, handler: async ({ capabilities }) => { await capabilities.public_web.register_route({ type: 'static', path: '/legacy', }); // No return needed }, });