The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| ColineScheduleContextV2, |
| 6 | ColineToolResult, |
| 7 | } from "../define/types"; |
| 8 | import type { ColineUiNode } from "../tree/tree"; |
| 9 | import { validateChatCardTree } from "../tree/chat-card"; |
| 10 | import { |
| 11 | TestWorkspaceState, |
| 12 | createFakeCapabilities, |
| 13 | type TestJobRun, |
| 14 | type TestWorkspaceOptions, |
| 15 | } from "./fake-capabilities"; |
| 16 | import { eventPatternMatches } from "../manifest/triggers"; |
| 17 | import { toEndpointResponse, toJobRunResult } from "../server/kinds"; |
| 18 | import { parseEndpointJson } from "../server/runtime-helpers"; |
| 19 | import { guardTestCapabilities } from "./guarded-capabilities"; |
| 20 | import type { ToolEffect } from "../manifest/effect"; |
| 21 | import type { ColineCapabilities, ColineMemberSummary } from "../capabilities/types"; |
| 22 |
| 23 | /** |
| 24 | * `createTestWorkspace` (plan §14.2): a fake workspace + runtime for app |
| 25 | * tests. Invoke tools, render surfaces, and fire schedules exactly the |
| 26 | * way the platform does — input parsed through the tool's schema, cards |
| 27 | * validated against the chat subset, render contexts read-shaped — then |
| 28 | * assert on the inspectable state. |
| 29 | * |
| 30 | * ```ts |
| 31 | * const ws = createTestWorkspace(app); |
| 32 | * const result = await ws.invokeTool("crm.create_contact", { name: "Sarah" }); |
| 33 | * expect(ws.files.byType("crm.contact")).toHaveLength(1); |
| 34 | * ``` |
| 35 | */ |
| 36 |
| 37 | export interface InvokeToolResult { |
| 38 | output: unknown; |
| 39 | card: ColineUiNode | null; |
| 40 | } |
| 41 |
| 42 | export interface TestWorkspace { |
| 43 | state: TestWorkspaceState; |
| 44 | client(surface?: "home" | "editor" | "preview"): ColineCapabilities; |
| 45 | clientAs(userId: string, surface?: "home" | "editor" | "preview"): ColineCapabilities; |
| 46 | files: { |
| 47 | byType( |
| 48 | typeKey: string, |
| 49 | ): Array<{ fileId: string; name: string; document: Record<string, unknown> }>; |
| 50 | all(): Array<{ fileId: string; name: string; typeKey: string }>; |
| 51 | }; |
| 52 | invokeTool(name: string, input: unknown, userId?: string): Promise<InvokeToolResult>; |
| 53 | renderHome(): Promise<ColineUiNode>; |
| 54 | renderFileSurface( |
| 55 | surface: "editor" | "preview" | "inline", |
| 56 | file: { fileId: string; name: string; typeKey: string; version?: number }, |
| 57 | document?: Record<string, unknown>, |
| 58 | ): Promise<ColineUiNode>; |
| 59 | fireSchedule(scheduleId: string, at?: Date): Promise<void>; |
| 60 | /** |
| 61 | * Deliver an event to `onEvent` the way the platform does — only when |
| 62 | * the manifest subscribes to it. Returns false when unsubscribed. |
| 63 | */ |
| 64 | fireEvent( |
| 65 | type: string, |
| 66 | data?: Record<string, unknown>, |
| 67 | options?: { actorUserId?: string; sourceAppKey?: string }, |
| 68 | ): Promise<boolean>; |
| 69 | /** Send a request to a declared endpoint, as if verification passed. */ |
| 70 | request( |
| 71 | endpointId: string, |
| 72 | request?: { |
| 73 | method?: string; |
| 74 | path?: string; |
| 75 | query?: Record<string, string>; |
| 76 | headers?: Record<string, string>; |
| 77 | body?: string | Record<string, unknown>; |
| 78 | }, |
| 79 | ): Promise<{ status: number; headers: Record<string, string>; body: unknown }>; |
| 80 | /** |
| 81 | * Run queued background jobs until none are due (bounded by `maxRuns`). |
| 82 | * Failed attempts retry immediately in tests; returns the runs executed. |
| 83 | */ |
| 84 | runJobs(options?: { maxRuns?: number; now?: Date }): Promise<TestJobRun[]>; |
| 85 | /** Render the tree public surface for a visitor. */ |
| 86 | renderPublic(path?: string, query?: Record<string, string>): Promise<ColineUiNode>; |
| 87 | /** Run a tool the public surface exposes, as a visitor would. */ |
| 88 | invokePublicTool(name: string, input: unknown): Promise<InvokeToolResult>; |
| 89 | } |
| 90 |
| 91 | export function createTestWorkspace( |
| 92 | app: ColineAppV2, |
| 93 | options: TestWorkspaceOptions = {}, |
| 94 | ): TestWorkspace { |
| 95 | const state = new TestWorkspaceState(); |
| 96 | const resolved = { |
| 97 | workspaceId: options.workspaceId ?? "ws_test", |
| 98 | workspaceSlug: options.workspaceSlug ?? "test-workspace", |
| 99 | workspaceName: options.workspaceName ?? "Test Workspace", |
| 100 | appKey: options.appKey ?? app.manifest.key, |
| 101 | installId: options.installId ?? "inst_test", |
| 102 | }; |
| 103 | const members: ColineMemberSummary[] = (options.members ?? [{ userId: "user_test" }]).map( |
| 104 | (member) => ({ |
| 105 | userId: member.userId, |
| 106 | email: member.email ?? `${member.userId}@example.com`, |
| 107 | handle: member.handle ?? member.userId, |
| 108 | firstName: member.firstName ?? "Test", |
| 109 | lastName: member.lastName ?? "User", |
| 110 | avatarUrl: member.avatarUrl ?? null, |
| 111 | }), |
| 112 | ); |
| 113 | const actorUserId = members[0]?.userId ?? "user_test"; |
| 114 | for (const [key, connection] of Object.entries(options.connections ?? {})) { |
| 115 | state.connections.set(key, { |
| 116 | accountLabel: connection.accountLabel ?? null, |
| 117 | scopes: connection.scopes ?? [], |
| 118 | connectedAt: new Date().toISOString(), |
| 119 | }); |
| 120 | } |
| 121 |
| 122 | function capabilities( |
| 123 | effect: ToolEffect, |
| 124 | interactive = false, |
| 125 | appPrincipal = false, |
| 126 | userId = actorUserId, |
| 127 | ) { |
| 128 | if (!appPrincipal && !members.some((member) => member.userId === userId)) |
| 129 | throw Object.assign(new Error("Actor is not a member."), { code: "actor_not_member" }); |
| 130 | const raw = createFakeCapabilities(state, { |
| 131 | ...resolved, |
| 132 | members, |
| 133 | actor: appPrincipal ? { kind: "app" } : { kind: "user", userId }, |
| 134 | onNetFetch: options.onNetFetch, |
| 135 | onAiGenerate: options.onAiGenerate, |
| 136 | aiModels: options.aiModels, |
| 137 | onAiJob: options.onAiJob, |
| 138 | pickers: options.pickers ?? {}, |
| 139 | manifest: app.manifest, |
| 140 | destructiveAllowed: effect === "destructive", |
| 141 | }); |
| 142 | return guardTestCapabilities( |
| 143 | raw, |
| 144 | { |
| 145 | manifestPermissions: app.manifest.permissions, |
| 146 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 147 | effectCeiling: effect, |
| 148 | }, |
| 149 | { interactive, appPrincipal }, |
| 150 | ); |
| 151 | } |
| 152 | const coline = capabilities("read"); |
| 153 |
| 154 | const base = { |
| 155 | coline, |
| 156 | workspace: { |
| 157 | workspaceId: resolved.workspaceId, |
| 158 | workspaceSlug: resolved.workspaceSlug, |
| 159 | workspaceName: resolved.workspaceName, |
| 160 | }, |
| 161 | app: { appKey: resolved.appKey, installId: resolved.installId }, |
| 162 | }; |
| 163 |
| 164 | const workspace: TestWorkspace = { |
| 165 | state, |
| 166 | client: (surface = "home") => workspace.clientAs(actorUserId, surface), |
| 167 | clientAs: (userId, surface = "home") => { |
| 168 | const client = capabilities( |
| 169 | surface === "preview" ? "read" : "destructive", |
| 170 | true, |
| 171 | false, |
| 172 | userId, |
| 173 | ); |
| 174 | return { |
| 175 | ...client, |
| 176 | tools: { |
| 177 | invoke: async (name, input = {}) => { |
| 178 | const { createCapabilityGate } = await import("../capabilities/gate"); |
| 179 | createCapabilityGate({ |
| 180 | manifestPermissions: app.manifest.permissions, |
| 181 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 182 | effectCeiling: surface === "preview" ? "read" : "destructive", |
| 183 | }).assert("tools.invoke"); |
| 184 | const result = await workspace.invokeTool(name, input, userId); |
| 185 | return { output: result.output, ...(result.card ? { card: result.card } : {}) }; |
| 186 | }, |
| 187 | }, |
| 188 | }; |
| 189 | }, |
| 190 | files: { |
| 191 | byType: (typeKey) => |
| 192 | state |
| 193 | .filesByType(typeKey) |
| 194 | .map((file) => ({ fileId: file.fileId, name: file.name, document: file.document })), |
| 195 | all: () => |
| 196 | [...state.files.values()] |
| 197 | .filter((file) => !file.trashed) |
| 198 | .map((file) => ({ fileId: file.fileId, name: file.name, typeKey: file.typeKey })), |
| 199 | }, |
| 200 |
| 201 | async invokeTool(name, input, userId = actorUserId) { |
| 202 | const tool = app.runtime.tools.get(name); |
| 203 | if (!tool) { |
| 204 | throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); |
| 205 | } |
| 206 | const parsed = tool.input.safeParse(input); |
| 207 | if (!parsed.success) { |
| 208 | throw new Error( |
| 209 | `Tool input is invalid: ${parsed.error.issues |
| 210 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 211 | .join("; ")}`, |
| 212 | ); |
| 213 | } |
| 214 | const result: ColineToolResult = await tool.execute(parsed.data as never, { |
| 215 | ...base, |
| 216 | coline: capabilities(tool.effect, false, false, userId), |
| 217 | actor: { kind: "user", userId }, |
| 218 | }); |
| 219 | let card: ColineUiNode | null = null; |
| 220 | if (result.card) { |
| 221 | const validation = validateChatCardTree(result.card); |
| 222 | if (!validation.ok) { |
| 223 | throw new Error(`Tool card rejected: ${validation.reason}`); |
| 224 | } |
| 225 | card = result.card; |
| 226 | } |
| 227 | return { output: result.output, card }; |
| 228 | }, |
| 229 |
| 230 | async renderHome() { |
| 231 | if (!app.runtime.renderHome) { |
| 232 | throw new Error(`App "${app.manifest.key}" has no tree home handler.`); |
| 233 | } |
| 234 | const context: ColineHomeRenderContextV2 = { |
| 235 | ...base, |
| 236 | actor: { kind: "user", userId: actorUserId }, |
| 237 | }; |
| 238 | return app.runtime.renderHome(context); |
| 239 | }, |
| 240 |
| 241 | async renderFileSurface(surface, file, document = {}) { |
| 242 | const runtime = app.runtime.fileTypes.get(file.typeKey); |
| 243 | const handler = |
| 244 | surface === "editor" |
| 245 | ? runtime?.renderEditor |
| 246 | : surface === "preview" |
| 247 | ? runtime?.renderPreview |
| 248 | : runtime?.renderInline; |
| 249 | if (!handler) { |
| 250 | throw new Error(`File type "${file.typeKey}" has no ${surface} handler.`); |
| 251 | } |
| 252 | const context: ColineFileRenderContextV2 = { |
| 253 | ...base, |
| 254 | actor: { kind: "user", userId: actorUserId }, |
| 255 | file: { version: 1, ...file }, |
| 256 | document, |
| 257 | }; |
| 258 | const tree = await handler(context); |
| 259 | if (surface === "inline") { |
| 260 | const validation = validateChatCardTree(tree); |
| 261 | if (!validation.ok) { |
| 262 | throw new Error(`Inline surface rejected: ${validation.reason}`); |
| 263 | } |
| 264 | } |
| 265 | return tree; |
| 266 | }, |
| 267 |
| 268 | async fireSchedule(scheduleId, at = new Date()) { |
| 269 | if (!app.runtime.onSchedule) { |
| 270 | throw new Error(`App "${app.manifest.key}" has no schedule handler.`); |
| 271 | } |
| 272 | const declared = app.manifest.triggers.schedules.find( |
| 273 | (schedule) => schedule.id === scheduleId, |
| 274 | ); |
| 275 | if (!declared) { |
| 276 | throw new Error(`Schedule "${scheduleId}" is not declared in the manifest.`); |
| 277 | } |
| 278 | const context: ColineScheduleContextV2 = { |
| 279 | coline: capabilities("write", false, true), |
| 280 | workspace: base.workspace, |
| 281 | app: base.app, |
| 282 | scheduleId, |
| 283 | firedAt: at.toISOString(), |
| 284 | }; |
| 285 | await app.runtime.onSchedule(context); |
| 286 | }, |
| 287 |
| 288 | async fireEvent(type, data = {}, eventOptions = {}) { |
| 289 | const subscribed = app.manifest.triggers.webhooks.some((subscription) => |
| 290 | eventPatternMatches(subscription.event, type), |
| 291 | ); |
| 292 | if (!subscribed) return false; |
| 293 | if (!app.runtime.onEvent) { |
| 294 | throw new Error(`App "${app.manifest.key}" has no onEvent handler.`); |
| 295 | } |
| 296 | eventCounter += 1; |
| 297 | await app.runtime.onEvent({ |
| 298 | coline: capabilities("write", false, true), |
| 299 | workspace: base.workspace, |
| 300 | app: base.app, |
| 301 | attempt: 1, |
| 302 | event: { |
| 303 | id: `evt_test_${eventCounter}`, |
| 304 | type, |
| 305 | occurredAt: new Date().toISOString(), |
| 306 | actor: eventOptions.actorUserId ? { kind: "user", userId: eventOptions.actorUserId } : null, |
| 307 | source: eventOptions.sourceAppKey |
| 308 | ? { kind: "app", appKey: eventOptions.sourceAppKey } |
| 309 | : { kind: "platform" }, |
| 310 | data: structuredClone(data), |
| 311 | }, |
| 312 | }); |
| 313 | return true; |
| 314 | }, |
| 315 |
| 316 | async request(endpointId, request = {}) { |
| 317 | const declared = app.manifest.endpoints.find((endpoint) => endpoint.id === endpointId); |
| 318 | const handler = app.runtime.endpoints.get(endpointId); |
| 319 | if (!declared || !handler) { |
| 320 | throw new Error(`App "${app.manifest.key}" does not define endpoint "${endpointId}".`); |
| 321 | } |
| 322 | const method = (request.method ?? declared.methods[0] ?? "POST").toUpperCase(); |
| 323 | if (!declared.methods.includes(method as never)) { |
| 324 | return { status: 405, headers: {}, body: { error: "method_not_allowed" } }; |
| 325 | } |
| 326 | const body = |
| 327 | typeof request.body === "string" |
| 328 | ? request.body |
| 329 | : request.body === undefined |
| 330 | ? "" |
| 331 | : JSON.stringify(request.body); |
| 332 | const response = toEndpointResponse( |
| 333 | await handler({ |
| 334 | coline: capabilities("write", false, true), |
| 335 | workspace: base.workspace, |
| 336 | app: base.app, |
| 337 | request: { |
| 338 | endpointId, |
| 339 | method, |
| 340 | path: request.path ?? "/", |
| 341 | query: request.query ?? {}, |
| 342 | headers: Object.fromEntries( |
| 343 | Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]), |
| 344 | ), |
| 345 | body, |
| 346 | bodyBase64: null, |
| 347 | receivedAt: new Date().toISOString(), |
| 348 | verifiedBy: declared.verify.kind, |
| 349 | }, |
| 350 | json: <T>() => parseEndpointJson<T>(body), |
| 351 | }), |
| 352 | ); |
| 353 | return { status: response.status, headers: response.headers, body: response.body ?? null }; |
| 354 | }, |
| 355 |
| 356 | async runJobs(runOptions = {}) { |
| 357 | const executed: TestJobRun[] = []; |
| 358 | const maxRuns = runOptions.maxRuns ?? 100; |
| 359 | while (executed.length < maxRuns) { |
| 360 | const nowMs = (runOptions.now ?? new Date()).getTime(); |
| 361 | const run = [...state.jobs.values()].find( |
| 362 | (candidate) => candidate.status === "queued" && Date.parse(candidate.runAt) <= nowMs, |
| 363 | ); |
| 364 | if (!run) break; |
| 365 | const definition = app.runtime.jobs.get(run.name); |
| 366 | const declared = app.manifest.jobs.find((job) => job.name === run.name); |
| 367 | if (!definition || !declared) { |
| 368 | run.status = "dead"; |
| 369 | run.error = { code: "unknown_job", message: `Job "${run.name}" is not defined.` }; |
| 370 | executed.push(run); |
| 371 | continue; |
| 372 | } |
| 373 | run.status = "running"; |
| 374 | run.attempt += 1; |
| 375 | run.startedAt = new Date().toISOString(); |
| 376 | const appPrincipal = run.actor.kind === "app"; |
| 377 | const coline = capabilities( |
| 378 | declared.effect, |
| 379 | false, |
| 380 | appPrincipal, |
| 381 | run.actor.kind === "user" ? run.actor.userId : actorUserId, |
| 382 | ); |
| 383 | try { |
| 384 | let payload: unknown = run.payload; |
| 385 | if (definition.payload) payload = definition.payload.parse(payload); |
| 386 | const outcome = toJobRunResult( |
| 387 | await definition.run(payload as never, { |
| 388 | coline, |
| 389 | workspace: base.workspace, |
| 390 | app: base.app, |
| 391 | actor: run.actor, |
| 392 | job: { |
| 393 | id: run.id, |
| 394 | name: run.name, |
| 395 | attempt: run.attempt, |
| 396 | maxAttempts: run.maxAttempts, |
| 397 | enqueuedAt: run.createdAt, |
| 398 | checkpoint: run.checkpoint, |
| 399 | saveCheckpoint: async (value) => { |
| 400 | run.checkpoint = structuredClone(value); |
| 401 | }, |
| 402 | progress: async (value, message) => { |
| 403 | run.progress = { value: Math.max(0, Math.min(1, value)), message: message ?? null }; |
| 404 | }, |
| 405 | }, |
| 406 | }), |
| 407 | ); |
| 408 | if (outcome.kind === "continue") { |
| 409 | run.payload = outcome.payload; |
| 410 | run.status = "queued"; |
| 411 | run.attempt = 0; |
| 412 | run.runAt = new Date(nowMs + (outcome.delaySeconds ?? 0) * 1000).toISOString(); |
| 413 | } else { |
| 414 | run.status = "succeeded"; |
| 415 | run.result = outcome.result ?? null; |
| 416 | run.finishedAt = new Date().toISOString(); |
| 417 | } |
| 418 | } catch (error) { |
| 419 | run.error = { |
| 420 | code: |
| 421 | error && typeof error === "object" && "code" in error ? String(error.code) : "job_failed", |
| 422 | message: error instanceof Error ? error.message : String(error), |
| 423 | }; |
| 424 | run.status = run.attempt >= run.maxAttempts ? "dead" : "queued"; |
| 425 | if (run.status === "dead") run.finishedAt = new Date().toISOString(); |
| 426 | } |
| 427 | executed.push(structuredClone(run)); |
| 428 | } |
| 429 | return executed; |
| 430 | }, |
| 431 |
| 432 | async renderPublic(path = "/", query = {}) { |
| 433 | if (!app.manifest.surfaces.public) { |
| 434 | throw new Error(`App "${app.manifest.key}" has no public surface.`); |
| 435 | } |
| 436 | if (!app.runtime.renderPublic) { |
| 437 | throw new Error(`App "${app.manifest.key}" has no renderPublic handler.`); |
| 438 | } |
| 439 | return app.runtime.renderPublic({ |
| 440 | coline: capabilities("read", false, true), |
| 441 | workspace: base.workspace, |
| 442 | app: base.app, |
| 443 | path, |
| 444 | query, |
| 445 | visitor: { id: "visitor_test" }, |
| 446 | }); |
| 447 | }, |
| 448 |
| 449 | async invokePublicTool(name, input) { |
| 450 | if (!app.manifest.surfaces.public?.tools.includes(name)) { |
| 451 | throw Object.assign(new Error(`Tool "${name}" is not exposed on the public surface.`), { |
| 452 | code: "public_tool_not_allowed", |
| 453 | }); |
| 454 | } |
| 455 | const tool = app.runtime.tools.get(name); |
| 456 | if (!tool) throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); |
| 457 | const parsed = tool.input.parse(input); |
| 458 | const result = await tool.execute(parsed as never, { |
| 459 | ...base, |
| 460 | coline: capabilities(tool.effect, false, true), |
| 461 | actor: { kind: "app" }, |
| 462 | }); |
| 463 | if (result.card) { |
| 464 | const validation = validateChatCardTree(result.card); |
| 465 | if (!validation.ok) throw new Error(`Tool card rejected: ${validation.reason}`); |
| 466 | } |
| 467 | return { output: result.output, card: result.card ?? null }; |
| 468 | }, |
| 469 | }; |
| 470 | let eventCounter = 0; |
| 471 | return workspace; |
| 472 | } |
| 473 |