first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const e2eDir = path.resolve(__dirname, '..');
|
||||
|
||||
const targets = [
|
||||
path.join(e2eDir, '.od-data'),
|
||||
path.join(e2eDir, 'test-results'),
|
||||
path.join(e2eDir, 'reports', 'test-results'),
|
||||
path.join(e2eDir, 'reports', 'html'),
|
||||
path.join(e2eDir, 'reports', 'playwright-html-report'),
|
||||
path.join(e2eDir, 'reports', 'results.json'),
|
||||
path.join(e2eDir, 'reports', 'junit.xml'),
|
||||
path.join(e2eDir, 'reports', 'latest.md'),
|
||||
path.join(e2eDir, '.DS_Store'),
|
||||
];
|
||||
|
||||
for (const target of targets) {
|
||||
await rm(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await mkdir(path.join(e2eDir, 'reports'), { recursive: true });
|
||||
|
||||
// Recreate runtime roots so local inspection stays predictable even before
|
||||
// Playwright or the daemon materializes them.
|
||||
await mkdir(path.join(e2eDir, '.od-data'), { recursive: true });
|
||||
await mkdir(path.join(e2eDir, 'reports', 'test-results'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Best-effort removal of accidental empty directories directly under the
|
||||
// test data root. This keeps old project ids from piling up across runs.
|
||||
const projectsRoot = path.join(e2eDir, '.od-data', 'projects');
|
||||
try {
|
||||
const entries = await readdir(projectsRoot, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) =>
|
||||
rm(path.join(projectsRoot, entry.name), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
||||
if (code !== 'ENOENT') {
|
||||
console.warn('Failed to clean stale e2e project dirs:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type http from 'node:http';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface AgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
bin: string;
|
||||
available: boolean;
|
||||
path?: string;
|
||||
version?: string | null;
|
||||
models?: Array<{ id: string; label?: string }>;
|
||||
streamFormat?: string;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents: AgentInfo[];
|
||||
}
|
||||
|
||||
interface ParsedSseEvent {
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type StartServer = (options: { port: number; returnServer: true }) => Promise<http.Server | undefined>;
|
||||
type CloseDatabase = () => void;
|
||||
|
||||
const liveTimeoutMs = Number(process.env.OD_RUNTIME_LIVE_TIMEOUT_MS || 180_000);
|
||||
const requestedRuntimeIds = parseRuntimeIds(process.env.OD_E2E_RUNTIMES);
|
||||
const maxRuntimeCount = 8;
|
||||
const marker = 'OD_RUNTIME_ADAPTER_LIVE_OK';
|
||||
|
||||
let baseUrl: string;
|
||||
let server: http.Server | undefined;
|
||||
let startServer: StartServer;
|
||||
let closeDatabase: CloseDatabase | undefined;
|
||||
let detectedAgents: AgentInfo[] | undefined;
|
||||
let dataDir: string;
|
||||
|
||||
test.before(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'od-runtime-adapter-live-'));
|
||||
process.env.OD_DATA_DIR = dataDir;
|
||||
({ startServer } = await import('../../apps/daemon/dist/server.js') as { startServer: StartServer });
|
||||
({ closeDatabase } = await import('../../apps/daemon/dist/db.js') as { closeDatabase: CloseDatabase });
|
||||
const started = await startServer({ port: 0, returnServer: true });
|
||||
if (started == null) {
|
||||
throw new Error('startServer did not return a server handle');
|
||||
}
|
||||
const address = started.address();
|
||||
if (address == null || typeof address === 'string') {
|
||||
throw new Error('startServer did not bind to a TCP port');
|
||||
}
|
||||
server = started;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server?.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
closeDatabase?.();
|
||||
if (dataDir) {
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime adapter live detection flow exposes installed runtimes', async () => {
|
||||
log('detect', 'starting runtime detection via /api/agents');
|
||||
const res = await fetch(`${baseUrl}/api/agents`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await readAgentsResponse(res);
|
||||
assert.ok(Array.isArray(body.agents));
|
||||
assert.ok(body.agents.length > 0);
|
||||
|
||||
detectedAgents = body.agents;
|
||||
const available = body.agents.filter((agent) => agent.available);
|
||||
|
||||
for (const agent of body.agents) {
|
||||
const status = agent.available ? 'available' : 'unavailable';
|
||||
const version = agent.version ? ` version=${agent.version}` : '';
|
||||
const resolvedPath = agent.path ? ` path=${agent.path}` : '';
|
||||
log(
|
||||
'detect',
|
||||
`${agent.id}: ${status}${version}${resolvedPath} models=${agent.models?.length ?? 0} stream=${agent.streamFormat}`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
available.length > 0,
|
||||
'Install at least one supported runtime CLI on PATH: claude, codex, gemini, opencode, hermes, kimi, cursor-agent, or qwen.',
|
||||
);
|
||||
|
||||
for (const agent of body.agents) {
|
||||
assert.equal(typeof agent.id, 'string');
|
||||
assert.equal(typeof agent.name, 'string');
|
||||
assert.equal(typeof agent.bin, 'string');
|
||||
assert.equal(typeof agent.available, 'boolean');
|
||||
assert.ok(Array.isArray(agent.models));
|
||||
assert.ok(agent.models.some((model) => model.id === 'default'));
|
||||
assert.equal(typeof agent.streamFormat, 'string');
|
||||
if (agent.available) {
|
||||
assert.equal(typeof agent.path, 'string');
|
||||
const resolvedPath = agent.path;
|
||||
assert.ok(resolvedPath && resolvedPath.length > 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime adapter live run flow streams a successful response for every available runtime', { timeout: liveTimeoutMs * maxRuntimeCount + 30_000 }, async () => {
|
||||
if (!detectedAgents) {
|
||||
log('run', 'detection cache empty; fetching /api/agents before run flow');
|
||||
const res = await fetch(`${baseUrl}/api/agents`);
|
||||
detectedAgents = (await readAgentsResponse(res)).agents;
|
||||
}
|
||||
|
||||
const requestedSet = requestedRuntimeIds ? new Set(requestedRuntimeIds) : null;
|
||||
const availableAgents = detectedAgents.filter(
|
||||
(agent) => agent.available && (!requestedSet || requestedSet.has(agent.id)),
|
||||
);
|
||||
|
||||
if (requestedSet) {
|
||||
log('run', `runtime filter=${requestedRuntimeIds?.join(',')}`);
|
||||
for (const id of requestedSet) {
|
||||
assert.ok(
|
||||
detectedAgents.some((agent) => agent.id === id),
|
||||
`Requested runtime ${id} is missing from /api/agents.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of detectedAgents) {
|
||||
if (agent.available) {
|
||||
if (!requestedSet || requestedSet.has(agent.id)) {
|
||||
log('run', `${agent.id}: queued`);
|
||||
} else {
|
||||
log('run', `${agent.id}: skipped by runtime filter`);
|
||||
}
|
||||
} else {
|
||||
log('run', `${agent.id}: skipped because runtime is unavailable`);
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
availableAgents.length > 0,
|
||||
requestedSet
|
||||
? `Requested runtimes unavailable: ${requestedRuntimeIds?.join(',')}.`
|
||||
: 'Available runtime required from /api/agents.',
|
||||
);
|
||||
|
||||
for (const agent of availableAgents) {
|
||||
await runRuntime(agent);
|
||||
}
|
||||
});
|
||||
|
||||
async function runRuntime(agent: AgentInfo): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
log('run', `${agent.id}: starting /api/chat live run`);
|
||||
|
||||
const projectId = `runtime-adapter-live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const events: ParsedSseEvent[] = [];
|
||||
const abort = AbortSignal.timeout(liveTimeoutMs);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
signal: abort,
|
||||
body: JSON.stringify({
|
||||
agentId: agent.id,
|
||||
projectId,
|
||||
model: 'default',
|
||||
message: `Reply with exactly this token and nothing else: ${marker}`,
|
||||
systemPrompt: [
|
||||
'You are running a local runtime-adapter live smoke test.',
|
||||
'Produce a minimal text-only response.',
|
||||
'Do not create, edit, delete, or inspect files.',
|
||||
].join('\n'),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get('content-type') || '', /text\/event-stream/);
|
||||
assert.ok(res.body, 'SSE response should include a readable body.');
|
||||
|
||||
await collectSseEvents(res, events, agent.id);
|
||||
} finally {
|
||||
await fs.rm(path.join(dataDir, 'projects', projectId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const start = events.find((event) => event.event === 'start');
|
||||
assert.ok(start, 'SSE stream should include a start event.');
|
||||
assert.equal(start.data.agentId, agent.id);
|
||||
assert.equal(start.data.projectId, projectId);
|
||||
log('run', `${agent.id}: start event cwd=${String(start.data.cwd ?? '')}`);
|
||||
|
||||
const end = events.find((event) => event.event === 'end');
|
||||
assert.ok(end, 'SSE stream should include an end event.');
|
||||
assert.equal(end.data.code, 0, renderEvents(events));
|
||||
log('run', `${agent.id}: end event code=${String(end.data.code)} signal=${String(end.data.signal ?? 'none')}`);
|
||||
|
||||
const text = events
|
||||
.map((event) => {
|
||||
if (event.event === 'stdout') return stringData(event.data.chunk);
|
||||
if (event.event === 'agent') return stringData(event.data.text) || stringData(event.data.delta);
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
assert.match(text, new RegExp(marker), renderEvents(events));
|
||||
log('run', `${agent.id}: passed in ${Date.now() - startedAt}ms`);
|
||||
}
|
||||
|
||||
async function collectSseEvents(res: Response, events: ParsedSseEvent[], agentId: string): Promise<void> {
|
||||
const reader = res.body?.getReader();
|
||||
assert.ok(reader, 'SSE response should include a readable body.');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() || '';
|
||||
for (const chunk of chunks) {
|
||||
const parsed = parseSseEvent(chunk);
|
||||
if (parsed) {
|
||||
events.push(parsed);
|
||||
logSseProgress(agentId, parsed, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
const parsed = parseSseEvent(buffer);
|
||||
if (parsed) {
|
||||
events.push(parsed);
|
||||
logSseProgress(agentId, parsed, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseEvent(chunk: string): ParsedSseEvent | null {
|
||||
const lines = chunk.split('\n');
|
||||
if (lines.every((line) => line === '' || line.startsWith(':'))) return null;
|
||||
|
||||
const eventLine = lines.find((line) => line.startsWith('event: '));
|
||||
const dataLine = lines.find((line) => line.startsWith('data: '));
|
||||
if (!eventLine || !dataLine) return null;
|
||||
return {
|
||||
event: eventLine.slice('event: '.length),
|
||||
data: JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEvents(events: ParsedSseEvent[]): string {
|
||||
return JSON.stringify(events, null, 2).slice(0, 8000);
|
||||
}
|
||||
|
||||
function parseRuntimeIds(value: string | undefined): string[] | null {
|
||||
if (!value) return null;
|
||||
const ids = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
function log(stage: string, message: string): void {
|
||||
console.log(`[runtime-adapter:e2e:${stage}] ${message}`);
|
||||
}
|
||||
|
||||
function logSseProgress(agentId: string, event: ParsedSseEvent, seen: Set<string>): void {
|
||||
if (event.event === 'start' && !seen.has('start')) {
|
||||
seen.add('start');
|
||||
log('run', `${agentId}: received start event`);
|
||||
return;
|
||||
}
|
||||
if (event.event === 'stdout' && !seen.has('stdout')) {
|
||||
seen.add('stdout');
|
||||
log('run', `${agentId}: received stdout stream`);
|
||||
return;
|
||||
}
|
||||
const type = stringData(event.data.type) || 'event';
|
||||
if (event.event === 'agent' && !seen.has(`agent:${type}`)) {
|
||||
seen.add(`agent:${type}`);
|
||||
log('run', `${agentId}: received agent event type=${type || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
if (event.event === 'stderr' && !seen.has('stderr')) {
|
||||
seen.add('stderr');
|
||||
log('run', `${agentId}: received stderr stream`);
|
||||
return;
|
||||
}
|
||||
if (event.event === 'error') {
|
||||
log('run', `${agentId}: received error event ${stringData(event.data.message)}`.trim());
|
||||
return;
|
||||
}
|
||||
if (event.event === 'end' && !seen.has('end')) {
|
||||
seen.add('end');
|
||||
log('run', `${agentId}: received end event`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readAgentsResponse(res: Response): Promise<AgentsResponse> {
|
||||
const body = await res.json() as Partial<AgentsResponse>;
|
||||
return { agents: Array.isArray(body.agents) ? body.agents : [] };
|
||||
}
|
||||
|
||||
function stringData(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
Reference in New Issue
Block a user