import { convexTest } from 'convex-test'; import { describe, expect, test } from 'vitest'; import type { Id } from '../../convex/_generated/dataModel.js'; import { api } from '../../convex/_generated/api.js'; import schema from '../../convex/schema'; const modules = import.meta.glob('../../convex/**/*.*s'); const createUser = async (t: ReturnType, email: string) => await t.mutation(async (ctx) => { return await ctx.db.insert('users', { email, name: email }); }); const authed = (t: ReturnType, userId: string) => t.withIdentity({ subject: `${userId}|session`, issuer: 'https://convex.test', }); const spoonInput = { name: 'Editor Spoon', provider: 'gitea' as const, upstreamOwner: 'upstream', upstreamRepo: 'editor', upstreamDefaultBranch: 'main', upstreamUrl: 'https://git.example.com/upstream/editor', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://git.example.com/team/editor-spoon', visibility: 'private' as const, maintenanceMode: 'watch' as const, syncCadence: 'daily' as const, productionRefStrategy: 'default_branch' as const, }; const githubSpoonInput = { ...spoonInput, provider: 'github' as const, upstreamUrl: 'https://github.com/upstream/editor', forkUrl: 'https://github.com/team/editor-spoon', }; const createAgentJob = async ( t: ReturnType, args: { ownerId: Id<'users'>; spoonId: Id<'spoons'>; status: 'running' | 'failed' | 'cancelled'; workspaceStatus?: 'active' | 'stopped' | 'failed' | 'expired'; threadId?: Id<'threads'>; }, ) => await t.mutation(async (ctx) => { const now = Date.now(); const requestId = await ctx.db.insert('agentRequests', { spoonId: args.spoonId, ownerId: args.ownerId, prompt: 'Clean this workspace', status: 'running', createdAt: now, updatedAt: now, }); const jobId = await ctx.db.insert('agentJobs', { spoonId: args.spoonId, ownerId: args.ownerId, agentRequestId: requestId, threadId: args.threadId, status: args.status, prompt: 'Clean this workspace', runtime: 'opencode', workspaceStatus: args.workspaceStatus, baseBranch: 'main', workBranch: 'spoon/test', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://git.example.com/team/editor-spoon', upstreamOwner: 'upstream', upstreamRepo: 'editor', selectedSecretIds: [], model: 'openai/gpt-5.1-codex', reasoningEffort: 'medium', createdAt: now, updatedAt: now, }); await ctx.db.patch(requestId, { agentJobId: jobId }); await ctx.db.insert('agentJobMessages', { jobId, spoonId: args.spoonId, ownerId: args.ownerId, role: 'assistant', content: 'done', status: 'completed', createdAt: now, updatedAt: now, }); return jobId; }); describe('convex-test harness', () => { test('boots and executes against the project schema', async () => { const t = convexTest(schema, modules); expect(await t.run(() => Promise.resolve(42))).toBe(42); }); test('requires authentication to create a Spoon', async () => { const t = convexTest(schema, modules); await expect( t.mutation(api.spoons.createManual, spoonInput), ).rejects.toThrow('Not authenticated.'); }); test('creates and lists Spoons for the current user', async () => { const t = convexTest(schema, modules); const userId = await createUser(t, 'one@example.com'); const session = authed(t, userId); const spoonId = await session.mutation(api.spoons.createManual, spoonInput); const spoons = await session.query(api.spoons.listMine, {}); expect(spoons).toHaveLength(1); expect(spoons[0]?._id).toBe(spoonId); expect(spoons[0]?.ownerId).toBe(userId); }); test('lists effective drift after ignored upstream changes', async () => { const t = convexTest(schema, modules); const ownerId = await createUser(t, 'owner@example.com'); const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, githubSpoonInput, ); await t.mutation(async (ctx) => { const now = Date.now(); await ctx.db.insert('spoonRepositoryStates', { spoonId, ownerId: ownerId as Id<'users'>, upstreamFullName: 'upstream/editor', forkFullName: 'team/editor-spoon', upstreamDefaultBranch: 'main', forkDefaultBranch: 'main', upstreamHeadSha: 'upstream-head', forkHeadSha: 'fork-head', upstreamAheadBy: 2, forkAheadBy: 1, status: 'diverged', openForkPullRequestCount: 0, openUpstreamPullRequestCount: 0, refreshedAt: now, createdAt: now, updatedAt: now, }); await ctx.db.insert('ignoredUpstreamChanges', { spoonId, ownerId: ownerId as Id<'users'>, upstreamTo: 'upstream-head', commitShas: ['abc123'], reason: 'irrelevant', decidedBy: 'user', createdAt: now, }); }); const spoons = await authed(t, ownerId).query( api.spoons.listMineWithState, {}, ); expect(spoons[0]?.rawUpstreamAheadBy).toBe(2); expect(spoons[0]?.effectiveUpstreamAheadBy).toBe(1); expect(spoons[0]?.ignoredUpstreamCount).toBe(1); }); test('does not allow reading another user’s Spoon', async () => { const t = convexTest(schema, modules); const ownerId = await createUser(t, 'owner@example.com'); const otherId = await createUser(t, 'other@example.com'); const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); await expect( authed(t, otherId).query(api.spoons.get, { spoonId }), ).rejects.toThrow('Spoon not found.'); }); test('thread notes are completed when no workspace handles them', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, githubSpoonInput, ); const threadId = await t.mutation(async (ctx) => { return await ctx.db.insert('threads', { ownerId, spoonId, title: 'Manual note', source: 'user_request', status: 'open', priority: 'normal', createdAt: Date.now(), updatedAt: Date.now(), }); }); const messageId = await authed(t, ownerId).mutation( api.threads.appendUserMessage, { threadId, content: 'extra context', }, ); const message = await t.run(async (ctx) => await ctx.db.get(messageId)); expect(message?.status).toBe('completed'); }); test('requires Spoon ownership for agent requests', async () => { const t = convexTest(schema, modules); const ownerId = await createUser(t, 'owner@example.com'); const otherId = await createUser(t, 'other@example.com'); const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); await expect( authed(t, otherId).mutation(api.agentRequests.create, { spoonId, prompt: 'Add a settings page', }), ).rejects.toThrow('Spoon not found.'); }); test('deletes terminal workspaces and associated rows', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const jobId = await createAgentJob(t, { ownerId, spoonId, status: 'failed', workspaceStatus: 'failed', }); await authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); const messages = await t.run( async (ctx) => await ctx.db .query('agentJobMessages') .withIndex('by_job', (q) => q.eq('jobId', jobId)) .collect(), ); expect(job).toBeNull(); expect(messages).toHaveLength(0); }); test('does not delete active workspaces', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const jobId = await createAgentJob(t, { ownerId, spoonId, status: 'running', workspaceStatus: 'active', }); await expect( authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }), ).rejects.toThrow('Only stopped, cancelled, failed, or expired workspaces'); }); test('does not delete another user’s workspace', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const otherId = (await createUser(t, 'other@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const jobId = await createAgentJob(t, { ownerId, spoonId, status: 'cancelled', workspaceStatus: 'stopped', }); await expect( authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }), ).rejects.toThrow('Agent job not found.'); }); test('persists and clamps workspace agent thread width', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const jobId = await createAgentJob(t, { ownerId, spoonId, status: 'running', workspaceStatus: 'active', }); const defaults = await authed(t, ownerId).query( api.agentJobs.getWorkspaceUiState, { jobId }, ); expect(defaults.agentThreadWidth).toBe(420); await authed(t, ownerId).mutation(api.agentJobs.patchWorkspaceUiState, { jobId, agentThreadWidth: 999, }); const wide = await authed(t, ownerId).query( api.agentJobs.getWorkspaceUiState, { jobId }, ); expect(wide.agentThreadWidth).toBe(720); await authed(t, ownerId).mutation(api.agentJobs.patchWorkspaceUiState, { jobId, agentThreadWidth: 100, }); const narrow = await authed(t, ownerId).query( api.agentJobs.getWorkspaceUiState, { jobId }, ); expect(narrow.agentThreadWidth).toBe(320); }); test('deletes terminal threads and attached terminal workspace rows', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const threadId = await t.mutation(async (ctx) => { return await ctx.db.insert('threads', { ownerId, spoonId, title: 'Failed attempt', source: 'user_request', status: 'failed', priority: 'normal', createdAt: Date.now(), updatedAt: Date.now(), }); }); const jobId = await createAgentJob(t, { ownerId, spoonId, threadId, status: 'failed', workspaceStatus: 'failed', }); await t.mutation(async (ctx) => { await ctx.db.patch(threadId, { latestAgentJobId: jobId }); }); await authed(t, ownerId).mutation(api.threads.deleteThread, { threadId }); const [thread, job, messages] = await t.run(async (ctx) => { const rows = await ctx.db .query('agentJobMessages') .withIndex('by_job', (q) => q.eq('jobId', jobId)) .collect(); return [await ctx.db.get(threadId), await ctx.db.get(jobId), rows]; }); expect(thread).toBeNull(); expect(job).toBeNull(); expect(messages).toHaveLength(0); }); test('does not delete threads with active workspace runs', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const threadId = await t.mutation(async (ctx) => { return await ctx.db.insert('threads', { ownerId, spoonId, title: 'Active attempt', source: 'user_request', status: 'running', priority: 'normal', createdAt: Date.now(), updatedAt: Date.now(), }); }); const jobId = await createAgentJob(t, { ownerId, spoonId, threadId, status: 'running', workspaceStatus: 'active', }); await t.mutation(async (ctx) => { await ctx.db.patch(threadId, { latestAgentJobId: jobId }); }); await expect( authed(t, ownerId).mutation(api.threads.deleteThread, { threadId }), ).rejects.toThrow('Stop or cancel active workspace runs'); }); test('queues a new thread job after the previous job is terminal', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, githubSpoonInput, ); await t.mutation(async (ctx) => { await ctx.db.insert('aiProviderProfiles', { ownerId, name: 'Test provider', provider: 'openai', authType: 'none', defaultModel: 'gpt-5.1-codex', modelOptions: ['gpt-5.1-codex'], reasoningEffort: 'medium', enabled: true, createdAt: Date.now(), updatedAt: Date.now(), }); }); const threadId = await t.mutation(async (ctx) => { return await ctx.db.insert('threads', { ownerId, spoonId, title: 'Retryable thread', summary: 'try again', source: 'user_request', status: 'failed', priority: 'normal', createdAt: Date.now(), updatedAt: Date.now(), }); }); const failedJobId = await createAgentJob(t, { ownerId, spoonId, status: 'failed', workspaceStatus: 'failed', }); await t.mutation(async (ctx) => { await ctx.db.patch(threadId, { latestAgentJobId: failedJobId }); }); const newJobId = await authed(t, ownerId).mutation( api.agentJobs.createForThread, { threadId, jobType: 'user_change', }, ); expect(newJobId).not.toBe(failedJobId); }); });