391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
|
import * as http from 'node:http';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
getTeamsBasePath,
|
|
setClaudeBasePathOverride,
|
|
} from '../../../../src/main/utils/pathDecoder';
|
|
|
|
import { formatProgressDump } from './memberWorkSyncLiveHarness';
|
|
import {
|
|
createOpenCodeLiveHarness,
|
|
type OpenCodeLiveHarness,
|
|
waitForOpenCodeLanesStopped,
|
|
waitUntil,
|
|
} from './openCodeLiveTestHarness';
|
|
|
|
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
|
|
|
const liveDescribe =
|
|
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_LOCAL_PROVIDER_APP_LAUNCH === '1'
|
|
? describe
|
|
: describe.skip;
|
|
|
|
const LOCAL_MODEL = 'llama.cpp/qwen-test:0.5b';
|
|
|
|
liveDescribe('OpenCode local provider app launch live e2e', () => {
|
|
let tempDir: string;
|
|
let tempClaudeRoot: string;
|
|
let fakeServer: FakeOpenAiCompatibleServer | null;
|
|
let harness: OpenCodeLiveHarness | null;
|
|
let teamName: string | null;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-local-provider-app-launch-'));
|
|
tempClaudeRoot = path.join(tempDir, '.claude');
|
|
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
|
setClaudeBasePathOverride(tempClaudeRoot);
|
|
fakeServer = null;
|
|
harness = null;
|
|
teamName = null;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness && teamName) {
|
|
await harness.svc.stopTeam(teamName).catch(() => undefined);
|
|
await waitForOpenCodeLanesStopped(teamName);
|
|
}
|
|
await harness?.dispose().catch(() => undefined);
|
|
await fakeServer?.close().catch(() => undefined);
|
|
setClaudeBasePathOverride(null);
|
|
if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') {
|
|
console.info(`[OpenCodeLocalProviderAppLaunch.live] preserved temp dir: ${tempDir}`);
|
|
} else {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
clearBenignSlowConfigReadWarnings();
|
|
}, 90_000);
|
|
|
|
it(
|
|
'creates and stops an OpenCode team through the app service using a configured authless local provider',
|
|
async () => {
|
|
const projectPath = path.join(tempDir, 'project');
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(projectPath, 'README.md'),
|
|
'# OpenCode local provider app launch live e2e\n',
|
|
'utf8'
|
|
);
|
|
fakeServer = await startFakeOpenAiCompatibleServer();
|
|
await writeFakeLocalOpenCodeConfig({
|
|
projectPath,
|
|
baseUrl: fakeServer.baseUrl,
|
|
});
|
|
|
|
harness = await createOpenCodeLiveHarness({
|
|
tempDir,
|
|
selectedModel: LOCAL_MODEL,
|
|
projectPath,
|
|
});
|
|
|
|
teamName = `opencode-local-provider-app-${Date.now()}`;
|
|
const progressEvents: TeamProvisioningProgress[] = [];
|
|
const { runId } = await harness.svc.createTeam(
|
|
{
|
|
teamName,
|
|
cwd: projectPath,
|
|
providerId: 'opencode',
|
|
model: LOCAL_MODEL,
|
|
skipPermissions: true,
|
|
members: [
|
|
{
|
|
name: 'bob',
|
|
role: 'Developer',
|
|
providerId: 'opencode',
|
|
model: LOCAL_MODEL,
|
|
mcpPolicy: { mode: 'appOnly' },
|
|
},
|
|
],
|
|
},
|
|
(progress) => progressEvents.push(progress)
|
|
);
|
|
|
|
const progressDump = formatProgressDump(progressEvents);
|
|
expect(runId, progressDump).toBeTruthy();
|
|
expect(
|
|
progressEvents.some((progress) =>
|
|
progress.message.includes('OpenCode team launch is ready')
|
|
),
|
|
progressDump
|
|
).toBe(true);
|
|
expect(progressDump).not.toContain('provider not connected');
|
|
expect(progressDump).not.toContain('not authenticated');
|
|
expect(progressDump).not.toContain('OpenCode team launch is not enabled');
|
|
expect(fakeServer.requests, progressDump).toContain('POST /v1/chat/completions');
|
|
|
|
const runtimeSnapshot = await harness.svc.getTeamAgentRuntimeSnapshot(teamName);
|
|
expect(runtimeSnapshot.runId).toBe(runId);
|
|
expect(runtimeSnapshot.members.bob).toMatchObject({
|
|
alive: true,
|
|
providerId: 'opencode',
|
|
laneId: 'primary',
|
|
laneKind: 'primary',
|
|
runtimeModel: LOCAL_MODEL,
|
|
historicalBootstrapConfirmed: true,
|
|
});
|
|
|
|
const deliveryMarker = `local-provider-delivery-${Date.now()}`;
|
|
const chatBodyCountBeforeDelivery = fakeServer.chatBodies.length;
|
|
const delivery = await harness.svc.deliverOpenCodeMemberMessage(teamName, {
|
|
memberName: 'bob',
|
|
messageId: `local-provider-delivery-${Date.now()}`,
|
|
replyRecipient: 'user',
|
|
source: 'manual',
|
|
text: [
|
|
`Local provider delivery marker: ${deliveryMarker}`,
|
|
'Answer with PONG. Do not edit files.',
|
|
].join('\n'),
|
|
});
|
|
expect(delivery.delivered, JSON.stringify(delivery, null, 2)).toBe(true);
|
|
await waitUntil(
|
|
async () =>
|
|
fakeServer!.chatBodies.length > chatBodyCountBeforeDelivery &&
|
|
fakeServer!.chatBodies.some((body) => JSON.stringify(body).includes(deliveryMarker)),
|
|
60_000,
|
|
500
|
|
);
|
|
|
|
await harness.svc.stopTeam(teamName);
|
|
await waitForOpenCodeLanesStopped(teamName);
|
|
clearBenignSlowConfigReadWarnings();
|
|
},
|
|
300_000
|
|
);
|
|
|
|
it(
|
|
'fails app service launch for an unknown local model before creating OpenCode lanes',
|
|
async () => {
|
|
const projectPath = path.join(tempDir, 'unknown-model-project');
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
fakeServer = await startFakeOpenAiCompatibleServer();
|
|
await writeFakeLocalOpenCodeConfig({
|
|
projectPath,
|
|
baseUrl: fakeServer.baseUrl,
|
|
});
|
|
|
|
harness = await createOpenCodeLiveHarness({
|
|
tempDir,
|
|
selectedModel: 'llama.cpp/missing-test:0.5b',
|
|
projectPath,
|
|
});
|
|
|
|
teamName = `opencode-local-provider-unknown-${Date.now()}`;
|
|
const progressEvents: TeamProvisioningProgress[] = [];
|
|
const { runId } = await harness.svc.createTeam(
|
|
{
|
|
teamName,
|
|
cwd: projectPath,
|
|
providerId: 'opencode',
|
|
model: 'llama.cpp/missing-test:0.5b',
|
|
skipPermissions: true,
|
|
members: [
|
|
{
|
|
name: 'bob',
|
|
role: 'Developer',
|
|
providerId: 'opencode',
|
|
model: 'llama.cpp/missing-test:0.5b',
|
|
},
|
|
],
|
|
},
|
|
(progress) => progressEvents.push(progress)
|
|
);
|
|
expect(runId).toBeTruthy();
|
|
await waitUntil(
|
|
async () => progressEvents.some((progress) => progress.state === 'failed'),
|
|
30_000,
|
|
500
|
|
);
|
|
|
|
const progressDump = formatProgressDump(progressEvents);
|
|
expect(progressEvents.some((progress) => progress.state === 'failed'), progressDump).toBe(
|
|
true
|
|
);
|
|
expect(progressDump).toMatch(/missing-test:0\.5b|not available|unavailable/i);
|
|
expect(fakeServer.requests, progressDump).not.toContain('POST /v1/chat/completions');
|
|
await waitUntil(
|
|
async () => {
|
|
const laneIndexPath = path.join(
|
|
getTeamsBasePath(),
|
|
teamName!,
|
|
'runtime',
|
|
'opencode',
|
|
'lanes.json'
|
|
);
|
|
try {
|
|
const parsed = JSON.parse(await fs.readFile(laneIndexPath, 'utf8')) as {
|
|
lanes?: Record<string, unknown>;
|
|
};
|
|
return Object.keys(parsed.lanes ?? {}).length === 0;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return true;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
15_000,
|
|
500
|
|
);
|
|
clearBenignSlowConfigReadWarnings();
|
|
},
|
|
180_000
|
|
);
|
|
});
|
|
|
|
interface FakeOpenAiCompatibleServer {
|
|
baseUrl: string;
|
|
requests: string[];
|
|
chatBodies: unknown[];
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
async function startFakeOpenAiCompatibleServer(): Promise<FakeOpenAiCompatibleServer> {
|
|
const requests: string[] = [];
|
|
const chatBodies: unknown[] = [];
|
|
const server = http.createServer(async (request, response) => {
|
|
requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`);
|
|
if (request.url === '/v1/models') {
|
|
sendJson(response, 200, {
|
|
object: 'list',
|
|
data: [{ id: 'qwen-test:0.5b', object: 'model' }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.method === 'POST' && request.url === '/v1/chat/completions') {
|
|
const body = JSON.parse((await readRequestBody(request)) || '{}') as { stream?: boolean };
|
|
chatBodies.push(body);
|
|
if (body.stream) {
|
|
const created = Math.floor(Date.now() / 1000);
|
|
response.writeHead(200, {
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-cache',
|
|
});
|
|
response.write(
|
|
`data: ${JSON.stringify({
|
|
id: 'chatcmpl-test',
|
|
object: 'chat.completion.chunk',
|
|
created,
|
|
model: 'qwen-test:0.5b',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: { role: 'assistant', content: 'PONG' },
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
})}\n\n`
|
|
);
|
|
response.write(
|
|
`data: ${JSON.stringify({
|
|
id: 'chatcmpl-test',
|
|
object: 'chat.completion.chunk',
|
|
created,
|
|
model: 'qwen-test:0.5b',
|
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
})}\n\n`
|
|
);
|
|
response.end('data: [DONE]\n\n');
|
|
return;
|
|
}
|
|
|
|
sendJson(response, 200, {
|
|
id: 'chatcmpl-test',
|
|
object: 'chat.completion',
|
|
model: 'qwen-test:0.5b',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: 'assistant', content: 'PONG' },
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
sendJson(response, 404, { error: { message: 'not found' } });
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
await closeServer(server);
|
|
throw new Error('Fake OpenAI-compatible server did not bind to a TCP port');
|
|
}
|
|
|
|
return {
|
|
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
requests,
|
|
chatBodies,
|
|
close: () => closeServer(server),
|
|
};
|
|
}
|
|
|
|
async function writeFakeLocalOpenCodeConfig(input: {
|
|
projectPath: string;
|
|
baseUrl: string;
|
|
}): Promise<void> {
|
|
const configPath = path.join(input.projectPath, 'opencode.json');
|
|
await fs.writeFile(
|
|
configPath,
|
|
`${JSON.stringify(
|
|
{
|
|
provider: {
|
|
'llama.cpp': {
|
|
npm: '@ai-sdk/openai-compatible',
|
|
options: {
|
|
baseURL: `${input.baseUrl}/v1`,
|
|
},
|
|
models: {
|
|
'qwen-test:0.5b': {},
|
|
},
|
|
},
|
|
},
|
|
model: LOCAL_MODEL,
|
|
small_model: LOCAL_MODEL,
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function readRequestBody(request: http.IncomingMessage): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
return Buffer.concat(chunks).toString('utf8');
|
|
}
|
|
|
|
function sendJson(response: http.ServerResponse, status: number, body: unknown): void {
|
|
response.writeHead(status, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify(body));
|
|
}
|
|
|
|
function closeServer(server: http.Server): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
|
|
function clearBenignSlowConfigReadWarnings(): void {
|
|
const warn = vi.mocked(console.warn);
|
|
if (
|
|
warn.mock.calls.length > 0 &&
|
|
warn.mock.calls.every((call) =>
|
|
call.map((part) => String(part)).join(' ').includes('[getConfig] slow read diag=')
|
|
)
|
|
) {
|
|
warn.mockClear();
|
|
}
|
|
}
|