Files
spoon/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts
T

191 lines
5.8 KiB
TypeScript

import { getFunctionName } from 'convex/server';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
const mocks = vi.hoisted(() => ({
getWorktreeDiff: vi.fn(() =>
Promise.resolve({ exitCode: 0, output: 'unexpected diff' }),
),
mutation: vi.fn(),
stopWorkspaceContainer: vi.fn(() => Promise.resolve()),
streamExecInContainer: vi.fn(),
}));
vi.mock('convex/browser', () => ({
ConvexHttpClient: vi.fn(
class ConvexHttpClient {
mutation = mocks.mutation;
},
),
}));
vi.mock('../../src/env', () => ({
env: {
buildCreatedAt: 'test',
buildSha: 'test',
containerAccess: 'network',
convexUrl: 'http://convex.test',
jobTimeoutMs: 1000,
runtime: 'docker',
workerId: 'worker-1',
workerToken: 'token',
},
}));
vi.mock('../../src/git', () => ({
cloneRepository: vi.fn(),
commitAndPush: vi.fn(),
getStatus: vi.fn(),
getWorktreeDiff: mocks.getWorktreeDiff,
run: vi.fn(),
}));
vi.mock('../../src/github', () => ({
getInstallationToken: vi.fn(),
openDraftPullRequest: vi.fn(),
}));
vi.mock('../../src/opencode-session', () => ({
abortOpenCodeSession: vi.fn(),
createOpenCodeSession: vi.fn(),
promptOpenCodeSession: vi.fn(),
replyOpenCodePermission: vi.fn(),
}));
vi.mock('../../src/runtime/docker', () => ({
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
runExecInContainer: vi.fn(),
startWorkspaceContainer: vi.fn(),
stopWorkspaceContainer: mocks.stopWorkspaceContainer,
streamExecInContainer: mocks.streamExecInContainer,
}));
vi.mock('../../src/user-container', () => ({
acquireUserBox: vi.fn(),
runningBoxUsernames: vi.fn(() => new Set()),
}));
vi.mock('../../src/user-environment', () => ({
fetchUserEnvironment: vi.fn(),
materializeUserHome: vi.fn(),
}));
const decision = {
decision: 'sync',
risk: 'low',
summary: 'Safe to sync.',
ignoredCommitShas: [],
ignoredReason: '',
recommendedAction: 'Sync upstream.',
requiresUserApproval: false,
};
const load = async () => await import('../../src/worker');
afterEach(async () => {
const worker = await load();
worker._resetActiveWorkspacesForTests();
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('maintenance workspace cleanup', () => {
test.each(['mark', 'container'] as const)(
'releases and removes the workspace when %s teardown fails',
async (failingStep) => {
const worker = await load();
const release = vi.fn();
const close = vi.fn();
const jobId = `maintenance-${failingStep}`;
mocks.mutation.mockImplementation((reference) => {
if (
failingStep === 'mark' &&
getFunctionName(reference) === 'agentJobs:markWorkspaceStopped'
) {
return Promise.reject(new Error('status teardown failed'));
}
return Promise.resolve('message-1');
});
mocks.stopWorkspaceContainer.mockImplementation(() =>
failingStep === 'container'
? Promise.reject(new Error('container teardown failed'))
: Promise.resolve(),
);
mocks.streamExecInContainer.mockImplementation(async (args) => {
await args.onStdoutLine(
JSON.stringify({
type: 'item.completed',
item: {
type: 'agent_message',
text: JSON.stringify(decision),
},
}),
);
return { exitCode: 0, output: '' };
});
worker._setActiveWorkspaceForTests(jobId, {
claim: {
job: {
_id: jobId as Id<'agentJobs'>,
prompt: 'Review maintenance changes',
jobType: 'maintenance_review',
baseBranch: 'main',
workBranch: 'spoon/maintenance',
forkOwner: 'team',
forkRepo: 'spoon',
upstreamOwner: 'upstream',
upstreamRepo: 'spoon',
},
spoon: { name: 'Spoon' },
openai: {
model: 'gpt-5',
reasoningEffort: 'medium',
},
aiProviderProfile: {
id: 'profile-1',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
},
workdir: '/tmp/workspace',
homeDir: '/tmp/workspace',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/workspace/Code/spoon',
boxName: 'spoon-box-tester',
boxHandle: { boxName: 'spoon-box-tester', release },
githubToken: 'github-token',
redact: (value: string) => value,
runtime: 'codex',
containerName: 'spoon-agent-job-maintenance',
opencodeSession: {
client: {} as never,
sessionId: 'session-1',
close,
},
});
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await expect(
worker.sendWorkspaceMessage(jobId, 'Review maintenance changes'),
).resolves.toBeUndefined();
expect(release).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
expect(mocks.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
expect(
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
).toBe(false);
await expect(worker.stopWorkspace(jobId)).rejects.toThrow(
'Agent workspace is not active on this worker.',
);
expect(consoleError).toHaveBeenCalledTimes(1);
},
);
});