This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -11,9 +11,9 @@
* message: "Backend is offline. Connect to a Waggle server to start chatting."
*
* (b) the body simply ENDS without a `done` event → the reader yields
* done=true, the while loop at adapter.ts:360 exits, sendMessage()
* returns normally, and no error is shown but the partial tokens that DID
* arrive remain rendered (graceful truncation, no crash/hang).
* done=true, useChat rejects the non-terminal stream, clears the
* uncommitted token draft, and renders a retryable incomplete-response
* error (fail closed, no crash/hang).
*
* The client does NOT auto-retry (by design). These tests assert the RECOVERY /
* ERROR contract for both shapes, plus that a user can re-send after a drop and
@@ -122,9 +122,9 @@ test('chat SSE connection dropped → shows offline error, does not hang or cras
await expect(input).toBeVisible();
});
// ── Test 2 · Mid-stream truncation (token then close) → token kept, no crash ──
// ── Test 2 · Mid-stream truncation → draft rejected, retry offered, no crash ──
test('chat SSE truncated after one token → partial token rendered, no hang or crash', async ({ page }) => {
test('chat SSE truncated after one token → incomplete draft rejected, retryable error shown', async ({ page }) => {
await gotoDesktop(page);
const input = await openChatInput(page);
@@ -142,12 +142,15 @@ test('chat SSE truncated after one token → partial token rendered, no hang or
await input.fill('trigger a truncated stream');
await input.press('Enter');
// (1) The token that arrived before the drop must be rendered (proves the drop
// happened mid-token, not before any data).
await expect(page.locator('text=MIDSTREAM_TOKEN_PROBE').first()).toBeVisible({ timeout: 15_000 });
// A stream without a terminal event is not authoritative. The client must
// discard the token draft and expose a visible, actionable retry state.
const incompleteError = page.getByText(/response ended before completion/i);
await expect(incompleteError.first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'Retry' }).first()).toBeVisible();
await expect(page.locator('text=MIDSTREAM_TOKEN_PROBE')).toHaveCount(0);
// (3) No hang: reader hit done=true, sendMessage() returned, loading cleared —
// the composer is editable again.
// No hang: the incomplete-stream failure settles and clears loading, so the
// composer is editable again.
await expect(input).toBeEditable({ timeout: 10_000 });
// (3b) No crash, no infinite loop: desktop + input still present.

View File

@@ -493,9 +493,33 @@ const PERSONAS: PersonaBundle[] = [
},
},
{ url: '**/api/settings/probe-model', status: 200, body: { model: null, configured: false, verified: false } },
{ url: '**/api/local-inference/status', status: 200, body: { servers: [], ollamaInstalled: false, totalLocalModels: 0 } },
{
url: '**/api/local-inference/status',
status: 200,
body: {
servers: [],
ollamaInstalled: false,
ollamaRunning: false,
totalLocalModels: 0,
offlineReady: false,
dockerRequired: false,
managedRuntime: {
source: 'waggle-managed',
supported: true,
installed: false,
running: false,
targetVersion: '0.32.0',
version: null,
artifactSizeBytes: 1_503_047_573,
downloadRequired: true,
dockerRequired: false,
},
setupRequired: true,
setupMessage: 'Install the private runtime in Waggle, then download an offline model. Docker and a system Ollama install are not required.',
},
},
],
expected: /No local runtime detected|Install Ollama/i,
expected: /No local runtime yet[\s\S]*Install private runtime/i,
},
{
id: 'mobile-chat-backend-offline',

View File

@@ -13,10 +13,11 @@ type HookEnvelope = {
};
type HookToolCase = {
id: 'claude-code' | 'codex' | 'codex-desktop' | 'cursor' | 'hermes' | 'openclaw';
id: 'claude-code' | 'claude-desktop' | 'codex' | 'codex-desktop' | 'cursor' | 'hermes' | 'openclaw';
packageName: string;
configDir: string;
configFile: string;
configPath: string;
pointerPath: string;
cleanupDirs: string[];
precreateConfig?: string;
managedHookDir?: string;
};
@@ -25,81 +26,134 @@ const HOOK_TOOL_CASES: HookToolCase[] = [
{
id: 'claude-code',
packageName: '@waggle/hive-mind-hooks-claude-code',
configDir: '.claude',
configFile: 'settings.json',
configPath: path.join('.claude', 'settings.json'),
pointerPath: path.join('.claude', 'hive-mind-install.json'),
cleanupDirs: ['.claude'],
precreateConfig: '{}\n',
},
{
id: 'claude-desktop',
packageName: '@waggle/hive-mind-hooks-claude-desktop',
configPath: path.join('AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'),
pointerPath: path.join('.waggle', 'claude-desktop', 'hive-mind-install.json'),
cleanupDirs: [path.join('AppData', 'Roaming', 'Claude'), path.join('.waggle', 'claude-desktop')],
precreateConfig: '{}\n',
},
{
id: 'codex',
packageName: '@waggle/hive-mind-hooks-codex',
configDir: '.codex',
configFile: 'hooks.json',
configPath: path.join('.codex', 'hooks.json'),
pointerPath: path.join('.codex', 'hive-mind-install.json'),
cleanupDirs: ['.codex'],
},
{
id: 'codex-desktop',
packageName: '@waggle/hive-mind-hooks-codex-desktop',
configDir: '.codex',
configFile: 'hooks.json',
configPath: path.join('.codex', 'hooks.json'),
pointerPath: path.join('.codex', 'hive-mind-install.json'),
cleanupDirs: ['.codex'],
},
{
id: 'cursor',
packageName: '@waggle/hive-mind-hooks-cursor',
configDir: '.cursor',
configFile: 'hooks.json',
configPath: path.join('.cursor', 'hooks.json'),
pointerPath: path.join('.cursor', 'hive-mind-install.json'),
cleanupDirs: ['.cursor'],
},
{
id: 'hermes',
packageName: '@waggle/hive-mind-hooks-hermes',
configDir: '.hermes',
configFile: 'config.yaml',
configPath: path.join('.hermes', 'config.yaml'),
pointerPath: path.join('.hermes', 'hive-mind-install.json'),
cleanupDirs: ['.hermes'],
},
{
id: 'openclaw',
packageName: '@waggle/hive-mind-hooks-openclaw',
configDir: '.openclaw',
configFile: 'openclaw.json',
managedHookDir: path.join('hooks', 'hive-mind'),
configPath: path.join('.openclaw', 'openclaw.json'),
pointerPath: path.join('.openclaw', 'hive-mind-install.json'),
cleanupDirs: ['.openclaw'],
managedHookDir: path.join('.openclaw', 'hooks', 'hive-mind'),
},
];
function writeFakeHiveMindCli(root: string): string {
const cliPath = path.join(root, 'fake-hive-mind-cli.js');
fs.writeFileSync(
cliPath,
[
'#!/usr/bin/env node',
"if (process.argv.includes('--help')) {",
" console.log('hive-mind-cli test help');",
' process.exit(0);',
'}',
"console.error('unexpected fake hive-mind-cli invocation');",
'process.exit(1);',
'',
].join('\n'),
'utf8',
function selectRequestedHookTools(tools: readonly HookToolCase[]): HookToolCase[] {
const raw = process.env.WAGGLE_E2E_HOST_IDS;
if (raw === undefined) return [...tools];
const rawIds = raw.split(',');
if (rawIds.some(id => id.trim().length === 0)) {
throw new Error('Invalid WAGGLE_E2E_HOST_IDS: empty host ID.');
}
const requestedIds = rawIds.map(id => id.trim());
const duplicateIds = requestedIds.filter(
(id, index) => requestedIds.indexOf(id) !== index,
);
return cliPath;
if (duplicateIds.length > 0) {
throw new Error(`Duplicate WAGGLE_E2E_HOST_IDS: ${[...new Set(duplicateIds)].join(', ')}`);
}
const availableIds = new Set(tools.map(tool => tool.id));
const unknownIds = requestedIds.filter(
id => !availableIds.has(id as HookToolCase['id']),
);
if (unknownIds.length > 0) {
throw new Error(`Unknown WAGGLE_E2E_HOST_IDS: ${unknownIds.join(', ')}`);
}
const requested = new Set(requestedIds);
return tools.filter(tool => requested.has(tool.id));
}
test.describe('Launcher real hook lifecycle', () => {
test('runs every hook-capable tool install, verify, and uninstall through the sidecar route in an isolated profile', async ({ request }) => {
test.setTimeout(180_000);
function normalized(value: string): string {
return path.resolve(value).toLowerCase();
}
function inside(root: string, relativePath: string): string {
const candidate = path.resolve(root, relativePath);
const relative = path.relative(path.resolve(root), candidate);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Refusing path outside isolated hook profile: ${candidate}`);
}
return candidate;
}
function safeRemove(root: string, relativePath: string): void {
fs.rmSync(inside(root, relativePath), { recursive: true, force: true });
}
function assertIsolatedWindowsProfile(hookHome: string): void {
const guardedRoot = process.env.WAGGLE_E2E_TEMP_ROOT;
expect(guardedRoot, 'guarded runner must issue WAGGLE_E2E_TEMP_ROOT').toBeTruthy();
const relativeToRoot = path.relative(path.resolve(guardedRoot!), path.resolve(hookHome));
expect(relativeToRoot, 'hook profile must be a child of the guarded temp root').not.toMatch(/^\.\.|^[\\/]/);
expect(relativeToRoot, 'hook profile must not be the guarded temp root itself').not.toBe('');
expect(normalized(process.env.USERPROFILE ?? ''), 'isolated USERPROFILE').toBe(normalized(hookHome));
expect(normalized(process.env.HOME ?? ''), 'isolated HOME').toBe(normalized(hookHome));
expect(normalized(process.env.APPDATA ?? ''), 'isolated APPDATA').toBe(
normalized(path.join(hookHome, 'AppData', 'Roaming')),
);
expect(normalized(process.env.LOCALAPPDATA ?? ''), 'isolated LOCALAPPDATA').toBe(
normalized(path.join(hookHome, 'AppData', 'Local')),
);
}
test.describe('Launcher real Windows hook lifecycle', () => {
test('runs requested packaged hook routes with server-owned CLI wiring and reversible temp-profile cleanup', async ({ request }, testInfo) => {
test.setTimeout(240_000);
test.skip(process.platform !== 'win32', 'This real-host safety lane is Windows-specific.');
test.skip(
process.env.WAGGLE_E2E_REAL_HOOKS !== '1' || !process.env.WAGGLE_E2E_HOOK_HOME,
'Set WAGGLE_E2E_REAL_HOOKS=1 and WAGGLE_E2E_HOOK_HOME to a throwaway profile; also set USERPROFILE/HOME to that profile before the server starts.',
'Use scripts/test-windows-external-agents.ps1 to provide a throwaway Windows profile.',
);
const hookHome = process.env.WAGGLE_E2E_HOOK_HOME!;
const hookToolCases = selectRequestedHookTools(HOOK_TOOL_CASES);
const hookHome = path.resolve(process.env.WAGGLE_E2E_HOOK_HOME!);
assertIsolatedWindowsProfile(hookHome);
fs.mkdirSync(hookHome, { recursive: true });
const fakeCliPath = writeFakeHiveMindCli(hookHome);
const completed: Array<{ id: HookToolCase['id']; actions: string[]; packagedCli: string }> = [];
const postHook = async (tool: HookToolCase, action: 'install' | 'verify' | 'uninstall') => {
const postHook = async (tool: HookToolCase, action: HookEnvelope['action']) => {
const response = await request.post('/api/tools/hooks', {
data: {
id: tool.id,
action,
...(action === 'install' ? { cliPath: fakeCliPath } : {}),
},
data: { id: tool.id, action },
});
expect(response.status(), await response.text()).toBe(200);
const body = await response.json() as HookEnvelope;
@@ -113,18 +167,15 @@ test.describe('Launcher real hook lifecycle', () => {
};
try {
for (const tool of HOOK_TOOL_CASES) {
const toolRoot = path.join(hookHome, tool.configDir);
const configPath = path.join(toolRoot, tool.configFile);
const pointerPath = path.join(toolRoot, 'hive-mind-install.json');
const managedHookDir = tool.managedHookDir
? path.join(toolRoot, tool.managedHookDir)
: null;
for (const tool of hookToolCases) {
const configPath = inside(hookHome, tool.configPath);
const pointerPath = inside(hookHome, tool.pointerPath);
const managedHookDir = tool.managedHookDir ? inside(hookHome, tool.managedHookDir) : null;
await test.step(`${tool.id} hook lifecycle`, async () => {
fs.rmSync(toolRoot, { recursive: true, force: true });
await test.step(`${tool.id} hook install -> verify -> uninstall`, async () => {
for (const cleanupDir of tool.cleanupDirs) safeRemove(hookHome, cleanupDir);
if (tool.precreateConfig !== undefined) {
fs.mkdirSync(toolRoot, { recursive: true });
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, tool.precreateConfig, 'utf8');
}
@@ -132,9 +183,13 @@ test.describe('Launcher real hook lifecycle', () => {
expect(install.stdout).toContain('install');
expect(fs.existsSync(configPath)).toBe(true);
expect(fs.existsSync(pointerPath)).toBe(true);
if (managedHookDir) {
expect(fs.existsSync(managedHookDir)).toBe(true);
}
if (managedHookDir) expect(fs.existsSync(managedHookDir)).toBe(true);
const pointer = JSON.parse(fs.readFileSync(pointerPath, 'utf8')) as { cli_path?: unknown };
expect(pointer.cli_path, 'route pins the packaged hive-mind CLI').toEqual(expect.any(String));
const packagedCli = path.resolve(String(pointer.cli_path));
expect(fs.existsSync(packagedCli), `packaged CLI exists: ${packagedCli}`).toBe(true);
expect(packagedCli.replace(/\\/g, '/')).toMatch(/hive-mind-cli\/dist\/index\.js$/);
const verify = await postHook(tool, 'verify');
expect(verify.stdout).toContain('All checks passed.');
@@ -147,16 +202,20 @@ test.describe('Launcher real hook lifecycle', () => {
} else {
expect(fs.existsSync(configPath)).toBe(false);
}
if (managedHookDir) {
expect(fs.existsSync(managedHookDir)).toBe(false);
}
if (managedHookDir) expect(fs.existsSync(managedHookDir)).toBe(false);
completed.push({ id: tool.id, actions: ['install', 'verify', 'uninstall'], packagedCli });
});
}
await testInfo.attach('windows-hook-route-summary', {
body: Buffer.from(JSON.stringify({ hookHome, completed }, null, 2)),
contentType: 'application/json',
});
expect(completed.map(item => item.id)).toEqual(hookToolCases.map(tool => tool.id));
} finally {
for (const tool of HOOK_TOOL_CASES) {
fs.rmSync(path.join(hookHome, tool.configDir), { recursive: true, force: true });
for (const tool of hookToolCases) {
for (const cleanupDir of tool.cleanupDirs) safeRemove(hookHome, cleanupDir);
}
fs.rmSync(fakeCliPath, { force: true });
}
});
});

View File

@@ -1,12 +1,83 @@
import { expect, test } from '@playwright/test';
import { expect, test, type APIRequestContext } from '@playwright/test';
import { SUPPORTED_TOOLS } from '@waggle/shared';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
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'],
type ToolId = typeof SUPPORTED_TOOLS[number];
const SAFE_VERSION_ARGS: Partial<Record<ToolId, string[]>> = {
'claude-code': ['--version'],
codex: ['--version'],
hermes: ['--version'],
openclaw: ['--version'],
};
const SECRET_ENV_NAMES = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'OPENAI_API_KEY',
'OPENAI_ACCESS_TOKEN',
'OPENROUTER_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_API_KEY',
'GROQ_API_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
'COHERE_API_KEY',
'DEEPSEEK_API_KEY',
'AZURE_OPENAI_API_KEY',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'AWS_PROFILE',
'AWS_CONFIG_FILE',
'AWS_SHARED_CREDENTIALS_FILE',
'GOOGLE_APPLICATION_CREDENTIALS',
'CLOUDSDK_CONFIG',
'AZURE_CONFIG_DIR',
'KUBECONFIG',
'DOCKER_CONFIG',
'DOCKER_HOST',
'GITHUB_TOKEN',
'GH_TOKEN',
'STRIPE_SECRET_KEY',
'DATABASE_URL',
'NPM_TOKEN',
'HF_TOKEN',
'HUGGING_FACE_HUB_TOKEN',
'RENDER_API_KEY',
'SSH_AUTH_SOCK',
'GIT_ASKPASS',
'SSH_ASKPASS',
'GIT_SSH_COMMAND',
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NODE_OPTIONS',
] as const;
function selectRequestedToolIds(tools: readonly ToolId[]): ToolId[] {
const raw = process.env.WAGGLE_E2E_HOST_IDS;
if (raw === undefined) return [...tools];
const rawIds = raw.split(',');
if (rawIds.some(id => id.trim().length === 0)) {
throw new Error('Invalid WAGGLE_E2E_HOST_IDS: empty host ID.');
}
const requestedIds = rawIds.map(id => id.trim());
const duplicateIds = requestedIds.filter(
(id, index) => requestedIds.indexOf(id) !== index,
);
if (duplicateIds.length > 0) {
throw new Error(`Duplicate WAGGLE_E2E_HOST_IDS: ${[...new Set(duplicateIds)].join(', ')}`);
}
const unknownIds = requestedIds.filter(id => !tools.includes(id as ToolId));
if (unknownIds.length > 0) {
throw new Error(`Unknown WAGGLE_E2E_HOST_IDS: ${unknownIds.join(', ')}`);
}
const requested = new Set(requestedIds);
return tools.filter(tool => requested.has(tool));
}
type DetectedTool = {
id: string;
@@ -14,27 +85,77 @@ type DetectedTool = {
installed: boolean;
installedPath: string | null;
launchable?: boolean;
hookCapable?: boolean;
builtin?: boolean;
capabilities?: {
interactiveLaunch: boolean;
headlessTask: boolean;
};
};
type DetectionEnvelope = {
platform: string;
tools: DetectedTool[];
};
type LaunchEnvelope = {
ok: boolean;
pid: number | null;
roomId?: string;
runId?: string;
error?: string;
};
type WorkspaceEnvelope = {
id?: string;
name?: string;
storageType?: string;
};
type RouteResult = {
id: string;
displayName: string;
status: 'unavailable' | 'safe-version-exit' | 'interactive-only-rejected';
installedPath: string | null;
exitCode?: number | null;
output?: string;
route?: 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));
function assertTemporaryDataDir(): void {
const dataDir = process.env.WAGGLE_E2E_DATA_DIR;
const guardedRoot = process.env.WAGGLE_E2E_TEMP_ROOT;
expect(dataDir, 'WAGGLE_E2E_DATA_DIR must be explicitly isolated').toBeTruthy();
expect(guardedRoot, 'guarded runner must issue WAGGLE_E2E_TEMP_ROOT').toBeTruthy();
const relative = path.relative(path.resolve(guardedRoot!), path.resolve(dataDir!));
expect(relative, 'E2E data dir must be a child of the guarded temp root').not.toMatch(/^\.\.|^[\\/]/);
expect(relative, 'E2E data dir must not be the guarded temp root itself').not.toBe('');
}
async function createManagedWorkspace(request: APIRequestContext): Promise<WorkspaceEnvelope & { id: string }> {
const createResponse = await request.post('/api/workspaces', {
data: {
name: `Windows external-agent route ${randomUUID().slice(0, 8)}`,
group: 'external-agent-e2e',
icon: 'Terminal',
tone: 'technical',
storageType: 'virtual',
},
});
expect(createResponse.status(), await createResponse.text()).toBe(201);
const created = await createResponse.json() as WorkspaceEnvelope;
expect(created.id, 'managed workspace id from POST /api/workspaces').toMatch(/\S/);
const persistedResponse = await request.get(`/api/workspaces/${encodeURIComponent(created.id!)}`);
expect(persistedResponse.status(), await persistedResponse.text()).toBe(200);
const persisted = await persistedResponse.json() as WorkspaceEnvelope;
expect(persisted).toMatchObject({ id: created.id, name: created.name });
return { ...created, id: created.id! };
}
async function readObservedStream(
@@ -42,7 +163,7 @@ async function readObservedStream(
pid: number,
): Promise<{ lines: string[]; exitCode: number | null | undefined }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const timeout = setTimeout(() => controller.abort(), 30_000);
const lines: string[] = [];
let exitCode: number | null | undefined;
@@ -60,7 +181,7 @@ async function readObservedStream(
while (exitCode === undefined) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r\n/g, '\n');
let eventEnd = buffer.indexOf('\n\n');
while (eventEnd >= 0) {
@@ -75,9 +196,7 @@ async function readObservedStream(
if (line.startsWith('data:')) data += line.slice('data:'.length).trim();
}
if (event === 'line') {
lines.push((JSON.parse(data) as { line: string }).line);
}
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;
@@ -91,50 +210,149 @@ async function readObservedStream(
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 }) => {
async function waitForProcessClear(request: APIRequestContext, pid: number): Promise<void> {
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 === pid);
}, { timeout: 10_000 }).toBe(false);
}
test.describe('Launcher real Windows supported-route lifecycle', () => {
test('covers requested built-in tools without credentials, unsafe GUI launch, or fabricated workspace ids', async ({ baseURL, page, request }, testInfo) => {
test.setTimeout(240_000);
test.skip(process.platform !== 'win32', 'This real-host route lane is Windows-specific.');
test.skip(
process.env.WAGGLE_E2E_REAL_TOOLS !== '1',
'Set WAGGLE_E2E_REAL_TOOLS=1 on a machine with Claude, Hermes, or OpenClaw installed.',
'Use scripts/test-windows-external-agents.ps1 to run the guarded real-tool lane.',
);
assertTemporaryDataDir();
expect(
process.env.WAGGLE_E2E_REUSE_EXISTING_SERVER,
'guarded runner must forbid reuse of a pre-existing app server',
).toBe('0');
expect(
SECRET_ENV_NAMES.filter(name => Boolean(process.env[name])),
'provider and cloud credentials must be scrubbed by the guarded runner',
).toEqual([]);
const toolIds = selectRequestedToolIds(SUPPORTED_TOOLS);
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();
expect(detection.platform).toBe('win32');
expect(
detection.tools.filter(tool => tool.builtin === true).map(tool => tool.id),
).toEqual(SUPPORTED_TOOLS);
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 });
await expect(page.getByText('Tool Launcher', { exact: true })).toBeVisible({ timeout: 15_000 });
for (const tool of detection.tools) {
await expect(page.getByText(tool.displayName, { exact: true }).first()).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 workspace = await createManagedWorkspace(request);
const activePids = new Set<number>();
const results: RouteResult[] = [];
const stream = await readObservedStream(root, launch.pid!);
expect(stream.exitCode).toBe(0);
expect(stream.lines.join('\n').trim().length).toBeGreaterThan(0);
try {
for (const toolId of toolIds) {
const tool = detection.tools.find(candidate => candidate.id === toolId)!;
if (!tool.installed || !tool.installedPath) {
results.push({
id: tool.id,
displayName: tool.displayName,
status: 'unavailable',
installedPath: null,
});
testInfo.annotations.push({ type: 'tool-unavailable', description: tool.displayName });
continue;
}
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);
await test.step(`${tool.displayName} supported route`, async () => {
if (tool.capabilities?.headlessTask !== true) {
const unsupported = await request.post('/api/tools/run', {
data: {
toolId: tool.id,
workspaceIds: [workspace.id],
prompt: 'Do not run. This request must be rejected as interactive-only.',
timeoutMs: 10_000,
},
});
expect(unsupported.status(), await unsupported.text()).toBe(409);
expect(await unsupported.json()).toMatchObject({
error: 'TOOL_NOT_HEADLESS',
toolId: tool.id,
});
results.push({
id: tool.id,
displayName: tool.displayName,
status: 'interactive-only-rejected',
installedPath: tool.installedPath,
route: '/api/tools/run',
});
return;
}
const args = SAFE_VERSION_ARGS[tool.id as ToolId];
expect(args, `${tool.displayName} must have an audited no-network version command`).toBeTruthy();
const launchResponse = await request.post('/api/tools/launch', {
data: {
id: tool.id,
args,
workspaceId: workspace.id,
observe: true,
},
});
expect(launchResponse.status(), await launchResponse.text()).toBe(202);
const launch = await launchResponse.json() as LaunchEnvelope;
expect(launch, launch.error).toMatchObject({ ok: true });
expect(launch.pid).toEqual(expect.any(Number));
expect(launch.roomId).toMatch(/\S/);
expect(launch.runId).toMatch(/\S/);
activePids.add(launch.pid!);
const stream = await readObservedStream(root, launch.pid!);
expect(stream.exitCode).toBe(0);
expect(stream.lines.join('\n').trim().length).toBeGreaterThan(0);
await waitForProcessClear(request, launch.pid!);
activePids.delete(launch.pid!);
results.push({
id: tool.id,
displayName: tool.displayName,
status: 'safe-version-exit',
installedPath: tool.installedPath,
exitCode: stream.exitCode,
output: stream.lines.join('\n').trim(),
route: '/api/tools/launch',
});
});
}
expect(results.map(result => result.id)).toEqual(toolIds);
if (process.env.WAGGLE_E2E_HOST_IDS) {
expect(
results.filter(result => result.status === 'unavailable').map(result => result.id),
'every explicitly requested host must be installed and healthy',
).toEqual([]);
} else {
expect(results.some(result => result.status !== 'unavailable'), 'at least one real installed tool route').toBe(true);
}
} finally {
try {
await testInfo.attach('windows-external-tool-route-summary', {
body: Buffer.from(JSON.stringify({ workspace, results }, null, 2)),
contentType: 'application/json',
});
} finally {
for (const pid of activePids) {
await request.post('/api/tools/kill', { data: { pid } }).catch(() => null);
}
const deleteResponse = await request.delete(`/api/workspaces/${encodeURIComponent(workspace.id)}`).catch(() => null);
if (deleteResponse) expect([204, 404]).toContain(deleteResponse.status());
}
}
});
});

View File

@@ -28,11 +28,10 @@ async function mockProcesses(page: Page): Promise<void> {
const HOOK_RENDER_CASES = [
{ id: 'claude-code', displayName: 'Claude Code', packageName: '@waggle/hive-mind-hooks-claude-code', configDir: '.claude', configFile: 'settings.json' },
{ id: 'claude-desktop', displayName: 'Claude Desktop', packageName: '@waggle/hive-mind-hooks-claude-desktop', configDir: '.waggle/claude-desktop', configFile: 'claude_desktop_config.json' },
{ id: 'codex', displayName: 'Codex CLI', packageName: '@waggle/hive-mind-hooks-codex', configDir: '.codex', configFile: 'hooks.json' },
{ id: 'codex-desktop', displayName: 'Codex Desktop', packageName: '@waggle/hive-mind-hooks-codex-desktop', configDir: '.codex', configFile: 'hooks.json' },
{ id: 'cursor', displayName: 'Cursor', packageName: '@waggle/hive-mind-hooks-cursor', configDir: '.cursor', configFile: 'hooks.json' },
{ id: 'hermes', displayName: 'Hermes Agent', packageName: '@waggle/hive-mind-hooks-hermes', configDir: '.hermes', configFile: 'config.yaml' },
{ id: 'openclaw', displayName: 'OpenClaw', packageName: '@waggle/hive-mind-hooks-openclaw', configDir: '.openclaw', configFile: 'openclaw.json' },
] as const;
test.describe('Launcher rendered states', () => {

View File

@@ -1,38 +1,12 @@
/**
* Phase 8 — Visual Regression Baselines (9G-4)
* Phase 8 structural smoke coverage for the current Waggle views.
*
* Captures screenshot baselines for all 7 Waggle views in both dark and light
* modes. This completes the 9G-4 gap identified in CONTINUE-PHASE9.md.
*
* Each view × theme = 1 baseline PNG. Total: 14 baselines.
*
* Baseline storage: tests/visual/baselines/
* Snapshot template: {snapshotDir}/{testName}/{arg}{ext} (from playwright.config.ts)
*
* Usage:
* # Create / update baselines (first run or after intentional UI changes)
* npx playwright test tests/e2e/phase8-visual.spec.ts --update-snapshots
*
* # Verify no regressions (CI)
* npx playwright test tests/e2e/phase8-visual.spec.ts
*
* Prerequisites:
* - Server running at localhost:3333 (playwright.config.ts webServer auto-starts it)
* - app/dist built (npm run build in app/)
* - No onboarding wizard state (fresh ~/.waggle or pre-seeded with config)
*
* Diff threshold: 0.3% pixel ratio (configured in playwright.config.ts)
*
* Notes:
* - Tests skip gracefully when onboarding wizard is active (first-run state).
* - MissionControl view is tested for presence only (may be gated by Phase 8D).
* - Animations are disabled via playwright config to prevent flaky snapshots.
* Deterministic pixel regression coverage lives in tests/visual/views.spec.ts.
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const RUN_PIXEL_BASELINES = process.env.WAGGLE_E2E_VISUAL === '1' || !process.env.CI;
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -154,129 +128,6 @@ async function navigateTo(page: Page, viewName: string): Promise<void> {
await waitForApp(page);
}
/**
* Set theme by clicking the sidebar theme toggle until the correct mode is active.
* Returns the final theme ('dark' | 'light').
*/
async function setTheme(page: Page, target: 'dark' | 'light'): Promise<void> {
// Theme toggle is in the sidebar — ensure it's expanded
await page.evaluate((mode) => {
localStorage.setItem('waggle-theme', mode);
if (mode === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
}, target);
await page.waitForTimeout(100);
}
/**
* Capture a stable screenshot — waits for network idle and hides dynamic elements
* (timestamps, cost counters, status bar tokens) that would cause diff failures.
*/
async function stableScreenshot(page: Page): Promise<Buffer> {
// Hide elements whose content changes between runs
await page.evaluate(() => {
const selectors = [
'[data-testid="status-bar-tokens"]',
'[data-testid="status-bar-cost"]',
'[class*="timestamp"]',
'[class*="Timestamp"]',
'.status-bar__cost',
'.waggle-status-bar__tokens',
];
for (const sel of selectors) {
document.querySelectorAll(sel).forEach((el) => {
(el as HTMLElement).style.visibility = 'hidden';
});
}
const dynamicText = [
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s/i,
/^\d{1,2}:\d{2}$/,
/^Last active:/i,
];
document.querySelectorAll('body *').forEach((el) => {
if (el.children.length > 0) return;
const text = el.textContent?.trim() ?? '';
if (dynamicText.some((pattern) => pattern.test(text))) {
(el as HTMLElement).style.visibility = 'hidden';
}
});
document.querySelectorAll('button[aria-label="Notifications"]').forEach((el) => {
(el as HTMLElement).style.visibility = 'hidden';
});
});
await page.waitForTimeout(200);
return page.screenshot({ fullPage: false });
}
// ── View definitions ──────────────────────────────────────────────────────────
const VIEWS = [
{ name: 'Chat', sidebar: 'Chat' },
{ name: 'Memory', sidebar: 'Memory' },
{ name: 'Events', sidebar: 'Events' },
{ name: 'Capabilities', sidebar: 'Skills Hub' },
{ name: 'Cockpit', sidebar: 'Cockpit' },
{ name: 'MissionControl', sidebar: 'Mission Control' },
{ name: 'Settings', sidebar: 'Settings' },
] as const;
const THEMES = ['light', 'dark'] as const;
// ═════════════════════════════════════════════════════════════════════════════
// Visual Baseline Tests (7 views × 2 themes = 14 baselines)
// ═════════════════════════════════════════════════════════════════════════════
for (const theme of THEMES) {
test.describe(`Visual baselines — ${theme} mode`, () => {
test.skip(!RUN_PIXEL_BASELINES, 'Pixel baselines run with WAGGLE_E2E_VISUAL=1; structural smoke tests still run in CI.');
// Visual tests need more time: beforeEach (goto + waitForApp + setTheme) ~10-20s
// + navigateTo ~5s + waitForFunction + networkidle + screenshot ~10s = up to 35s
test.describe.configure({ timeout: 90_000 });
test.beforeEach(async ({ page }) => {
// CRITICAL: register addInitScript BEFORE first goto so localStorage
// is set BEFORE React mounts and reads onboarding state.
await page.addInitScript((targetTheme) => {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
localStorage.setItem('waggle:first-run', 'done');
localStorage.setItem('waggle-theme', targetTheme);
if (targetTheme === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
}, theme);
// Server-side: PATCH /api/settings (belt and suspenders)
await page.request.patch(`${BASE}/api/settings`, {
data: { onboardingCompleted: true },
headers: { 'Content-Type': 'application/json' },
}).catch(() => {});
// NOW navigate — initScript fires before React, no onboarding shown
await page.goto(routeWithSkip('/home'));
await waitForApp(page);
await setTheme(page, theme);
});
for (const view of VIEWS) {
test(`${view.name} view — ${theme}`, async ({ page }) => {
// No skip conditions — if onboarding blocks navigation, test fails with clear error
// navigateTo will throw if sidebar button not found within 5s
await navigateTo(page, view.sidebar);
// Wait for view content — not a fixed timer
await page.waitForFunction(() =>
(document.body.textContent?.length ?? 0) > 100,
{ timeout: 8000 }
).catch(() => {});
await page.waitForTimeout(400); // short final settle for animations
const screenshot = await stableScreenshot(page);
expect(screenshot).toMatchSnapshot(`${view.name}-${theme}.png`);
});
}
});
}
// ═════════════════════════════════════════════════════════════════════════════
// Structural smoke tests — verify views render without crashing
// (These always run, even without baselines.)

View File

@@ -641,6 +641,18 @@ test.describe('User Journey Tests', () => {
'button:visible, [role="tab"]:visible, [role="tabpanel"]:visible, input:visible, select:visible, textarea:visible',
);
expect(overflow, `${route} visible control overflow`).toEqual([]);
if (route === '/settings/profile') {
const profileTabs = page.getByRole('tablist', { name: 'Profile sections' });
for (const tabName of ['Writing Style', 'Brand & Templates', 'Interests']) {
await profileTabs.getByRole('tab', { name: tabName }).click();
const tabOverflow = await visibleHorizontalOverflow(
page,
'button:visible, [role="tab"]:visible, [role="tabpanel"]:visible, input:visible, select:visible, textarea:visible',
);
expect(tabOverflow, `/settings/profile ${tabName} visible control overflow`).toEqual([]);
}
}
}
});
@@ -775,11 +787,15 @@ test.describe('User Journey Tests', () => {
contentType: 'application/json',
body: JSON.stringify({ model: null, configured: false, verified: false }),
}));
await page.route('**/api/settings/probe-provider', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ configured: false, valid: false, verified: false }),
}));
await page.route('**/api/settings/probe-provider', route => {
const { provider } = route.request().postDataJSON() as { provider?: string };
const configured = keySaved && provider === 'anthropic';
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ configured, valid: configured, verified: false }),
});
});
await page.route('**/api/settings', async route => {
if (route.request().method() !== 'PUT') {
await route.continue();