import { createFakeWorkspaceCapabilities } from "./workspace-capabilities"; import type { ColineTask, ColineCalendarEvent, ColineMessage } from "../capabilities/workspace"; import type { ColineActorRef, ColineCapabilities, ColineFileSummaryV2, ColineMemberSummary, ColineNetFetchInput, ColineNetFetchResult, ColineRecordEnvelope, } from "../capabilities/types"; /** * 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; /** 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>; confirm?: () => Promise; }; } 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 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[] = []; private fileCounter = 0; private recordCounter = 0; 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"]; pickers: NonNullable; }, ): ColineCapabilities { const now = () => new Date().toISOString(); 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: { 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) => { const collection = () => { let map = state.records.get(name); if (!map) { map = new Map(); state.records.set(name, map); } return map; }; const wrap = (record: ColineRecordEnvelope>) => structuredClone(record) as unknown as ColineRecordEnvelope; return { insert: async (data: TData) => { const record: ColineRecordEnvelope> = { id: state.nextRecordId(), appId: "app_test", installId: options.installId, collection: name, createdBy: options.actor, createdAt: now(), updatedAt: now(), data: structuredClone(data) as Record, }; collection().set(record.id, record); return wrap(record); }, get: async (recordId: string) => { const record = collection().get(recordId); return record ? wrap(record) : null; }, update: async (recordId: string, data: Partial) => { const record = collection().get(recordId); if (!record) throw new Error(`Test record not found: ${recordId}`); record.data = { ...record.data, ...(data as Record) }; record.updatedAt = now(); return wrap(record); }, delete: async (recordId: string) => { collection().delete(recordId); }, query: async (query) => { const { matchesRecordWhere } = await import("../capabilities/record-query"); const rows = [...collection().values()].filter((row) => matchesRecordWhere(row.data, query?.where), ); rows.sort((a, b) => { const av = query?.orderBy ? a.data[query.orderBy] : a.updatedAt; const bv = query?.orderBy ? b.data[query.orderBy] : b.updatedAt; const comparison = String(av ?? "").localeCompare(String(bv ?? "")) || a.id.localeCompare(b.id); return query?.order === "asc" ? comparison : -comparison; }); const offset = Number(query?.cursor ?? 0); if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid record cursor."); const limit = query?.limit ?? 50; if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new Error("Record limit must be 1–200."); return { records: rows.slice(offset, offset + limit).map(wrap), nextCursor: rows.length > offset + limit ? String(offset + limit) : null, }; }, }; }, }, 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: { 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), 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 () => ({ unsubscribe: () => undefined }), publish: async (event, data) => { state.published.push({ event, data: data ?? null }); return { delivered: true }; }, }, events: { emit: async (event, data) => { state.events.push({ kind: event, ...(data ? { payload: data } : {}) }); return { emitted: true }; }, }, }; }