import { createFakeCollection } from "./fake-collection"; 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 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[] = []; 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) => createFakeCollection(state, name, options.actor, options.installId), }, 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 (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 }; }, }, }; }