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 TestJobRun, type TestWorkspaceOptions, } from "./fake-capabilities"; import { eventPatternMatches } from "../manifest/triggers"; import { toEndpointResponse, toJobRunResult } from "../server/kinds"; import { parseEndpointJson } from "../server/runtime-helpers"; import { guardTestCapabilities } from "./guarded-capabilities"; import { siteToolEffect, type ToolEffect } from "../manifest/effect"; import type { AppPermissionV2 } from "../manifest/permissions"; import type { ColineCapabilities, ColineCollection, ColineMemberSummary, ColineVisitorRef, } from "../capabilities/types"; import { createFakeCollection } from "./fake-collection"; /** * `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; /** * Deliver an event to `onEvent` the way the platform does โ€” only when * the manifest subscribes to it. Returns false when unsubscribed. */ fireEvent( type: string, data?: Record, options?: { actorUserId?: string; sourceAppKey?: string }, ): Promise; /** Send a request to a declared endpoint, as if verification passed. */ request( endpointId: string, request?: { method?: string; path?: string; query?: Record; headers?: Record; body?: string | Record; }, ): Promise<{ status: number; headers: Record; body: unknown }>; /** * Run queued background jobs until none are due (bounded by `maxRuns`). * Failed attempts retry immediately in tests; returns the runs executed. */ runJobs(options?: { maxRuns?: number; now?: Date }): Promise; /** * Run a tool the site lists, as a visitor would: the tool's calls run as * the app at most at the `write` effect, and records it creates belong to * the visitor. */ invokeSiteTool(name: string, input: unknown, visitor?: TestVisitor): Promise; /** * What a site page sees calling collections directly: the collection's * declared visitor rules, and nothing a page may not call. */ asVisitor(visitor?: TestVisitor): { collection(name: string): ColineCollection }; } export interface TestVisitor { visitorId?: string; signedIn?: boolean; name?: string | null; email?: string | null; } function visitorRef(visitor: TestVisitor = {}): ColineVisitorRef { const signedIn = visitor.signedIn ?? false; return { kind: "visitor", visitorId: visitor.visitorId ?? "vis_test", signedIn, name: signedIn ? (visitor.name ?? "Test Visitor") : null, email: signedIn ? (visitor.email ?? "visitor@example.com") : null, }; } 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"; for (const [key, connection] of Object.entries(options.connections ?? {})) { state.connections.set(key, { accountLabel: connection.accountLabel ?? null, scopes: connection.scopes ?? [], connectedAt: new Date().toISOString(), }); } function capabilities( effect: ToolEffect, interactive = false, appPrincipal = false, userId = actorUserId, readAsUserId: string | null = null, ): ReturnType { 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, aiModels: options.aiModels, onAiJob: options.onAiJob, pickers: options.pickers ?? {}, manifest: app.manifest, destructiveAllowed: effect === "destructive", effect, }); return guardTestCapabilities( raw, { manifestPermissions: app.manifest.permissions, grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, effectCeiling: effect, }, { interactive, appPrincipal, // Like production, an event's reads are evaluated as its actor // while they are still a member. ...(appPrincipal && readAsUserId && members.some((member) => member.userId === readAsUserId) ? { readAs: capabilities("read", false, false, readAsUserId) } : {}), }, ); } const coline = capabilities("read"); const base = { coline, workspace: { workspaceId: resolved.workspaceId, workspaceSlug: resolved.workspaceSlug, workspaceName: resolved.workspaceName, }, app: { appKey: resolved.appKey, installId: resolved.installId }, }; // Site calls pass the same permission gate as on Coline: declared and granted. const assertSiteGrant = (permission: AppPermissionV2) => { const granted = options.grantedPermissions ?? app.manifest.permissions; if (!app.manifest.permissions.includes(permission) || !granted.includes(permission)) { throw Object.assign(new Error(`Permission "${permission}" is not granted to this app.`), { code: "permission_not_granted", }); } }; 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); }, async fireEvent(type, data = {}, eventOptions = {}) { const subscribed = app.manifest.triggers.webhooks.some((subscription) => eventPatternMatches(subscription.event, type), ); if (!subscribed) return false; if (!app.runtime.onEvent) { throw new Error(`App "${app.manifest.key}" has no onEvent handler.`); } eventCounter += 1; await app.runtime.onEvent({ coline: capabilities("write", false, true, actorUserId, eventOptions.actorUserId ?? null), workspace: base.workspace, app: base.app, attempt: 1, event: { id: `evt_test_${eventCounter}`, type, occurredAt: new Date().toISOString(), actor: eventOptions.actorUserId ? { kind: "user", userId: eventOptions.actorUserId } : null, source: eventOptions.sourceAppKey ? { kind: "app", appKey: eventOptions.sourceAppKey } : { kind: "platform" }, data: structuredClone(data), }, }); return true; }, async request(endpointId, request = {}) { const declared = app.manifest.endpoints.find((endpoint) => endpoint.id === endpointId); const handler = app.runtime.endpoints.get(endpointId); if (!declared || !handler) { throw new Error(`App "${app.manifest.key}" does not define endpoint "${endpointId}".`); } const method = (request.method ?? declared.methods[0] ?? "POST").toUpperCase(); if (!declared.methods.includes(method as never)) { return { status: 405, headers: {}, body: { error: "method_not_allowed" } }; } const body = typeof request.body === "string" ? request.body : request.body === undefined ? "" : JSON.stringify(request.body); const response = toEndpointResponse( await handler({ coline: capabilities("write", false, true), workspace: base.workspace, app: base.app, request: { endpointId, method, path: request.path ?? "/", query: request.query ?? {}, headers: Object.fromEntries( Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]), ), body, bodyBase64: null, receivedAt: new Date().toISOString(), verifiedBy: declared.verify.kind, }, json: () => parseEndpointJson(body), }), ); return { status: response.status, headers: response.headers, body: response.body ?? null }; }, async runJobs(runOptions = {}) { const executed: TestJobRun[] = []; const maxRuns = runOptions.maxRuns ?? 100; while (executed.length < maxRuns) { const nowMs = (runOptions.now ?? new Date()).getTime(); const run = [...state.jobs.values()].find( (candidate) => candidate.status === "queued" && Date.parse(candidate.runAt) <= nowMs, ); if (!run) break; const definition = app.runtime.jobs.get(run.name); const declared = app.manifest.jobs.find((job) => job.name === run.name); if (!definition || !declared) { run.status = "dead"; run.error = { code: "unknown_job", message: `Job "${run.name}" is not defined.` }; executed.push(run); continue; } run.status = "running"; run.attempt += 1; run.startedAt = new Date().toISOString(); const appPrincipal = run.actor.kind === "app"; const coline = capabilities( declared.effect, false, appPrincipal, run.actor.kind === "user" ? run.actor.userId : actorUserId, ); try { let payload: unknown = run.payload; if (definition.payload) payload = definition.payload.parse(payload); const outcome = toJobRunResult( await definition.run(payload as never, { coline, workspace: base.workspace, app: base.app, actor: run.actor, job: { id: run.id, name: run.name, attempt: run.attempt, maxAttempts: run.maxAttempts, enqueuedAt: run.createdAt, checkpoint: run.checkpoint, saveCheckpoint: async (value) => { run.checkpoint = structuredClone(value); }, progress: async (value, message) => { run.progress = { value: Math.max(0, Math.min(1, value)), message: message ?? null }; }, }, }), ); if (outcome.kind === "continue") { run.payload = outcome.payload; run.status = "queued"; run.attempt = 0; run.runAt = new Date(nowMs + (outcome.delaySeconds ?? 0) * 1000).toISOString(); } else { run.status = "succeeded"; run.result = outcome.result ?? null; run.finishedAt = new Date().toISOString(); } } catch (error) { run.error = { code: error && typeof error === "object" && "code" in error ? String(error.code) : "job_failed", message: error instanceof Error ? error.message : String(error), }; run.status = run.attempt >= run.maxAttempts ? "dead" : "queued"; if (run.status === "dead") run.finishedAt = new Date().toISOString(); } executed.push(structuredClone(run)); } return executed; }, async invokeSiteTool(name, input, visitor) { if (!app.manifest.surfaces.site?.tools.includes(name)) { throw Object.assign(new Error(`Tool "${name}" is not listed in surfaces.site.tools.`), { code: "site_tool_not_allowed", }); } const tool = app.runtime.tools.get(name); if (!tool) throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); // Like Coline: running a tool from a site needs ai.tools granted. assertSiteGrant("ai.tools"); const parsed = tool.input.parse(input); const actor = visitorRef(visitor); const effect = siteToolEffect(tool.effect); const raw = createFakeCapabilities(state, { ...resolved, members, actor: { kind: "app" }, createdBy: actor, onNetFetch: options.onNetFetch, onAiGenerate: options.onAiGenerate, aiModels: options.aiModels, onAiJob: options.onAiJob, pickers: options.pickers ?? {}, manifest: app.manifest, destructiveAllowed: false, effect, }); const result = await tool.execute(parsed as never, { ...base, coline: guardTestCapabilities( raw, { manifestPermissions: app.manifest.permissions, grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, effectCeiling: effect, }, { interactive: false, appPrincipal: true }, ), actor, }); if (result.card) { const validation = validateChatCardTree(result.card); if (!validation.ok) throw new Error(`Tool card rejected: ${validation.reason}`); } return { output: result.output, card: result.card ?? null }; }, asVisitor(visitor) { if (!app.manifest.surfaces.site) { throw new Error(`App "${app.manifest.key}" has no site surface.`); } const actor = visitorRef(visitor); return { collection: (name: string) => { assertSiteGrant("storage.app"); const access = app.manifest.collections[name]?.access; return createFakeCollection(state, name, actor, resolved.installId, { destructiveAllowed: true, ...(access ? { rules: access } : {}), }); }, }; }, }; let eventCounter = 0; return workspace; }