Files
Gabriel Brown a6f7ea7f78
Build and Push Spoon Images / quality (push) Successful in 2m22s
Build and Push Spoon Images / build-images (push) Successful in 23m10s
Clean up old stuff & fix ui errors
2026-06-23 14:57:05 -04:00

479 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof convexTest>, email: string) =>
await t.mutation(async (ctx) => {
return await ctx.db.insert('users', { email, name: email });
});
const authed = (t: ReturnType<typeof convexTest>, 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<typeof convexTest>,
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 users 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 users 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);
});
});