Files
waggle-os/tests/e2e/launcher-real-tool-lifecycle.spec.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

141 lines
4.8 KiB
TypeScript

import { expect, test } from '@playwright/test';
const SKIP_PARAMS = 'skipOnboarding=true&skipBoot=true&skipBriefing=true&tier=power';
const PREFERRED_TOOL_IDS = ['openclaw', 'claude-code', 'hermes'] as const;
const SAFE_ARGS_BY_TOOL: Record<string, string[]> = {
openclaw: ['--version'],
'claude-code': ['--version'],
hermes: ['--version'],
};
type DetectedTool = {
id: string;
displayName: string;
installed: boolean;
installedPath: string | null;
launchable?: boolean;
};
type DetectionEnvelope = {
tools: DetectedTool[];
};
type LaunchEnvelope = {
ok: boolean;
pid: number | null;
error?: string;
};
function routeWithSkip(route: string): string {
const sep = route.includes('?') ? '&' : '?';
return `${route}${sep}${SKIP_PARAMS}`;
}
function chooseSafeTool(tools: DetectedTool[]): DetectedTool | undefined {
return PREFERRED_TOOL_IDS
.map((id) => tools.find((tool) => tool.id === id && tool.installed && tool.installedPath && tool.launchable))
.find((tool): tool is DetectedTool => Boolean(tool));
}
async function readObservedStream(
baseURL: string,
pid: number,
): Promise<{ lines: string[]; exitCode: number | null | undefined }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const lines: string[] = [];
let exitCode: number | null | undefined;
try {
const response = await fetch(new URL(`/api/tools/stream?pid=${pid}`, baseURL), {
signal: controller.signal,
});
expect(response.ok).toBe(true);
expect(response.body).toBeTruthy();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (exitCode === undefined) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let eventEnd = buffer.indexOf('\n\n');
while (eventEnd >= 0) {
const rawEvent = buffer.slice(0, eventEnd);
buffer = buffer.slice(eventEnd + 2);
eventEnd = buffer.indexOf('\n\n');
let event = 'message';
let data = '';
for (const line of rawEvent.split('\n')) {
if (line.startsWith('event:')) event = line.slice('event:'.length).trim();
if (line.startsWith('data:')) data += line.slice('data:'.length).trim();
}
if (event === 'line') {
lines.push((JSON.parse(data) as { line: string }).line);
}
if (event === 'exit') {
exitCode = (JSON.parse(data) as { code: number | null }).code;
break;
}
}
}
} finally {
clearTimeout(timeout);
}
return { lines, exitCode };
}
test.describe('Launcher real tool lifecycle', () => {
test('renders a real detected CLI and observes a safe launch to exit', async ({ baseURL, page, request }) => {
test.skip(
process.env.WAGGLE_E2E_REAL_TOOLS !== '1',
'Set WAGGLE_E2E_REAL_TOOLS=1 on a machine with Claude, Hermes, or OpenClaw installed.',
);
const root = baseURL ?? process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const detectionResponse = await request.get('/api/tools/detect');
expect(detectionResponse.ok()).toBe(true);
const detection = await detectionResponse.json() as DetectionEnvelope;
const tool = chooseSafeTool(detection.tools);
expect(tool, 'expected at least one safe real CLI tool to be installed').toBeTruthy();
await page.goto(routeWithSkip('/launcher?watch=1'), { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
await expect(page.getByText('Tool Launcher')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(tool!.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });
const launchResponse = await request.post('/api/tools/launch', {
data: {
id: tool!.id,
installedPath: tool!.installedPath,
args: SAFE_ARGS_BY_TOOL[tool!.id],
workspaceId: 'e2e-real-tool-lifecycle',
observe: true,
},
});
expect(launchResponse.status()).toBe(202);
const launch = await launchResponse.json() as LaunchEnvelope;
expect(launch, launch.error).toMatchObject({ ok: true });
expect(launch.pid).toEqual(expect.any(Number));
const stream = await readObservedStream(root, launch.pid!);
expect(stream.exitCode).toBe(0);
expect(stream.lines.join('\n').trim().length).toBeGreaterThan(0);
await expect.poll(async () => {
const processesResponse = await request.get('/api/tools/processes');
expect(processesResponse.ok()).toBe(true);
const body = await processesResponse.json() as {
processes: Array<{ pid: number }>;
};
return body.processes.some((process) => process.pid === launch.pid);
}, { timeout: 5_000 }).toBe(false);
});
});