import { randomUUID } from "node:crypto"; import type { PaperclipPluginManifestV1, PluginCapability, PluginEventType, Company, Project, Issue, IssueComment, Agent, Goal, } from "@paperclipai/shared"; import type { EventFilter, PluginContext, PluginEntityRecord, PluginEntityUpsert, PluginJobContext, PluginLauncherRegistration, PluginEvent, ScopeKey, ToolResult, ToolRunContext, PluginWorkspace, AgentSession, AgentSessionEvent, } from "./types.js"; export interface TestHarnessOptions { /** Plugin manifest used to seed capability checks and metadata. */ manifest: PaperclipPluginManifestV1; /** Optional capability override. Defaults to `manifest.capabilities`. */ capabilities?: PluginCapability[]; /** Initial config returned by `ctx.config.get()`. */ config?: Record; } export interface TestHarnessLogEntry { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record; } export interface TestHarness { /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ ctx: PluginContext; /** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */ seed(input: { companies?: Company[]; projects?: Project[]; issues?: Issue[]; issueComments?: IssueComment[]; agents?: Agent[]; goals?: Goal[]; }): void; setConfig(config: Record): void; /** Dispatch a host or plugin event to registered handlers. */ emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial): Promise; /** Execute a previously-registered scheduled job handler. */ runJob(jobKey: string, partial?: Partial): Promise; /** Invoke a `ctx.data.register(...)` handler by key. */ getData(key: string, params?: Record): Promise; /** Invoke a `ctx.actions.register(...)` handler by key. */ performAction(key: string, params?: Record): Promise; /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ executeTool(name: string, params: unknown, runCtx?: Partial): Promise; /** Read raw in-memory state for assertions. */ getState(input: ScopeKey): unknown; /** Simulate a streaming event arriving for an active session. */ simulateSessionEvent(sessionId: string, event: Omit): void; logs: TestHarnessLogEntry[]; activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record }>; metrics: Array<{ name: string; value: number; tags?: Record }>; } type EventRegistration = { name: PluginEventType | `plugin.${string}`; filter?: EventFilter; fn: (event: PluginEvent) => Promise; }; function normalizeScope(input: ScopeKey): Required> & Pick { return { scopeKind: input.scopeKind, scopeId: input.scopeId, namespace: input.namespace ?? "default", stateKey: input.stateKey, }; } function stateMapKey(input: ScopeKey): string { const normalized = normalizeScope(input); return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`; } function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean { if (!filter) return true; if (filter.companyId && filter.companyId !== String((event.payload as Record | undefined)?.companyId ?? "")) return false; if (filter.projectId && filter.projectId !== String((event.payload as Record | undefined)?.projectId ?? "")) return false; if (filter.agentId && filter.agentId !== String((event.payload as Record | undefined)?.agentId ?? "")) return false; return true; } function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set, capability: PluginCapability) { if (allowed.has(capability)) return; throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`); } function requireCompanyId(companyId?: string): string { if (!companyId) throw new Error("companyId is required for this operation"); return companyId; } function isInCompany( record: T | null | undefined, companyId: string, ): record is T { return Boolean(record && record.companyId === companyId); } /** * Create an in-memory host harness for plugin worker tests. * * The harness enforces declared capabilities and simulates host APIs, so tests * can validate plugin behavior without spinning up the Paperclip server runtime. */ export function createTestHarness(options: TestHarnessOptions): TestHarness { const manifest = options.manifest; const capabilitySet = new Set(options.capabilities ?? manifest.capabilities); let currentConfig = { ...(options.config ?? {}) }; const logs: TestHarnessLogEntry[] = []; const activity: TestHarness["activity"] = []; const metrics: TestHarness["metrics"] = []; const state = new Map(); const entities = new Map(); const entityExternalIndex = new Map(); const companies = new Map(); const projects = new Map(); const issues = new Map(); const issueComments = new Map(); const agents = new Map(); const goals = new Map(); const projectWorkspaces = new Map(); const sessions = new Map(); const sessionEventCallbacks = new Map void>(); const events: EventRegistration[] = []; const jobs = new Map Promise>(); const launchers = new Map(); const dataHandlers = new Map) => Promise>(); const actionHandlers = new Map) => Promise>(); const toolHandlers = new Map Promise>(); const ctx: PluginContext = { manifest, config: { async get() { return { ...currentConfig }; }, }, events: { on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise), maybeFn?: (event: PluginEvent) => Promise): () => void { requireCapability(manifest, capabilitySet, "events.subscribe"); let registration: EventRegistration; if (typeof filterOrFn === "function") { registration = { name, fn: filterOrFn }; } else { if (!maybeFn) throw new Error("event handler is required"); registration = { name, filter: filterOrFn, fn: maybeFn }; } events.push(registration); return () => { const idx = events.indexOf(registration); if (idx !== -1) events.splice(idx, 1); }; }, async emit(name, companyId, payload) { requireCapability(manifest, capabilitySet, "events.emit"); await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId }); }, }, jobs: { register(key, fn) { requireCapability(manifest, capabilitySet, "jobs.schedule"); jobs.set(key, fn); }, }, launchers: { register(launcher) { launchers.set(launcher.id, launcher); }, }, http: { async fetch(url, init) { requireCapability(manifest, capabilitySet, "http.outbound"); return fetch(url, init); }, }, secrets: { async resolve(secretRef) { requireCapability(manifest, capabilitySet, "secrets.read-ref"); return `resolved:${secretRef}`; }, }, activity: { async log(entry) { requireCapability(manifest, capabilitySet, "activity.log.write"); activity.push(entry); }, }, state: { async get(input) { requireCapability(manifest, capabilitySet, "plugin.state.read"); return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null; }, async set(input, value) { requireCapability(manifest, capabilitySet, "plugin.state.write"); state.set(stateMapKey(input), value); }, async delete(input) { requireCapability(manifest, capabilitySet, "plugin.state.write"); state.delete(stateMapKey(input)); }, }, entities: { async upsert(input: PluginEntityUpsert) { const externalKey = input.externalId ? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}` : null; const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined; const existing = existingId ? entities.get(existingId) : undefined; const now = new Date().toISOString(); const previousExternalKey = existing?.externalId ? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}` : null; const record: PluginEntityRecord = existing ? { ...existing, entityType: input.entityType, scopeKind: input.scopeKind, scopeId: input.scopeId ?? null, externalId: input.externalId ?? null, title: input.title ?? null, status: input.status ?? null, data: input.data, updatedAt: now, } : { id: randomUUID(), entityType: input.entityType, scopeKind: input.scopeKind, scopeId: input.scopeId ?? null, externalId: input.externalId ?? null, title: input.title ?? null, status: input.status ?? null, data: input.data, createdAt: now, updatedAt: now, }; entities.set(record.id, record); if (previousExternalKey && previousExternalKey !== externalKey) { entityExternalIndex.delete(previousExternalKey); } if (externalKey) entityExternalIndex.set(externalKey, record.id); return record; }, async list(query) { let out = [...entities.values()]; if (query.entityType) out = out.filter((r) => r.entityType === query.entityType); if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind); if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId); if (query.externalId) out = out.filter((r) => r.externalId === query.externalId); if (query.offset) out = out.slice(query.offset); if (query.limit) out = out.slice(0, query.limit); return out; }, }, projects: { async list(input) { requireCapability(manifest, capabilitySet, "projects.read"); const companyId = requireCompanyId(input?.companyId); let out = [...projects.values()]; out = out.filter((project) => project.companyId === companyId); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(projectId, companyId) { requireCapability(manifest, capabilitySet, "projects.read"); const project = projects.get(projectId); return isInCompany(project, companyId) ? project : null; }, async listWorkspaces(projectId, companyId) { requireCapability(manifest, capabilitySet, "project.workspaces.read"); if (!isInCompany(projects.get(projectId), companyId)) return []; return projectWorkspaces.get(projectId) ?? []; }, async getPrimaryWorkspace(projectId, companyId) { requireCapability(manifest, capabilitySet, "project.workspaces.read"); if (!isInCompany(projects.get(projectId), companyId)) return null; const workspaces = projectWorkspaces.get(projectId) ?? []; return workspaces.find((workspace) => workspace.isPrimary) ?? null; }, async getWorkspaceForIssue(issueId, companyId) { requireCapability(manifest, capabilitySet, "project.workspaces.read"); const issue = issues.get(issueId); if (!isInCompany(issue, companyId)) return null; const projectId = (issue as unknown as Record)?.projectId as string | undefined; if (!projectId) return null; if (!isInCompany(projects.get(projectId), companyId)) return null; const workspaces = projectWorkspaces.get(projectId) ?? []; return workspaces.find((workspace) => workspace.isPrimary) ?? null; }, }, companies: { async list(input) { requireCapability(manifest, capabilitySet, "companies.read"); let out = [...companies.values()]; if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(companyId) { requireCapability(manifest, capabilitySet, "companies.read"); return companies.get(companyId) ?? null; }, }, issues: { async list(input) { requireCapability(manifest, capabilitySet, "issues.read"); const companyId = requireCompanyId(input?.companyId); let out = [...issues.values()]; out = out.filter((issue) => issue.companyId === companyId); if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId); if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId); if (input?.status) out = out.filter((issue) => issue.status === input.status); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(issueId, companyId) { requireCapability(manifest, capabilitySet, "issues.read"); const issue = issues.get(issueId); return isInCompany(issue, companyId) ? issue : null; }, async create(input) { requireCapability(manifest, capabilitySet, "issues.create"); const now = new Date(); const record: Issue = { id: randomUUID(), companyId: input.companyId, projectId: input.projectId ?? null, goalId: input.goalId ?? null, parentId: input.parentId ?? null, title: input.title, description: input.description ?? null, status: "todo", priority: input.priority ?? "medium", assigneeAgentId: input.assigneeAgentId ?? null, assigneeUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, createdByAgentId: null, createdByUserId: null, issueNumber: null, identifier: null, requestDepth: 0, billingCode: null, assigneeAdapterOverrides: null, executionWorkspaceSettings: null, startedAt: null, completedAt: null, cancelledAt: null, hiddenAt: null, createdAt: now, updatedAt: now, }; issues.set(record.id, record); return record; }, async update(issueId, patch, companyId) { requireCapability(manifest, capabilitySet, "issues.update"); const record = issues.get(issueId); if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`); const updated: Issue = { ...record, ...patch, updatedAt: new Date(), }; issues.set(issueId, updated); return updated; }, async listComments(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.comments.read"); if (!isInCompany(issues.get(issueId), companyId)) return []; return issueComments.get(issueId) ?? []; }, async createComment(issueId, body, companyId) { requireCapability(manifest, capabilitySet, "issue.comments.create"); const parentIssue = issues.get(issueId); if (!isInCompany(parentIssue, companyId)) { throw new Error(`Issue not found: ${issueId}`); } const now = new Date(); const comment: IssueComment = { id: randomUUID(), companyId: parentIssue.companyId, issueId, authorAgentId: null, authorUserId: null, body, createdAt: now, updatedAt: now, }; const current = issueComments.get(issueId) ?? []; current.push(comment); issueComments.set(issueId, current); return comment; }, documents: { async list(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); if (!isInCompany(issues.get(issueId), companyId)) return []; return []; }, async get(issueId, _key, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); if (!isInCompany(issues.get(issueId), companyId)) return null; return null; }, async upsert(input) { requireCapability(manifest, capabilitySet, "issue.documents.write"); const parentIssue = issues.get(input.issueId); if (!isInCompany(parentIssue, input.companyId)) { throw new Error(`Issue not found: ${input.issueId}`); } throw new Error("documents.upsert is not implemented in test context"); }, async delete(issueId, _key, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.write"); const parentIssue = issues.get(issueId); if (!isInCompany(parentIssue, companyId)) { throw new Error(`Issue not found: ${issueId}`); } }, }, }, agents: { async list(input) { requireCapability(manifest, capabilitySet, "agents.read"); const companyId = requireCompanyId(input?.companyId); let out = [...agents.values()]; out = out.filter((agent) => agent.companyId === companyId); if (input?.status) out = out.filter((agent) => agent.status === input.status); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(agentId, companyId) { requireCapability(manifest, capabilitySet, "agents.read"); const agent = agents.get(agentId); return isInCompany(agent, companyId) ? agent : null; }, async pause(agentId, companyId) { requireCapability(manifest, capabilitySet, "agents.pause"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent"); const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() }; agents.set(agentId, updated); return updated; }, async resume(agentId, companyId) { requireCapability(manifest, capabilitySet, "agents.resume"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent"); if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed"); const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() }; agents.set(agentId, updated); return updated; }, async invoke(agentId, companyId, opts) { requireCapability(manifest, capabilitySet, "agents.invoke"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); if ( agent!.status === "paused" || agent!.status === "terminated" || agent!.status === "pending_approval" ) { throw new Error(`Agent is not invokable in its current state: ${agent!.status}`); } return { runId: randomUUID() }; }, async updatePermissions(agentId, companyId, permissions) { requireCapability(manifest, capabilitySet, "agents.update-permissions"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); const updated: Agent = { ...agent!, permissions: { ...agent!.permissions, ...permissions }, updatedAt: new Date(), }; agents.set(agentId, updated); return updated; }, sessions: { async create(agentId, companyId, opts) { requireCapability(manifest, capabilitySet, "agent.sessions.create"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); const session: AgentSession = { sessionId: randomUUID(), agentId, companyId: cid, status: "active", createdAt: new Date().toISOString(), }; sessions.set(session.sessionId, session); return session; }, async list(agentId, companyId) { requireCapability(manifest, capabilitySet, "agent.sessions.list"); const cid = requireCompanyId(companyId); return [...sessions.values()].filter( (s) => s.agentId === agentId && s.companyId === cid && s.status === "active", ); }, async sendMessage(sessionId, companyId, opts) { requireCapability(manifest, capabilitySet, "agent.sessions.send"); const session = sessions.get(sessionId); if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`); if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); if (opts.onEvent) { sessionEventCallbacks.set(sessionId, opts.onEvent); } return { runId: randomUUID() }; }, async close(sessionId, companyId) { requireCapability(manifest, capabilitySet, "agent.sessions.close"); const session = sessions.get(sessionId); if (!session) throw new Error(`Session not found: ${sessionId}`); if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); session.status = "closed"; sessionEventCallbacks.delete(sessionId); }, }, }, goals: { async list(input) { requireCapability(manifest, capabilitySet, "goals.read"); const companyId = requireCompanyId(input?.companyId); let out = [...goals.values()]; out = out.filter((goal) => goal.companyId === companyId); if (input?.level) out = out.filter((goal) => goal.level === input.level); if (input?.status) out = out.filter((goal) => goal.status === input.status); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(goalId, companyId) { requireCapability(manifest, capabilitySet, "goals.read"); const goal = goals.get(goalId); return isInCompany(goal, companyId) ? goal : null; }, async create(input) { requireCapability(manifest, capabilitySet, "goals.create"); const now = new Date(); const record: Goal = { id: randomUUID(), companyId: input.companyId, title: input.title, description: input.description ?? null, level: input.level ?? "task", status: input.status ?? "planned", parentId: input.parentId ?? null, ownerAgentId: input.ownerAgentId ?? null, createdAt: now, updatedAt: now, }; goals.set(record.id, record); return record; }, async update(goalId, patch, companyId) { requireCapability(manifest, capabilitySet, "goals.update"); const record = goals.get(goalId); if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`); const updated: Goal = { ...record, ...patch, updatedAt: new Date(), }; goals.set(goalId, updated); return updated; }, }, data: { register(key, handler) { dataHandlers.set(key, handler); }, }, actions: { register(key, handler) { actionHandlers.set(key, handler); }, }, streams: (() => { const channelCompanyMap = new Map(); return { open(channel: string, companyId: string) { channelCompanyMap.set(channel, companyId); }, emit(_channel: string, _event: unknown) { // No-op in test harness — events are not forwarded }, close(channel: string) { channelCompanyMap.delete(channel); }, }; })(), tools: { register(name, _decl, fn) { requireCapability(manifest, capabilitySet, "agent.tools.register"); toolHandlers.set(name, fn); }, }, metrics: { async write(name, value, tags) { requireCapability(manifest, capabilitySet, "metrics.write"); metrics.push({ name, value, tags }); }, }, logger: { info(message, meta) { logs.push({ level: "info", message, meta }); }, warn(message, meta) { logs.push({ level: "warn", message, meta }); }, error(message, meta) { logs.push({ level: "error", message, meta }); }, debug(message, meta) { logs.push({ level: "debug", message, meta }); }, }, }; const harness: TestHarness = { ctx, seed(input) { for (const row of input.companies ?? []) companies.set(row.id, row); for (const row of input.projects ?? []) projects.set(row.id, row); for (const row of input.issues ?? []) issues.set(row.id, row); for (const row of input.issueComments ?? []) { const list = issueComments.get(row.issueId) ?? []; list.push(row); issueComments.set(row.issueId, list); } for (const row of input.agents ?? []) agents.set(row.id, row); for (const row of input.goals ?? []) goals.set(row.id, row); }, setConfig(config) { currentConfig = { ...config }; }, async emit(eventType, payload, base) { const event: PluginEvent = { eventId: base?.eventId ?? randomUUID(), eventType, companyId: base?.companyId ?? "test-company", occurredAt: base?.occurredAt ?? new Date().toISOString(), actorId: base?.actorId, actorType: base?.actorType, entityId: base?.entityId, entityType: base?.entityType, payload, }; for (const handler of events) { const exactMatch = handler.name === event.eventType; const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin."); const wildcardPluginOne = String(handler.name).endsWith(".*") && String(event.eventType).startsWith(String(handler.name).slice(0, -1)); if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue; if (!allowsEvent(handler.filter, event)) continue; await handler.fn(event); } }, async runJob(jobKey, partial = {}) { const handler = jobs.get(jobKey); if (!handler) throw new Error(`No job handler registered for '${jobKey}'`); await handler({ jobKey, runId: partial.runId ?? randomUUID(), trigger: partial.trigger ?? "manual", scheduledAt: partial.scheduledAt ?? new Date().toISOString(), }); }, async getData(key: string, params: Record = {}) { const handler = dataHandlers.get(key); if (!handler) throw new Error(`No data handler registered for '${key}'`); return await handler(params) as T; }, async performAction(key: string, params: Record = {}) { const handler = actionHandlers.get(key); if (!handler) throw new Error(`No action handler registered for '${key}'`); return await handler(params) as T; }, async executeTool(name: string, params: unknown, runCtx: Partial = {}) { const handler = toolHandlers.get(name); if (!handler) throw new Error(`No tool handler registered for '${name}'`); const ctxToPass: ToolRunContext = { agentId: runCtx.agentId ?? "agent-test", runId: runCtx.runId ?? randomUUID(), companyId: runCtx.companyId ?? "company-test", projectId: runCtx.projectId ?? "project-test", }; return await handler(params, ctxToPass) as T; }, getState(input) { return state.get(stateMapKey(input)); }, simulateSessionEvent(sessionId, event) { const cb = sessionEventCallbacks.get(sessionId); if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`); cb({ ...event, sessionId }); }, logs, activity, metrics, }; return harness; }