diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 22:03:19 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 22:23:39 +0900 |
| commit | 727c98c9dae516a2070eb950410314380a20c974 (patch) | |
| tree | 52aa1022c54f11770be7e4e2a324f0a8b8b8deec /packages/kernel/src/host/host.test.ts | |
| parent | e59dc11f63b1df51142259bb2c406af8c9c8c2bb (diff) | |
| download | dispatch-727c98c9dae516a2070eb950410314380a20c974.tar.gz dispatch-727c98c9dae516a2070eb950410314380a20c974.zip | |
style: switch from tabs to 2-space indentation
Diffstat (limited to 'packages/kernel/src/host/host.test.ts')
| -rw-r--r-- | packages/kernel/src/host/host.test.ts | 2438 |
1 files changed, 1219 insertions, 1219 deletions
diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts index 0067091..88e2836 100644 --- a/packages/kernel/src/host/host.test.ts +++ b/packages/kernel/src/host/host.test.ts @@ -2,1276 +2,1276 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createBus } from "../bus/bus.js"; import type { AuthContract } from "../contracts/auth.js"; import type { - ConfigAccess, - EventsEmitter, - Extension, - HostAPI, - Manifest, - ManifestContributions, - PermissionDecision, - PermissionGate, - PermissionRequest, - ScheduledJob, - SecretsAccess, - StorageNamespace, + ConfigAccess, + EventsEmitter, + Extension, + HostAPI, + Manifest, + ManifestContributions, + PermissionDecision, + PermissionGate, + PermissionRequest, + ScheduledJob, + SecretsAccess, + StorageNamespace, } from "../contracts/extension.js"; import { defineEventHook, defineFilter, defineService } from "../contracts/hooks.js"; import type { - Attributes, - ErrorAttributes, - LogDeps, - Logger, - LogRecord, - LogSink, + Attributes, + ErrorAttributes, + LogDeps, + Logger, + LogRecord, + LogSink, } from "../contracts/logging.js"; import type { ProviderContract } from "../contracts/provider.js"; import type { ToolContract } from "../contracts/tool.js"; import { createHost, type HostDeps, KERNEL_API_VERSION } from "./host.js"; interface FakeLogger extends Logger { - readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>; + readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>; } function createFakeLogger(): FakeLogger { - const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = []; - return { - logs, - debug: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "debug", message, attrs }); - } else { - logs.push({ level: "debug", message }); - } - }, - info: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "info", message, attrs }); - } else { - logs.push({ level: "info", message }); - } - }, - warn: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "warn", message, attrs }); - } else { - logs.push({ level: "warn", message }); - } - }, - error: (message: string, attrs?: ErrorAttributes) => { - if (attrs !== undefined) { - logs.push({ level: "error", message, attrs }); - } else { - logs.push({ level: "error", message }); - } - }, - child( - _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes }, - ): Logger { - return createFakeLogger(); - }, - span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span { - return { - id: "fake-span", - log: createFakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = []; + return { + logs, + debug: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "debug", message, attrs }); + } else { + logs.push({ level: "debug", message }); + } + }, + info: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "info", message, attrs }); + } else { + logs.push({ level: "info", message }); + } + }, + warn: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "warn", message, attrs }); + } else { + logs.push({ level: "warn", message }); + } + }, + error: (message: string, attrs?: ErrorAttributes) => { + if (attrs !== undefined) { + logs.push({ level: "error", message, attrs }); + } else { + logs.push({ level: "error", message }); + } + }, + child( + _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes }, + ): Logger { + return createFakeLogger(); + }, + span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span { + return { + id: "fake-span", + log: createFakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } function createFakeLogSink(): LogSink & { readonly records: LogRecord[] } { - const records: LogRecord[] = []; - return { - records, - emit: (record: LogRecord) => { - records.push(record); - }, - }; + const records: LogRecord[] = []; + return { + records, + emit: (record: LogRecord) => { + records.push(record); + }, + }; } function createFakeLogDeps(): LogDeps { - let idCounter = 0; - return { - now: () => 1000 + idCounter * 100, - newId: () => `span-${++idCounter}`, - }; + let idCounter = 0; + return { + now: () => 1000 + idCounter * 100, + newId: () => `span-${++idCounter}`, + }; } function createFakeConfig(): ConfigAccess { - return { - get: () => undefined, - getAll: () => ({}), - }; + return { + get: () => undefined, + getAll: () => ({}), + }; } function createFakeStorageFactory(): (ns: string) => StorageNamespace { - const stores = new Map<string, Map<string, string>>(); - return (ns: string) => { - let store = stores.get(ns); - if (!store) { - store = new Map(); - stores.set(ns, store); - } - const s = store; - return { - get: async (key: string) => s.get(key) ?? null, - set: async (key: string, value: string) => { - s.set(key, value); - }, - delete: async (key: string) => { - s.delete(key); - }, - has: async (key: string) => s.has(key), - keys: async () => [...s.keys()], - }; - }; + const stores = new Map<string, Map<string, string>>(); + return (ns: string) => { + let store = stores.get(ns); + if (!store) { + store = new Map(); + stores.set(ns, store); + } + const s = store; + return { + get: async (key: string) => s.get(key) ?? null, + set: async (key: string, value: string) => { + s.set(key, value); + }, + delete: async (key: string) => { + s.delete(key); + }, + has: async (key: string) => s.has(key), + keys: async () => [...s.keys()], + }; + }; } function createFakeSecrets(): SecretsAccess { - const store = new Map<string, string>(); - return { - get: async (key: string) => store.get(key) ?? null, - set: async (key: string, value: string) => { - store.set(key, value); - }, - delete: async (key: string) => { - store.delete(key); - }, - }; + const store = new Map<string, string>(); + return { + get: async (key: string) => store.get(key) ?? null, + set: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + }; } function createFakePermissions(): PermissionGate { - return { - check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({ - allowed: true, - }), - }; + return { + check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({ + allowed: true, + }), + }; } function createFakeScheduler(): { - readonly register: (job: ScheduledJob) => void; - readonly jobs: ScheduledJob[]; + readonly register: (job: ScheduledJob) => void; + readonly jobs: ScheduledJob[]; } { - const jobs: ScheduledJob[] = []; - return { - register: (job: ScheduledJob) => { - jobs.push(job); - }, - jobs, - }; + const jobs: ScheduledJob[] = []; + return { + register: (job: ScheduledJob) => { + jobs.push(job); + }, + jobs, + }; } function createFakeEvents(): EventsEmitter & { readonly emitted: unknown[] } { - const emitted: unknown[] = []; - return { - emitted, - emit: (event) => { - emitted.push(event); - }, - }; + const emitted: unknown[] = []; + return { + emitted, + emit: (event) => { + emitted.push(event); + }, + }; } function createExtension( - id: string, - opts: { - readonly dependsOn?: readonly string[]; - readonly apiVersion?: string; - readonly activate?: (host: HostAPI) => void | Promise<void>; - readonly deactivate?: () => void | Promise<void>; - readonly contributes?: ManifestContributions; - } = {}, + id: string, + opts: { + readonly dependsOn?: readonly string[]; + readonly apiVersion?: string; + readonly activate?: (host: HostAPI) => void | Promise<void>; + readonly deactivate?: () => void | Promise<void>; + readonly contributes?: ManifestContributions; + } = {}, ): Extension { - const base: Manifest = { - id, - name: id, - version: "1.0.0", - apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`, - trust: "bundled", - }; - const manifest: Manifest = - opts.dependsOn !== undefined - ? { ...base, dependsOn: opts.dependsOn } - : opts.contributes !== undefined - ? { ...base, contributes: opts.contributes } - : base; - const ext: Extension = { - manifest, - activate: opts.activate ?? (() => {}), - }; - if (opts.deactivate !== undefined) { - return { ...ext, deactivate: opts.deactivate }; - } - return ext; + const base: Manifest = { + id, + name: id, + version: "1.0.0", + apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`, + trust: "bundled", + }; + const manifest: Manifest = + opts.dependsOn !== undefined + ? { ...base, dependsOn: opts.dependsOn } + : opts.contributes !== undefined + ? { ...base, contributes: opts.contributes } + : base; + const ext: Extension = { + manifest, + activate: opts.activate ?? (() => {}), + }; + if (opts.deactivate !== undefined) { + return { ...ext, deactivate: opts.deactivate }; + } + return ext; } function createFakeTool(name: string): ToolContract { - return { - name, - description: `Tool ${name}`, - parameters: { type: "object" }, - execute: async () => ({ content: "ok" }), - }; + return { + name, + description: `Tool ${name}`, + parameters: { type: "object" }, + execute: async () => ({ content: "ok" }), + }; } function createFakeProvider(id: string): ProviderContract { - return { - id, - stream: async function* () {}, - }; + return { + id, + stream: async function* () {}, + }; } function createFakeAuth(id: string): AuthContract { - return { - id, - resolve: async () => null, - }; + return { + id, + resolve: async () => null, + }; } describe("createHost", () => { - let logger: FakeLogger; - let logSink: ReturnType<typeof createFakeLogSink>; - let logDeps: LogDeps; - let deps: HostDeps; - let scheduler: ReturnType<typeof createFakeScheduler>; - let events: ReturnType<typeof createFakeEvents>; - - beforeEach(() => { - logger = createFakeLogger(); - logSink = createFakeLogSink(); - logDeps = createFakeLogDeps(); - scheduler = createFakeScheduler(); - events = createFakeEvents(); - deps = { - logger, - config: createFakeConfig(), - storageFactory: createFakeStorageFactory(), - secrets: createFakeSecrets(), - permissions: createFakePermissions(), - scheduler, - bus: createBus(logger), - events, - logSink, - logDeps, - }; - }); - - describe("activation order", () => { - it("activates extensions in topological order", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - dependsOn: ["a"], - activate: () => { - order.push("b"); - }, - }); - const c = createExtension("c", { - dependsOn: ["b"], - activate: () => { - order.push("c"); - }, - }); - - const host = createHost([c, b, a], deps); - await host.activate(); - - expect(order).toEqual(["a", "b", "c"]); - }); - - it("activates independent extensions", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - activate: () => { - order.push("b"); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - expect(order).toHaveLength(2); - expect(order).toContain("a"); - expect(order).toContain("b"); - }); - }); - - describe("fault isolation", () => { - it("a throwing extension is isolated — others still activate", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - activate: () => { - throw new Error("boom"); - }, - }); - const c = createExtension("c", { - activate: () => { - order.push("c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - - expect(order).toEqual(["a", "c"]); - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("b"); - expect(host.getDisabled()[0]?.reason).toContain("boom"); - }); - - it("an async-rejecting extension is isolated", async () => { - const a = createExtension("a", { - activate: async () => { - throw new Error("async fail"); - }, - }); - const b = createExtension("b", { - activate: () => {}, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("a"); - }); - }); - - describe("apiVersion compatibility", () => { - it("activates compatible extensions", async () => { - const ext = createExtension("good", { - apiVersion: `^${KERNEL_API_VERSION}`, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(0); - }); - - it("disables incompatible extensions without crashing", async () => { - const ext = createExtension("bad", { - apiVersion: "^99.0.0", - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("bad"); - expect(host.getDisabled()[0]?.reason).toContain("incompatible"); - }); - - it("logs a warning for disabled extensions", async () => { - const ext = createExtension("bad", { - apiVersion: "^99.0.0", - }); - - const host = createHost([ext], deps); - await host.activate(); - - const warnings = logger.logs.filter((l) => l.level === "warn"); - expect(warnings).toHaveLength(1); - expect(warnings[0]?.message).toContain("bad"); - }); - }); - - describe("registries", () => { - it("defineTool populates the tool registry", async () => { - const tool = createFakeTool("read-file"); - const ext = createExtension("tools-fs", { - activate: (host) => { - host.defineTool(tool); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getTools().size).toBe(1); - expect(host.getTool("read-file")).toBe(tool); - }); - - it("defineProvider populates the provider registry", async () => { - const provider = createFakeProvider("anthropic"); - const ext = createExtension("provider-anthropic", { - activate: (host) => { - host.defineProvider(provider); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getProviders().size).toBe(1); - expect(host.getProvider("anthropic")).toBe(provider); - }); - - it("defineAuth populates the auth registry", async () => { - const auth = createFakeAuth("apikey"); - const ext = createExtension("auth-apikey", { - activate: (host) => { - host.defineAuth(auth); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getAuthProviders().size).toBe(1); - expect(host.getAuthProvider("apikey")).toBe(auth); - }); - - it("getService returns what an extension provided via provideService", async () => { - const handle = defineService<{ value: number }>("test/svc"); - const ext = createExtension("svc-provider", { - activate: (host) => { - host.provideService(handle, { value: 42 }); - }, - }); - const consumer = createExtension("svc-consumer", { - dependsOn: ["svc-provider"], - activate: (host) => { - const svc = host.getService(handle); - expect(svc.value).toBe(42); - }, - }); - - const host = createHost([ext, consumer], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(0); - }); - - it("multiple extensions contribute to the same registry", async () => { - const ext1 = createExtension("tools-a", { - activate: (host) => { - host.defineTool(createFakeTool("tool-a")); - }, - }); - const ext2 = createExtension("tools-b", { - activate: (host) => { - host.defineTool(createFakeTool("tool-b")); - }, - }); - - const host = createHost([ext1, ext2], deps); - await host.activate(); - - expect(host.getTools().size).toBe(2); - expect(host.getTool("tool-a")).toBeDefined(); - expect(host.getTool("tool-b")).toBeDefined(); - }); - }); - - describe("scheduler", () => { - it("collects scheduled jobs and forwards to sink", async () => { - const job: ScheduledJob = { - id: "cache-warm", - cron: "*/5 * * * *", - execute: () => {}, - }; - const ext = createExtension("scheduler-ext", { - activate: (host) => { - host.scheduler.register(job); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getScheduledJobs()).toHaveLength(1); - expect(host.getScheduledJobs()[0]).toBe(job); - expect(scheduler.jobs).toHaveLength(1); - expect(scheduler.jobs[0]).toBe(job); - }); - }); - - describe("migrations", () => { - it("collects migrations from manifests", async () => { - const ext = createExtension("store-ext", { - contributes: { migrations: ["001-init", "002-add-index"] }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]); - }); - }); - - describe("deactivation", () => { - it("deactivates in reverse activation order", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("activate-a"); - }, - deactivate: () => { - order.push("deactivate-a"); - }, - }); - const b = createExtension("b", { - activate: () => { - order.push("activate-b"); - }, - deactivate: () => { - order.push("deactivate-b"); - }, - }); - const c = createExtension("c", { - activate: () => { - order.push("activate-c"); - }, - deactivate: () => { - order.push("deactivate-c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - await host.deactivate(); - - expect(order).toEqual([ - "activate-a", - "activate-b", - "activate-c", - "deactivate-c", - "deactivate-b", - "deactivate-a", - ]); - }); - - it("a failing deactivate does not prevent others", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => {}, - deactivate: () => { - order.push("deactivate-a"); - }, - }); - const b = createExtension("b", { - activate: () => {}, - deactivate: () => { - throw new Error("deactivate boom"); - }, - }); - const c = createExtension("c", { - activate: () => {}, - deactivate: () => { - order.push("deactivate-c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - await host.deactivate(); - - expect(order).toEqual(["deactivate-c", "deactivate-a"]); - const errors = logger.logs.filter((l) => l.level === "error"); - expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true); - expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true); - }); - }); - - describe("HostAPI delegation", () => { - it("on/addFilter delegate to the bus", async () => { - const hook = defineEventHook<string>("test/host-event"); - const received: string[] = []; - - const ext = createExtension("hook-ext", { - activate: (host) => { - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - deps.bus.emit(hook, "hello"); - expect(received).toEqual(["hello"]); - }); - - it("emit dispatches to handlers registered via on", async () => { - const hook = defineEventHook<string>("test/emit-dispatch"); - const received: string[] = []; - - const ext = createExtension("emit-ext", { - activate: (host) => { - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - api.emit(hook, "world"); - expect(received).toEqual(["world"]); - }); - - it("emit isolates a throwing handler (does not propagate)", async () => { - const hook = defineEventHook<string>("test/emit-isolation"); - const received: string[] = []; - - const ext = createExtension("emit-isolation-ext", { - activate: (host) => { - host.on(hook, () => { - throw new Error("handler boom"); - }); - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.emit(hook, "safe")).not.toThrow(); - expect(received).toEqual(["safe"]); - }); - - it("applyFilters threads a value through registered filters in order", async () => { - const hook = defineFilter<string>("test/text-transform"); - - const ext = createExtension("filter-ext", { - activate: (host) => { - host.addFilter(hook, (value) => `${value}-first`); - host.addFilter(hook, (value) => `${value}-second`); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "start"); - expect(result).toBe("start-first-second"); - }); - - it("applyFilters returns the input unchanged when no filters are registered", async () => { - const hook = defineFilter<string>("test/unused-filter"); - - const ext = createExtension("no-filter-ext", { - activate: () => {}, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "unchanged"); - expect(result).toBe("unchanged"); - }); - - it("storage delegates to the factory", async () => { - let storageResult: StorageNamespace | undefined; - - const ext = createExtension("storage-ext", { - activate: (host) => { - storageResult = host.storage("my-ns"); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(storageResult).toBeDefined(); - await storageResult?.set("key", "value"); - expect(await storageResult?.get("key")).toBe("value"); - }); - - it("events delegates to the emitter", async () => { - const ext = createExtension("event-ext", { - activate: (host) => { - host.events.emit({ type: "custom", data: 42 }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(events.emitted).toHaveLength(1); - expect(events.emitted[0]).toEqual({ type: "custom", data: 42 }); - }); - }); - - describe("HostAPI registry access", () => { - it("getTools returns registered tools via HostAPI", async () => { - const tool = createFakeTool("read-file"); - let capturedTools: ReadonlyMap<string, ToolContract> | undefined; - - const producer = createExtension("tools-fs", { - activate: (host) => { - host.defineTool(tool); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["tools-fs"], - activate: (host) => { - capturedTools = host.getTools(); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedTools).toBeDefined(); - expect(capturedTools?.size).toBe(1); - expect(capturedTools?.get("read-file")).toBe(tool); - }); - - it("getProviders returns registered providers via HostAPI", async () => { - const provider = createFakeProvider("anthropic"); - let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined; - - const producer = createExtension("provider-anthropic", { - activate: (host) => { - host.defineProvider(provider); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["provider-anthropic"], - activate: (host) => { - capturedProviders = host.getProviders(); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedProviders).toBeDefined(); - expect(capturedProviders?.size).toBe(1); - expect(capturedProviders?.get("anthropic")).toBe(provider); - }); - - it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => { - const auth = createFakeAuth("apikey"); - let capturedAuth: ReadonlyMap<string, AuthContract> | undefined; - let capturedSingle: AuthContract | undefined; - - const producer = createExtension("auth-apikey", { - activate: (host) => { - host.defineAuth(auth); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["auth-apikey"], - activate: (host) => { - capturedAuth = host.getAuthProviders(); - capturedSingle = host.getAuthProvider("apikey"); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedAuth).toBeDefined(); - expect(capturedAuth?.size).toBe(1); - expect(capturedAuth?.get("apikey")).toBe(auth); - expect(capturedSingle).toBe(auth); - }); - }); - - describe("getExtensions", () => { - it("returns empty array when no extensions are activated", async () => { - const host = createHost([], deps); - await host.activate(); - - expect(host.getExtensions()).toEqual([]); - }); - - it("returns manifests of all activated extensions", async () => { - const a = createExtension("ext-a"); - const b = createExtension("ext-b"); - - const host = createHost([a, b], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(2); - expect(exts.map((e) => e.id)).toContain("ext-a"); - expect(exts.map((e) => e.id)).toContain("ext-b"); - }); - - it("returns manifests in activation order", async () => { - const a = createExtension("a"); - const b = createExtension("b", { dependsOn: ["a"] }); - const c = createExtension("c", { dependsOn: ["b"] }); - - const host = createHost([c, b, a], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); - }); - - it("excludes extensions that failed to activate", async () => { - const a = createExtension("good"); - const b = createExtension("bad", { - activate: () => { - throw new Error("boom"); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(1); - expect(exts[0]?.id).toBe("good"); - }); - - it("excludes extensions disabled by apiVersion incompatibility", async () => { - const good = createExtension("good"); - const bad = createExtension("bad", { apiVersion: "^99.0.0" }); - - const host = createHost([good, bad], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(1); - expect(exts[0]?.id).toBe("good"); - }); - - it("returns a frozen array", async () => { - const ext = createExtension("ext"); - const host = createHost([ext], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(Object.isFrozen(exts)).toBe(true); - }); - - it("HostAPI getExtensions reflects activated extensions after full activation", async () => { - const a = createExtension("ext-a"); - const b = createExtension("ext-b", { - dependsOn: ["ext-a"], - activate: () => {}, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - // Use getHostAPI() to verify the post-activation view - const api = host.getHostAPI(); - const capturedExtsAfter = api.getExtensions(); - - expect(capturedExtsAfter).toHaveLength(2); - expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); - }); - - it("HostAPI getExtensions during activation sees only previously activated", async () => { - const seenDuringActivation: string[][] = []; - - const a = createExtension("a", { - activate: (host) => { - seenDuringActivation.push(host.getExtensions().map((e) => e.id)); - }, - }); - const b = createExtension("b", { - activate: (host) => { - seenDuringActivation.push(host.getExtensions().map((e) => e.id)); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - // When a activates, activated[] is empty (a hasn't been pushed yet) - // When b activates, activated[] has [a] (b hasn't been pushed yet) - expect(seenDuringActivation).toEqual([[], ["a"]]); - }); - }); - - describe("DAG errors", () => { - it("throws on missing dependency", () => { - const ext = createExtension("a", { dependsOn: ["missing"] }); - expect(() => createHost([ext], deps)).toThrow(/not available/); - }); - - it("throws on dependency cycle", () => { - const a = createExtension("a", { dependsOn: ["b"] }); - const b = createExtension("b", { dependsOn: ["a"] }); - expect(() => createHost([a, b], deps)).toThrow(/cycle/i); - }); - }); - - describe("empty host", () => { - it("works with no extensions", async () => { - const host = createHost([], deps); - await host.activate(); - - expect(host.getTools().size).toBe(0); - expect(host.getProviders().size).toBe(0); - expect(host.getAuthProviders().size).toBe(0); - expect(host.getScheduledJobs()).toHaveLength(0); - expect(host.getMigrations()).toHaveLength(0); - expect(host.getDisabled()).toHaveLength(0); - }); - }); - - describe("getHostAPI", () => { - it("returns a HostAPI whose read-views reflect registrations from activation", async () => { - const tool = createFakeTool("read-file"); - const provider = createFakeProvider("anthropic"); - const auth = createFakeAuth("apikey"); - - const ext = createExtension("multi-ext", { - activate: (host) => { - host.defineTool(tool); - host.defineProvider(provider); - host.defineAuth(auth); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - - expect(api.getTools().size).toBe(1); - expect(api.getTools().get("read-file")).toBe(tool); - - expect(api.getProviders().size).toBe(1); - expect(api.getProviders().get("anthropic")).toBe(provider); - - expect(api.getAuthProviders().size).toBe(1); - expect(api.getAuthProvider("apikey")).toBe(auth); - }); - - it("throws on defineTool after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineTool(createFakeTool("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("throws on defineProvider after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineProvider(createFakeProvider("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("throws on defineAuth after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineAuth(createFakeAuth("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("applyFilters is available on registration-closed HostAPI", async () => { - const hook = defineFilter<string>("test/closed-filter"); - - const ext = createExtension("filter-ext", { - activate: (host) => { - host.addFilter(hook, (value) => `${value}-filtered`); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "input"); - expect(result).toBe("input-filtered"); - }); - }); - - describe("auto-scoped logger (D6)", () => { - it("each extension's logger stamps its own manifest.id as extensionId", async () => { - let extALogger: Logger | undefined; - let extBLogger: Logger | undefined; - - const a = createExtension("ext-a", { - activate: (host) => { - extALogger = host.logger; - }, - }); - const b = createExtension("ext-b", { - activate: (host) => { - extBLogger = host.logger; - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - extALogger?.info("from-a"); - extBLogger?.info("from-b"); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(2); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].extensionId).toBe("ext-a"); - expect(logRecords[0].msg).toBe("from-a"); - } - if (logRecords[1]?.kind === "log") { - expect(logRecords[1].extensionId).toBe("ext-b"); - expect(logRecords[1].msg).toBe("from-b"); - } - }); - - it("an extension cannot spoof extensionId — it is auto-stamped", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("real-id", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - // child() cannot override extensionId - const child = extLogger?.child({ extensionId: "spoofed" }); - child?.info("msg"); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(1); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].extensionId).toBe("real-id"); - } - }); - - it("host.logger.error uses structured { err } shape", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - extLogger?.error("something broke", { err: new Error("boom") }); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(1); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].level).toBe("error"); - expect(logRecords[0].msg).toBe("something broke"); - expect(logRecords[0].attributes?.["error.message"]).toBe("boom"); - } - }); - - it("a throwing sink does NOT break the caller", async () => { - const brokenSink: LogSink = { - emit() { - throw new Error("sink down"); - }, - }; - const brokenDeps: HostDeps = { - ...deps, - logSink: brokenSink, - }; - - let extLogger: Logger | undefined; - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], brokenDeps); - await host.activate(); - - // Should not throw - expect(() => extLogger?.info("msg")).not.toThrow(); - }); - - it("span() + end() emit incremental span-open and span-close records", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("my-span", { key: "value" }); - span?.setAttributes({ extra: "attr" }); - span?.end({ attrs: { result: "ok" } }); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - - expect(spanOpens).toHaveLength(1); - expect(spanCloses).toHaveLength(1); - - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].name).toBe("my-span"); - expect(spanOpens[0].extensionId).toBe("ext"); - expect(spanOpens[0].attributes?.key).toBe("value"); - } - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].name).toBe("my-span"); - expect(spanCloses[0].status).toBe("ok"); - expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0); - expect(spanCloses[0].attributes?.extra).toBe("attr"); - expect(spanCloses[0].attributes?.result).toBe("ok"); - } - }); - - it("span() with body emits body on span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}'); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - expect(spanOpens).toHaveLength(1); - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].body).toBe('{"payload":"hello"}'); - } - }); - - it("span() without body omits body field on span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("no-body"); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - expect(spanOpens).toHaveLength(1); - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].body).toBeUndefined(); - } - }); - - it("child() with body emits body on child span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("parent"); - const child = span?.child("child-name", { k: "v" }, '{"child":"body"}'); - child?.end(); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name"); - expect(childOpen).toBeDefined(); - if (childOpen?.kind === "span-open") { - expect(childOpen.body).toBe('{"child":"body"}'); - } - }); - - it("end() with body emits body on span-close record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("close-body"); - span?.end({ body: '{"result":"data"}' }); - - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - expect(spanCloses).toHaveLength(1); - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].body).toBe('{"result":"data"}'); - } - }); - - it("end() without body omits body field on span-close record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("no-close-body"); - span?.end(); - - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - expect(spanCloses).toHaveLength(1); - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].body).toBeUndefined(); - } - }); - }); + let logger: FakeLogger; + let logSink: ReturnType<typeof createFakeLogSink>; + let logDeps: LogDeps; + let deps: HostDeps; + let scheduler: ReturnType<typeof createFakeScheduler>; + let events: ReturnType<typeof createFakeEvents>; + + beforeEach(() => { + logger = createFakeLogger(); + logSink = createFakeLogSink(); + logDeps = createFakeLogDeps(); + scheduler = createFakeScheduler(); + events = createFakeEvents(); + deps = { + logger, + config: createFakeConfig(), + storageFactory: createFakeStorageFactory(), + secrets: createFakeSecrets(), + permissions: createFakePermissions(), + scheduler, + bus: createBus(logger), + events, + logSink, + logDeps, + }; + }); + + describe("activation order", () => { + it("activates extensions in topological order", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + dependsOn: ["a"], + activate: () => { + order.push("b"); + }, + }); + const c = createExtension("c", { + dependsOn: ["b"], + activate: () => { + order.push("c"); + }, + }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + expect(order).toEqual(["a", "b", "c"]); + }); + + it("activates independent extensions", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + activate: () => { + order.push("b"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + expect(order).toHaveLength(2); + expect(order).toContain("a"); + expect(order).toContain("b"); + }); + }); + + describe("fault isolation", () => { + it("a throwing extension is isolated — others still activate", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + activate: () => { + throw new Error("boom"); + }, + }); + const c = createExtension("c", { + activate: () => { + order.push("c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + + expect(order).toEqual(["a", "c"]); + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("b"); + expect(host.getDisabled()[0]?.reason).toContain("boom"); + }); + + it("an async-rejecting extension is isolated", async () => { + const a = createExtension("a", { + activate: async () => { + throw new Error("async fail"); + }, + }); + const b = createExtension("b", { + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("a"); + }); + }); + + describe("apiVersion compatibility", () => { + it("activates compatible extensions", async () => { + const ext = createExtension("good", { + apiVersion: `^${KERNEL_API_VERSION}`, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(0); + }); + + it("disables incompatible extensions without crashing", async () => { + const ext = createExtension("bad", { + apiVersion: "^99.0.0", + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("bad"); + expect(host.getDisabled()[0]?.reason).toContain("incompatible"); + }); + + it("logs a warning for disabled extensions", async () => { + const ext = createExtension("bad", { + apiVersion: "^99.0.0", + }); + + const host = createHost([ext], deps); + await host.activate(); + + const warnings = logger.logs.filter((l) => l.level === "warn"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.message).toContain("bad"); + }); + }); + + describe("registries", () => { + it("defineTool populates the tool registry", async () => { + const tool = createFakeTool("read-file"); + const ext = createExtension("tools-fs", { + activate: (host) => { + host.defineTool(tool); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getTools().size).toBe(1); + expect(host.getTool("read-file")).toBe(tool); + }); + + it("defineProvider populates the provider registry", async () => { + const provider = createFakeProvider("anthropic"); + const ext = createExtension("provider-anthropic", { + activate: (host) => { + host.defineProvider(provider); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getProviders().size).toBe(1); + expect(host.getProvider("anthropic")).toBe(provider); + }); + + it("defineAuth populates the auth registry", async () => { + const auth = createFakeAuth("apikey"); + const ext = createExtension("auth-apikey", { + activate: (host) => { + host.defineAuth(auth); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getAuthProviders().size).toBe(1); + expect(host.getAuthProvider("apikey")).toBe(auth); + }); + + it("getService returns what an extension provided via provideService", async () => { + const handle = defineService<{ value: number }>("test/svc"); + const ext = createExtension("svc-provider", { + activate: (host) => { + host.provideService(handle, { value: 42 }); + }, + }); + const consumer = createExtension("svc-consumer", { + dependsOn: ["svc-provider"], + activate: (host) => { + const svc = host.getService(handle); + expect(svc.value).toBe(42); + }, + }); + + const host = createHost([ext, consumer], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(0); + }); + + it("multiple extensions contribute to the same registry", async () => { + const ext1 = createExtension("tools-a", { + activate: (host) => { + host.defineTool(createFakeTool("tool-a")); + }, + }); + const ext2 = createExtension("tools-b", { + activate: (host) => { + host.defineTool(createFakeTool("tool-b")); + }, + }); + + const host = createHost([ext1, ext2], deps); + await host.activate(); + + expect(host.getTools().size).toBe(2); + expect(host.getTool("tool-a")).toBeDefined(); + expect(host.getTool("tool-b")).toBeDefined(); + }); + }); + + describe("scheduler", () => { + it("collects scheduled jobs and forwards to sink", async () => { + const job: ScheduledJob = { + id: "cache-warm", + cron: "*/5 * * * *", + execute: () => {}, + }; + const ext = createExtension("scheduler-ext", { + activate: (host) => { + host.scheduler.register(job); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getScheduledJobs()).toHaveLength(1); + expect(host.getScheduledJobs()[0]).toBe(job); + expect(scheduler.jobs).toHaveLength(1); + expect(scheduler.jobs[0]).toBe(job); + }); + }); + + describe("migrations", () => { + it("collects migrations from manifests", async () => { + const ext = createExtension("store-ext", { + contributes: { migrations: ["001-init", "002-add-index"] }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]); + }); + }); + + describe("deactivation", () => { + it("deactivates in reverse activation order", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("activate-a"); + }, + deactivate: () => { + order.push("deactivate-a"); + }, + }); + const b = createExtension("b", { + activate: () => { + order.push("activate-b"); + }, + deactivate: () => { + order.push("deactivate-b"); + }, + }); + const c = createExtension("c", { + activate: () => { + order.push("activate-c"); + }, + deactivate: () => { + order.push("deactivate-c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + await host.deactivate(); + + expect(order).toEqual([ + "activate-a", + "activate-b", + "activate-c", + "deactivate-c", + "deactivate-b", + "deactivate-a", + ]); + }); + + it("a failing deactivate does not prevent others", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => {}, + deactivate: () => { + order.push("deactivate-a"); + }, + }); + const b = createExtension("b", { + activate: () => {}, + deactivate: () => { + throw new Error("deactivate boom"); + }, + }); + const c = createExtension("c", { + activate: () => {}, + deactivate: () => { + order.push("deactivate-c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + await host.deactivate(); + + expect(order).toEqual(["deactivate-c", "deactivate-a"]); + const errors = logger.logs.filter((l) => l.level === "error"); + expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true); + expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true); + }); + }); + + describe("HostAPI delegation", () => { + it("on/addFilter delegate to the bus", async () => { + const hook = defineEventHook<string>("test/host-event"); + const received: string[] = []; + + const ext = createExtension("hook-ext", { + activate: (host) => { + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + deps.bus.emit(hook, "hello"); + expect(received).toEqual(["hello"]); + }); + + it("emit dispatches to handlers registered via on", async () => { + const hook = defineEventHook<string>("test/emit-dispatch"); + const received: string[] = []; + + const ext = createExtension("emit-ext", { + activate: (host) => { + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + api.emit(hook, "world"); + expect(received).toEqual(["world"]); + }); + + it("emit isolates a throwing handler (does not propagate)", async () => { + const hook = defineEventHook<string>("test/emit-isolation"); + const received: string[] = []; + + const ext = createExtension("emit-isolation-ext", { + activate: (host) => { + host.on(hook, () => { + throw new Error("handler boom"); + }); + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.emit(hook, "safe")).not.toThrow(); + expect(received).toEqual(["safe"]); + }); + + it("applyFilters threads a value through registered filters in order", async () => { + const hook = defineFilter<string>("test/text-transform"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-first`); + host.addFilter(hook, (value) => `${value}-second`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "start"); + expect(result).toBe("start-first-second"); + }); + + it("applyFilters returns the input unchanged when no filters are registered", async () => { + const hook = defineFilter<string>("test/unused-filter"); + + const ext = createExtension("no-filter-ext", { + activate: () => {}, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "unchanged"); + expect(result).toBe("unchanged"); + }); + + it("storage delegates to the factory", async () => { + let storageResult: StorageNamespace | undefined; + + const ext = createExtension("storage-ext", { + activate: (host) => { + storageResult = host.storage("my-ns"); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(storageResult).toBeDefined(); + await storageResult?.set("key", "value"); + expect(await storageResult?.get("key")).toBe("value"); + }); + + it("events delegates to the emitter", async () => { + const ext = createExtension("event-ext", { + activate: (host) => { + host.events.emit({ type: "custom", data: 42 }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(events.emitted).toHaveLength(1); + expect(events.emitted[0]).toEqual({ type: "custom", data: 42 }); + }); + }); + + describe("HostAPI registry access", () => { + it("getTools returns registered tools via HostAPI", async () => { + const tool = createFakeTool("read-file"); + let capturedTools: ReadonlyMap<string, ToolContract> | undefined; + + const producer = createExtension("tools-fs", { + activate: (host) => { + host.defineTool(tool); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["tools-fs"], + activate: (host) => { + capturedTools = host.getTools(); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedTools).toBeDefined(); + expect(capturedTools?.size).toBe(1); + expect(capturedTools?.get("read-file")).toBe(tool); + }); + + it("getProviders returns registered providers via HostAPI", async () => { + const provider = createFakeProvider("anthropic"); + let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined; + + const producer = createExtension("provider-anthropic", { + activate: (host) => { + host.defineProvider(provider); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["provider-anthropic"], + activate: (host) => { + capturedProviders = host.getProviders(); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedProviders).toBeDefined(); + expect(capturedProviders?.size).toBe(1); + expect(capturedProviders?.get("anthropic")).toBe(provider); + }); + + it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => { + const auth = createFakeAuth("apikey"); + let capturedAuth: ReadonlyMap<string, AuthContract> | undefined; + let capturedSingle: AuthContract | undefined; + + const producer = createExtension("auth-apikey", { + activate: (host) => { + host.defineAuth(auth); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["auth-apikey"], + activate: (host) => { + capturedAuth = host.getAuthProviders(); + capturedSingle = host.getAuthProvider("apikey"); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedAuth).toBeDefined(); + expect(capturedAuth?.size).toBe(1); + expect(capturedAuth?.get("apikey")).toBe(auth); + expect(capturedSingle).toBe(auth); + }); + }); + + describe("getExtensions", () => { + it("returns empty array when no extensions are activated", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getExtensions()).toEqual([]); + }); + + it("returns manifests of all activated extensions", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b"); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(2); + expect(exts.map((e) => e.id)).toContain("ext-a"); + expect(exts.map((e) => e.id)).toContain("ext-b"); + }); + + it("returns manifests in activation order", async () => { + const a = createExtension("a"); + const b = createExtension("b", { dependsOn: ["a"] }); + const c = createExtension("c", { dependsOn: ["b"] }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); + }); + + it("excludes extensions that failed to activate", async () => { + const a = createExtension("good"); + const b = createExtension("bad", { + activate: () => { + throw new Error("boom"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("excludes extensions disabled by apiVersion incompatibility", async () => { + const good = createExtension("good"); + const bad = createExtension("bad", { apiVersion: "^99.0.0" }); + + const host = createHost([good, bad], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("returns a frozen array", async () => { + const ext = createExtension("ext"); + const host = createHost([ext], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(Object.isFrozen(exts)).toBe(true); + }); + + it("HostAPI getExtensions reflects activated extensions after full activation", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b", { + dependsOn: ["ext-a"], + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // Use getHostAPI() to verify the post-activation view + const api = host.getHostAPI(); + const capturedExtsAfter = api.getExtensions(); + + expect(capturedExtsAfter).toHaveLength(2); + expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); + }); + + it("HostAPI getExtensions during activation sees only previously activated", async () => { + const seenDuringActivation: string[][] = []; + + const a = createExtension("a", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + const b = createExtension("b", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // When a activates, activated[] is empty (a hasn't been pushed yet) + // When b activates, activated[] has [a] (b hasn't been pushed yet) + expect(seenDuringActivation).toEqual([[], ["a"]]); + }); + }); + + describe("DAG errors", () => { + it("throws on missing dependency", () => { + const ext = createExtension("a", { dependsOn: ["missing"] }); + expect(() => createHost([ext], deps)).toThrow(/not available/); + }); + + it("throws on dependency cycle", () => { + const a = createExtension("a", { dependsOn: ["b"] }); + const b = createExtension("b", { dependsOn: ["a"] }); + expect(() => createHost([a, b], deps)).toThrow(/cycle/i); + }); + }); + + describe("empty host", () => { + it("works with no extensions", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getTools().size).toBe(0); + expect(host.getProviders().size).toBe(0); + expect(host.getAuthProviders().size).toBe(0); + expect(host.getScheduledJobs()).toHaveLength(0); + expect(host.getMigrations()).toHaveLength(0); + expect(host.getDisabled()).toHaveLength(0); + }); + }); + + describe("getHostAPI", () => { + it("returns a HostAPI whose read-views reflect registrations from activation", async () => { + const tool = createFakeTool("read-file"); + const provider = createFakeProvider("anthropic"); + const auth = createFakeAuth("apikey"); + + const ext = createExtension("multi-ext", { + activate: (host) => { + host.defineTool(tool); + host.defineProvider(provider); + host.defineAuth(auth); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + + expect(api.getTools().size).toBe(1); + expect(api.getTools().get("read-file")).toBe(tool); + + expect(api.getProviders().size).toBe(1); + expect(api.getProviders().get("anthropic")).toBe(provider); + + expect(api.getAuthProviders().size).toBe(1); + expect(api.getAuthProvider("apikey")).toBe(auth); + }); + + it("throws on defineTool after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineTool(createFakeTool("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("throws on defineProvider after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineProvider(createFakeProvider("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("throws on defineAuth after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineAuth(createFakeAuth("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("applyFilters is available on registration-closed HostAPI", async () => { + const hook = defineFilter<string>("test/closed-filter"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-filtered`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "input"); + expect(result).toBe("input-filtered"); + }); + }); + + describe("auto-scoped logger (D6)", () => { + it("each extension's logger stamps its own manifest.id as extensionId", async () => { + let extALogger: Logger | undefined; + let extBLogger: Logger | undefined; + + const a = createExtension("ext-a", { + activate: (host) => { + extALogger = host.logger; + }, + }); + const b = createExtension("ext-b", { + activate: (host) => { + extBLogger = host.logger; + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + extALogger?.info("from-a"); + extBLogger?.info("from-b"); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(2); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].extensionId).toBe("ext-a"); + expect(logRecords[0].msg).toBe("from-a"); + } + if (logRecords[1]?.kind === "log") { + expect(logRecords[1].extensionId).toBe("ext-b"); + expect(logRecords[1].msg).toBe("from-b"); + } + }); + + it("an extension cannot spoof extensionId — it is auto-stamped", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("real-id", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + // child() cannot override extensionId + const child = extLogger?.child({ extensionId: "spoofed" }); + child?.info("msg"); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(1); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].extensionId).toBe("real-id"); + } + }); + + it("host.logger.error uses structured { err } shape", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + extLogger?.error("something broke", { err: new Error("boom") }); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(1); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].level).toBe("error"); + expect(logRecords[0].msg).toBe("something broke"); + expect(logRecords[0].attributes?.["error.message"]).toBe("boom"); + } + }); + + it("a throwing sink does NOT break the caller", async () => { + const brokenSink: LogSink = { + emit() { + throw new Error("sink down"); + }, + }; + const brokenDeps: HostDeps = { + ...deps, + logSink: brokenSink, + }; + + let extLogger: Logger | undefined; + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], brokenDeps); + await host.activate(); + + // Should not throw + expect(() => extLogger?.info("msg")).not.toThrow(); + }); + + it("span() + end() emit incremental span-open and span-close records", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("my-span", { key: "value" }); + span?.setAttributes({ extra: "attr" }); + span?.end({ attrs: { result: "ok" } }); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + + expect(spanOpens).toHaveLength(1); + expect(spanCloses).toHaveLength(1); + + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].name).toBe("my-span"); + expect(spanOpens[0].extensionId).toBe("ext"); + expect(spanOpens[0].attributes?.key).toBe("value"); + } + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].name).toBe("my-span"); + expect(spanCloses[0].status).toBe("ok"); + expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0); + expect(spanCloses[0].attributes?.extra).toBe("attr"); + expect(spanCloses[0].attributes?.result).toBe("ok"); + } + }); + + it("span() with body emits body on span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}'); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + expect(spanOpens).toHaveLength(1); + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].body).toBe('{"payload":"hello"}'); + } + }); + + it("span() without body omits body field on span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("no-body"); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + expect(spanOpens).toHaveLength(1); + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].body).toBeUndefined(); + } + }); + + it("child() with body emits body on child span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("parent"); + const child = span?.child("child-name", { k: "v" }, '{"child":"body"}'); + child?.end(); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name"); + expect(childOpen).toBeDefined(); + if (childOpen?.kind === "span-open") { + expect(childOpen.body).toBe('{"child":"body"}'); + } + }); + + it("end() with body emits body on span-close record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("close-body"); + span?.end({ body: '{"result":"data"}' }); + + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + expect(spanCloses).toHaveLength(1); + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].body).toBe('{"result":"data"}'); + } + }); + + it("end() without body omits body field on span-close record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("no-close-body"); + span?.end(); + + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + expect(spanCloses).toHaveLength(1); + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].body).toBeUndefined(); + } + }); + }); }); |
