import { fakeAi } from "./fake-ai"; import { createFakeCollection } from "./fake-collection"; import { createFakeWorkspaceCapabilities } from "./workspace-capabilities"; import type { ColineTask, ColineCalendarEvent, ColineMessage } from "../capabilities/workspace"; import type { ColineActorRef, ColineCapabilities, ColineConnectionStatus, ColineFileSummaryV2, ColineJobRun, ColineJobStatus, ColineMemberSummary, ColineNetFetchInput, ColineNetFetchResult, ColineRecordEnvelope, } from "../capabilities/types"; import type { ColineAppManifestV2 } from "../manifest/manifest"; import { EFFECT_CEILINGS, MUTATION_CLASS_RANK, type ToolEffect } from "../manifest/effect"; import { uploadDataToBytes } from "../capabilities/upload"; function toJobRun(run: TestJobRun): ColineJobRun { return { id: run.id, name: run.name, status: run.status, attempt: run.attempt, maxAttempts: run.maxAttempts, progress: run.progress, result: run.result ?? null, error: run.error, runAt: run.runAt, createdAt: run.createdAt, startedAt: run.startedAt, finishedAt: run.finishedAt, }; } /** * The in-memory capability fake behind `createTestWorkspace` (plan §14.2). * Implements the same `coline.*` contract apps run against in production, * backed by maps — no network, no database, fully inspectable. */ export interface TestWorkspaceOptions { grantedPermissions?: import("../manifest/permissions").AppPermissionV2[]; workspaceId?: string; workspaceSlug?: string; workspaceName?: string; appKey?: string; installId?: string; members?: Array & { userId: string }>; /** Handler for coline.net.fetch; defaults to rejecting. */ onNetFetch?: (input: ColineNetFetchInput) => Promise; /** Models enabled by the deterministic test provider. */ aiModels?: import("../capabilities/ai").ColineAiModelInfo[]; onAiJob?: Parameters[3]; /** Handler for coline.ai.generate; defaults to echoing. */ onAiGenerate?: (prompt: string) => Promise; /** Picker results; default null (dismissed) / false (confirm). */ pickers?: { file?: () => Promise<{ fileId: string } | null>; member?: () => Promise<{ userId: string } | null>; destination?: () => Promise<{ driveId: string; parentId: string | null } | null>; confirm?: () => Promise; }; /** Connections already made, by key. `connect()` marks a connection connected. */ connections?: Record; } export interface TestJobRun { id: string; name: string; payload: Record; actor: ColineActorRef; status: ColineJobStatus; attempt: number; maxAttempts: number; runAt: string; createdAt: string; startedAt: string | null; finishedAt: string | null; checkpoint: unknown; progress: { value: number; message: string | null } | null; result: unknown; error: { code: string; message: string } | null; idempotencyKey: string | null; } export interface TestUpload { file: TestFile; mimeType: string; bytes: Uint8Array; } export interface TestFile extends ColineFileSummaryV2 { document: Record; trashed: boolean; } export interface TestNotification { channelKey: string; title: string; body?: string | undefined; href: string; recipients: string[]; } export interface TestIndexDocument { documentKey: string; documentType: string; title: string; body: string; fileId?: string | undefined; } export interface TestNavigationEvent { intent: "openFile" | "openAppHome" | "navigate" | "openReference"; payload: Record; } export class TestWorkspaceState { readonly tasks = new Map(); readonly calendar = new Map(); readonly messages = new Map(); readonly files = new Map(); readonly kv = new Map(); readonly recordRevisions = new Map(); readonly realtimeListeners = new Map< string, Set<(message: { event: string; data: unknown }) => void> >(); readonly records = new Map>>>(); readonly notifications: TestNotification[] = []; readonly indexDocuments = new Map(); readonly navigations: TestNavigationEvent[] = []; readonly events: { kind: string; payload?: Record }[] = []; readonly published: { event: string; data: unknown }[] = []; readonly netRequests: ColineNetFetchInput[] = []; readonly jobs = new Map(); readonly uploads = new Map(); readonly connections = new Map(); private fileCounter = 0; private recordCounter = 0; private jobCounter = 0; nextJobId(): string { this.jobCounter += 1; return `job_test_${this.jobCounter}`; } nextFileId(): string { this.fileCounter += 1; return `file_test_${this.fileCounter}`; } nextRecordId(): string { this.recordCounter += 1; return `rec_test_${this.recordCounter}`; } filesByType(typeKey: string): TestFile[] { return [...this.files.values()].filter((file) => file.typeKey === typeKey && !file.trashed); } } export function createFakeCapabilities( state: TestWorkspaceState, options: Required< Pick< TestWorkspaceOptions, "workspaceId" | "workspaceSlug" | "workspaceName" | "appKey" | "installId" > > & { members: ColineMemberSummary[]; actor: ColineActorRef; onNetFetch: TestWorkspaceOptions["onNetFetch"]; onAiGenerate: TestWorkspaceOptions["onAiGenerate"]; aiModels?: TestWorkspaceOptions["aiModels"]; onAiJob?: TestWorkspaceOptions["onAiJob"]; pickers: NonNullable; manifest: ColineAppManifestV2; /** Ceiling of the executing unit; batches with deletes need destructive. */ destructiveAllowed: boolean; /** The executing unit's effect; a job it enqueues may not exceed it. */ effect?: ToolEffect; /** Records created here belong to this actor (a site tool's visitor). */ createdBy?: ColineActorRef; }, ): ColineCapabilities { const now = () => new Date().toISOString(); const fail = (code: string, message: string): never => { throw Object.assign(new Error(message), { code }); }; const connectionStatus = (key: string): ColineConnectionStatus => { const declared = options.manifest.connections.find((connection) => connection.key === key); if (!declared) return fail("connection_not_found", `This app does not declare connection "${key}".`); const made = state.connections.get(key); return { key, name: declared.name, scope: declared.scope, connected: Boolean(made), account: made ? { label: made.accountLabel } : null, scopes: made?.scopes ?? [], connectedAt: made?.connectedAt ?? null, }; }; const files = { readUrl: async (fileId: string) => { const upload = state.uploads.get(fileId); if (!upload) return fail("file_not_binary", "This file has no binary content."); return { url: `memory://uploads/${fileId}`, mimeType: upload.mimeType }; }, }; return { ...createFakeWorkspaceCapabilities( state, options.actor.kind === "user" ? options.actor.userId : null, ), tools: { invoke: async () => { throw new Error("Use createTestWorkspace to invoke tools."); }, }, workspace: { get: async () => ({ workspaceId: options.workspaceId, workspaceSlug: options.workspaceSlug, workspaceName: options.workspaceName, }), }, members: { list: async () => ({ members: options.members }), get: async (userId) => options.members.find((member) => member.userId === userId) ?? null, }, files: { readUrl: files.readUrl, upload: async (input) => { const bytes = await uploadDataToBytes(input.data); if (bytes.byteLength > 25 * 1024 * 1024) return fail("upload_too_large", "files.upload accepts up to 25 MB."); const mimeType = input.mimeType ?? "application/octet-stream"; const file: TestFile = { fileId: state.nextFileId(), name: input.name, typeKey: "file", driveId: input.driveId ?? "drive_test_app", parentId: input.parentId ?? null, version: 1, createdAt: now(), updatedAt: now(), document: {}, trashed: false, }; state.files.set(file.fileId, file); state.uploads.set(file.fileId, { file, mimeType, bytes }); return { ...structuredClone(file), mimeType, size: bytes.byteLength }; }, createUpload: async (input) => ({ uploadId: `upload_${input.name}`, url: `memory://upload/${encodeURIComponent(input.name)}`, headers: { "content-type": input.mimeType }, expiresAt: new Date(Date.now() + 3_600_000).toISOString(), }), completeUpload: async () => fail("upload_not_found", "Direct uploads are not simulated; use files.upload in tests."), move: async (fileId, destination) => { const file = state.files.get(fileId) ?? fail("file_not_found", "File not found."); file.driveId = destination.driveId; file.parentId = destination.parentId ?? null; file.updatedAt = now(); return structuredClone(file); }, saveAs: async (fileId) => { const destination = options.pickers.destination ? await options.pickers.destination() : null; if (!destination) return null; const file = state.files.get(fileId) ?? fail("file_not_found", "File not found."); file.driveId = destination.driveId; file.parentId = destination.parentId; file.updatedAt = now(); return structuredClone(file); }, preview: async (fileId) => { const file = state.files.get(fileId) ?? fail("file_not_found", "File not found."); const upload = state.uploads.get(fileId); return { fileId, name: file.name, typeKey: file.typeKey, tree: null, media: upload ? { url: `memory://uploads/${fileId}`, mimeType: upload.mimeType } : null, href: null, }; }, list: async (input) => { let files = [...state.files.values()].filter((file) => !file.trashed); if (input?.typeKey) files = files.filter((file) => file.typeKey === input.typeKey); if (input?.driveId) files = files.filter((file) => file.driveId === input.driveId); const start = input?.cursor ? files.findIndex((file) => file.fileId === input.cursor) + 1 : 0; const limit = input?.limit ?? 50; const page = files.slice(start, start + limit); return { files: structuredClone(page), nextCursor: files.length > start + limit ? page.at(-1)!.fileId : null, }; }, get: async (fileId) => state.files.get(fileId) ?? null, create: async (input) => { const file: TestFile = { fileId: state.nextFileId(), name: input.name, typeKey: input.typeKey, driveId: input.driveId ?? "drive_test_app", parentId: input.parentId ?? null, version: 1, createdAt: now(), updatedAt: now(), document: input.document ?? {}, trashed: false, }; state.files.set(file.fileId, file); return file; }, update: async (fileId, input) => { const file = state.files.get(fileId); if (!file) throw new Error(`Test file not found: ${fileId}`); if (input.name) file.name = input.name; file.updatedAt = now(); return file; }, trash: async (fileId) => { const file = state.files.get(fileId); if (!file) throw new Error(`Test file not found: ${fileId}`); file.trashed = true; }, getDocument: async (fileId) => { const file = state.files.get(fileId); if (!file) throw new Error(`Test file not found: ${fileId}`); return file.document; }, updateDocument: async (fileId, document, options) => { const file = state.files.get(fileId); if (!file) throw new Error(`Test file not found: ${fileId}`); if (options?.expectedVersion !== undefined && options.expectedVersion !== file.version) throw Object.assign(new Error("The document changed. Reload before saving."), { code: "document_conflict", }); file.document = structuredClone(document); file.version += 1; file.updatedAt = now(); return { version: file.version, updatedAt: file.updatedAt }; }, }, drives: { getAppDrive: async () => ({ driveId: "drive_test_app" }), }, storage: { kv: { get: async (key) => state.kv.get(key) ?? null, set: async (key, value) => { state.kv.set(key, value ?? null); }, delete: async (key) => { state.kv.delete(key); }, list: async (input) => ({ keys: [...state.kv.keys()] .filter((key) => !input?.prefix || key.startsWith(input.prefix)) .sort() .slice(0, input?.limit ?? 200), }), }, collection: (name: string) => createFakeCollection(state, name, options.actor, options.installId, { destructiveAllowed: options.destructiveAllowed, ...(options.createdBy ? { createdBy: options.createdBy } : {}), }), batch: async (operations) => { // Atomic across collections: snapshot every touched collection. const names = [...new Set(operations.map((operation) => operation.collection))]; const snapshots = new Map( names.map((name) => [name, structuredClone([...(state.records.get(name) ?? new Map())])]), ); try { const results = []; for (const operation of operations) { const { collection, ...rest } = operation; const handle = createFakeCollection>( state, collection, options.actor, options.installId, { destructiveAllowed: options.destructiveAllowed, ...(options.createdBy ? { createdBy: options.createdBy } : {}), }, ); const { results: [result] } = await handle.batch([rest]); results.push(result ?? null); } return { results }; } catch (error) { for (const [name, rows] of snapshots) state.records.set(name, new Map(rows)); throw error; } }, }, jobs: { enqueue: async (name, payload = {}, enqueueOptions = {}) => { const declared = options.manifest.jobs.find((job) => job.name === name) ?? fail("job_not_found", `This app does not declare job "${name}".`); // Same rule as production: a job may not exceed its enqueuer. if ( options.effect && MUTATION_CLASS_RANK[EFFECT_CEILINGS[declared.effect].maxMutation] > MUTATION_CLASS_RANK[EFFECT_CEILINGS[options.effect].maxMutation] ) { fail( "effect_exceeds_caller", `Job "${name}" is declared "${declared.effect}", which exceeds the "${options.effect}" effect of the code enqueueing it.`, ); } // Like production, a key belongs to the principal that chose it: // two members using "refresh" get their own runs. const principal = options.actor.kind === "user" ? options.actor.userId : "app"; const idempotencyKey = enqueueOptions.idempotencyKey ? `${principal}:${enqueueOptions.idempotencyKey}` : null; if (idempotencyKey) { const existing = [...state.jobs.values()].find((run) => run.idempotencyKey === idempotencyKey); if (existing) return toJobRun(existing); } const runAt = enqueueOptions.runAt ? new Date(enqueueOptions.runAt) : new Date(Date.now() + (enqueueOptions.delaySeconds ?? 0) * 1000); const run: TestJobRun = { id: state.nextJobId(), name, payload: structuredClone(payload), actor: options.actor, status: "queued", attempt: 0, maxAttempts: declared.maxAttempts, runAt: runAt.toISOString(), createdAt: now(), startedAt: null, finishedAt: null, checkpoint: null, progress: null, result: null, error: null, idempotencyKey, }; state.jobs.set(run.id, run); return toJobRun(run); }, get: async (jobId) => { const run = state.jobs.get(jobId); return run ? toJobRun(run) : null; }, list: async (input) => { const runs = [...state.jobs.values()] .filter((run) => (!input?.name || run.name === input.name) && (!input?.status || run.status === input.status)) .reverse(); const offset = Number(input?.cursor ?? 0); const limit = input?.limit ?? 50; return { runs: runs.slice(offset, offset + limit).map(toJobRun), nextCursor: runs.length > offset + limit ? String(offset + limit) : null, }; }, cancel: async (jobId) => { const run = state.jobs.get(jobId) ?? fail("job_not_found", "Job run not found."); if (run.status === "queued") { run.status = "cancelled"; run.finishedAt = now(); } return toJobRun(run); }, }, connections: { list: async () => ({ connections: options.manifest.connections.map((connection) => connectionStatus(connection.key)), }), get: async (key) => connectionStatus(key), connect: async (key) => { connectionStatus(key); if (!state.connections.has(key)) { state.connections.set(key, { accountLabel: "test-account", scopes: [], connectedAt: now() }); } return connectionStatus(key); }, disconnect: async (key) => { connectionStatus(key); state.connections.delete(key); }, }, endpoints: { url: async (id) => { if (!options.manifest.endpoints.some((endpoint) => endpoint.id === id)) return fail("endpoint_not_found", `This app does not declare endpoint "${id}".`); return { id, url: `https://coline.test/api/apps/endpoints/${options.installId}-${id}` }; }, list: async () => ({ endpoints: options.manifest.endpoints.map((endpoint) => ({ id: endpoint.id, url: `https://coline.test/api/apps/endpoints/${options.installId}-${endpoint.id}`, methods: endpoint.methods, })), }), }, search: { query: async (input) => { const query = input.query.toLowerCase(); const matches = [...state.indexDocuments.values()].filter((doc) => `${doc.title} ${doc.body}`.toLowerCase().includes(query), ); const offset = Number(input.cursor ?? 0), limit = input.limit ?? 50; return { results: matches.slice(offset, offset + limit).map((doc) => ({ documentKey: doc.documentKey, documentType: doc.documentType, title: doc.title, snippet: doc.body, url: null, fileId: doc.fileId ?? null, })), nextCursor: matches.length > offset + limit ? String(offset + limit) : null, }; }, index: { upsert: async (input) => { for (const document of input.documents) { state.indexDocuments.set(document.documentKey, { documentKey: document.documentKey, documentType: document.documentType, title: document.title, body: document.body, fileId: document.fileId, }); } return { upserted: input.documents.length }; }, delete: async (input) => { let deleted = 0; for (const key of input.documentKeys) { if (state.indexDocuments.delete(key)) deleted += 1; } return { deleted }; }, }, }, notifications: { create: async (input) => { state.notifications.push({ channelKey: input.channelKey, title: input.title, body: input.body, href: input.href, recipients: input.recipients.map((recipient) => recipient.userId), }); }, }, ai: { ...fakeAi(state, options.actor.kind === "user" ? options.actor.userId : null, options.aiModels ?? [], options.onAiJob), generate: async (prompt) => ({ text: options.onAiGenerate ? await options.onAiGenerate(prompt) : `echo: ${prompt}`, }), }, net: { fetch: async (input) => { state.netRequests.push(input); if (!options.onNetFetch) { throw new Error("coline.net.fetch called in a test without an onNetFetch handler."); } return options.onNetFetch(input); }, }, pickers: { file: async () => (options.pickers.file ? options.pickers.file() : null), member: async () => (options.pickers.member ? options.pickers.member() : null), destination: async () => options.pickers.destination ? options.pickers.destination() : null, confirm: async () => (options.pickers.confirm ? options.pickers.confirm() : false), }, navigation: { openFile: async (fileId) => { state.navigations.push({ intent: "openFile", payload: { fileId } }); }, openAppHome: async () => { state.navigations.push({ intent: "openAppHome", payload: {} }); }, navigate: async (href) => { state.navigations.push({ intent: "navigate", payload: { href } }); }, openReference: async (reference) => { state.navigations.push({ intent: "openReference", payload: { reference } }); }, }, realtime: { subscribe: async (event, handler) => { const listeners = state.realtimeListeners.get(event) ?? new Set(); listeners.add(handler); state.realtimeListeners.set(event, listeners); return { unsubscribe: () => { listeners.delete(handler); }, }; }, publish: async (event, data) => { state.published.push({ event, data: data ?? null }); for (const listener of state.realtimeListeners.get(event) ?? []) listener({ event, data: data ?? null }); return { delivered: true }; }, }, events: { emit: async (event, data) => { state.events.push({ kind: event, ...(data ? { payload: data } : {}) }); return { emitted: true }; }, }, context: { publish: async () => undefined, clear: async () => undefined, }, }; }