import type { ColineAppV2, ColineFileRenderContextV2, ColineHomeRenderContextV2, ColineScheduleContextV2, ColineToolResult, } from "../define/types"; import type { ColineUiNode } from "../tree/tree"; import { validateChatCardTree } from "../tree/chat-card"; import { TestWorkspaceState, createFakeCapabilities, type TestWorkspaceOptions, } from "./fake-capabilities"; import { guardTestCapabilities } from "./guarded-capabilities"; import type { ToolEffect } from "../manifest/effect"; import type { ColineCapabilities, ColineMemberSummary } from "../capabilities/types"; /** * `createTestWorkspace` (plan §14.2): a fake workspace + runtime for app * tests. Invoke tools, render surfaces, and fire schedules exactly the * way the platform does — input parsed through the tool's schema, cards * validated against the chat subset, render contexts read-shaped — then * assert on the inspectable state. * * ```ts * const ws = createTestWorkspace(app); * const result = await ws.invokeTool("crm.create_contact", { name: "Sarah" }); * expect(ws.files.byType("crm.contact")).toHaveLength(1); * ``` */ export interface InvokeToolResult { output: unknown; card: ColineUiNode | null; } export interface TestWorkspace { state: TestWorkspaceState; client(surface?: "home" | "editor" | "preview"): ColineCapabilities; clientAs(userId: string, surface?: "home" | "editor" | "preview"): ColineCapabilities; files: { byType( typeKey: string, ): Array<{ fileId: string; name: string; document: Record }>; all(): Array<{ fileId: string; name: string; typeKey: string }>; }; invokeTool(name: string, input: unknown, userId?: string): Promise; renderHome(): Promise; renderFileSurface( surface: "editor" | "preview" | "inline", file: { fileId: string; name: string; typeKey: string; version?: number }, document?: Record, ): Promise; fireSchedule(scheduleId: string, at?: Date): Promise; } export function createTestWorkspace( app: ColineAppV2, options: TestWorkspaceOptions = {}, ): TestWorkspace { const state = new TestWorkspaceState(); const resolved = { workspaceId: options.workspaceId ?? "ws_test", workspaceSlug: options.workspaceSlug ?? "test-workspace", workspaceName: options.workspaceName ?? "Test Workspace", appKey: options.appKey ?? app.manifest.key, installId: options.installId ?? "inst_test", }; const members: ColineMemberSummary[] = (options.members ?? [{ userId: "user_test" }]).map( (member) => ({ userId: member.userId, email: member.email ?? `${member.userId}@example.com`, handle: member.handle ?? member.userId, firstName: member.firstName ?? "Test", lastName: member.lastName ?? "User", avatarUrl: member.avatarUrl ?? null, }), ); const actorUserId = members[0]?.userId ?? "user_test"; function capabilities( effect: ToolEffect, interactive = false, appPrincipal = false, userId = actorUserId, ) { if (!appPrincipal && !members.some((member) => member.userId === userId)) throw Object.assign(new Error("Actor is not a member."), { code: "actor_not_member" }); const raw = createFakeCapabilities(state, { ...resolved, members, actor: appPrincipal ? { kind: "app" } : { kind: "user", userId }, onNetFetch: options.onNetFetch, onAiGenerate: options.onAiGenerate, pickers: options.pickers ?? {}, }); return guardTestCapabilities( raw, { manifestPermissions: app.manifest.permissions, grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, effectCeiling: effect, }, { interactive, appPrincipal }, ); } const coline = capabilities("read"); const base = { coline, workspace: { workspaceId: resolved.workspaceId, workspaceSlug: resolved.workspaceSlug, workspaceName: resolved.workspaceName, }, app: { appKey: resolved.appKey, installId: resolved.installId }, }; const workspace: TestWorkspace = { state, client: (surface = "home") => workspace.clientAs(actorUserId, surface), clientAs: (userId, surface = "home") => { const client = capabilities( surface === "preview" ? "read" : "destructive", true, false, userId, ); return { ...client, tools: { invoke: async (name, input = {}) => { const { createCapabilityGate } = await import("../capabilities/gate"); createCapabilityGate({ manifestPermissions: app.manifest.permissions, grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, effectCeiling: surface === "preview" ? "read" : "destructive", }).assert("tools.invoke"); const result = await workspace.invokeTool(name, input, userId); return { output: result.output, ...(result.card ? { card: result.card } : {}) }; }, }, }; }, files: { byType: (typeKey) => state .filesByType(typeKey) .map((file) => ({ fileId: file.fileId, name: file.name, document: file.document })), all: () => [...state.files.values()] .filter((file) => !file.trashed) .map((file) => ({ fileId: file.fileId, name: file.name, typeKey: file.typeKey })), }, async invokeTool(name, input, userId = actorUserId) { const tool = app.runtime.tools.get(name); if (!tool) { throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); } const parsed = tool.input.safeParse(input); if (!parsed.success) { throw new Error( `Tool input is invalid: ${parsed.error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("; ")}`, ); } const result: ColineToolResult = await tool.execute(parsed.data as never, { ...base, coline: capabilities(tool.effect, false, false, userId), actor: { kind: "user", userId }, }); let card: ColineUiNode | null = null; if (result.card) { const validation = validateChatCardTree(result.card); if (!validation.ok) { throw new Error(`Tool card rejected: ${validation.reason}`); } card = result.card; } return { output: result.output, card }; }, async renderHome() { if (!app.runtime.renderHome) { throw new Error(`App "${app.manifest.key}" has no tree home handler.`); } const context: ColineHomeRenderContextV2 = { ...base, actor: { kind: "user", userId: actorUserId }, }; return app.runtime.renderHome(context); }, async renderFileSurface(surface, file, document = {}) { const runtime = app.runtime.fileTypes.get(file.typeKey); const handler = surface === "editor" ? runtime?.renderEditor : surface === "preview" ? runtime?.renderPreview : runtime?.renderInline; if (!handler) { throw new Error(`File type "${file.typeKey}" has no ${surface} handler.`); } const context: ColineFileRenderContextV2 = { ...base, actor: { kind: "user", userId: actorUserId }, file: { version: 1, ...file }, document, }; const tree = await handler(context); if (surface === "inline") { const validation = validateChatCardTree(tree); if (!validation.ok) { throw new Error(`Inline surface rejected: ${validation.reason}`); } } return tree; }, async fireSchedule(scheduleId, at = new Date()) { if (!app.runtime.onSchedule) { throw new Error(`App "${app.manifest.key}" has no schedule handler.`); } const declared = app.manifest.triggers.schedules.find( (schedule) => schedule.id === scheduleId, ); if (!declared) { throw new Error(`Schedule "${scheduleId}" is not declared in the manifest.`); } const context: ColineScheduleContextV2 = { coline: capabilities("write", false, true), workspace: base.workspace, app: base.app, scheduleId, firedAt: at.toISOString(), }; await app.runtime.onSchedule(context); }, }; return workspace; }