moving
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user