add electron e2e harness and regression coverage (#697)
This commit is contained in:
committed by
GitHub
Unverified
parent
514a6c4112
commit
2668082809
41
tests/e2e/app-smoke.spec.ts
Normal file
41
tests/e2e/app-smoke.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from './fixtures/electron';
|
||||
|
||||
test.describe('ClawX Electron smoke flows', () => {
|
||||
test('shows the setup wizard on a fresh profile', async ({ page }) => {
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await expect(page.getByTestId('setup-welcome-step')).toBeVisible();
|
||||
await expect(page.getByTestId('setup-skip-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can skip setup and navigate to the models page', async ({ page }) => {
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
|
||||
await expect(page.getByTestId('models-page')).toBeVisible();
|
||||
await expect(page.getByTestId('models-page-title')).toBeVisible();
|
||||
await expect(page.getByTestId('providers-settings')).toBeVisible();
|
||||
});
|
||||
|
||||
test('persists skipped setup across relaunch for the same isolated profile', async ({ electronApp, launchElectronApp }) => {
|
||||
const firstWindow = await electronApp.firstWindow();
|
||||
await firstWindow.waitForLoadState('domcontentloaded');
|
||||
await firstWindow.getByTestId('setup-skip-button').click();
|
||||
await expect(firstWindow.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
await electronApp.close();
|
||||
|
||||
const relaunchedApp = await launchElectronApp();
|
||||
try {
|
||||
const relaunchedWindow = await relaunchedApp.firstWindow();
|
||||
await relaunchedWindow.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(relaunchedWindow.getByTestId('main-layout')).toBeVisible();
|
||||
await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0);
|
||||
} finally {
|
||||
await relaunchedApp.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
32
tests/e2e/developer-mode.spec.ts
Normal file
32
tests/e2e/developer-mode.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
test.describe('ClawX developer-mode gated UI', () => {
|
||||
test('keeps developer-only configuration hidden until dev mode is enabled', async ({ page }) => {
|
||||
await completeSetup(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-settings').click();
|
||||
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-developer-section')).toHaveCount(0);
|
||||
await expect(page.getByTestId('settings-dev-mode-switch')).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await page.getByTestId('providers-add-button').click();
|
||||
await expect(page.getByTestId('add-provider-dialog')).toBeVisible();
|
||||
await page.getByTestId('add-provider-type-siliconflow').click();
|
||||
await expect(page.getByTestId('add-provider-model-id-input')).toHaveCount(0);
|
||||
await page.getByTestId('add-provider-close-button').click();
|
||||
await expect(page.getByTestId('add-provider-dialog')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('sidebar-nav-settings').click();
|
||||
await page.getByTestId('settings-dev-mode-switch').click();
|
||||
await expect(page.getByTestId('settings-dev-mode-switch')).toHaveAttribute('data-state', 'checked');
|
||||
await expect(page.getByTestId('settings-developer-section')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-developer-gateway-token')).toBeVisible();
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await page.getByTestId('providers-add-button').click();
|
||||
await expect(page.getByTestId('add-provider-dialog')).toBeVisible();
|
||||
await page.getByTestId('add-provider-type-siliconflow').click();
|
||||
await expect(page.getByTestId('add-provider-model-id-input')).toBeVisible();
|
||||
});
|
||||
});
|
||||
120
tests/e2e/fixtures/electron.ts
Normal file
120
tests/e2e/fixtures/electron.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import electronBinaryPath from 'electron';
|
||||
import { _electron as electron, expect, test as base, type ElectronApplication, type Page } from '@playwright/test';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
type ElectronFixtures = {
|
||||
electronApp: ElectronApplication;
|
||||
page: Page;
|
||||
homeDir: string;
|
||||
userDataDir: string;
|
||||
launchElectronApp: () => Promise<ElectronApplication>;
|
||||
};
|
||||
|
||||
const repoRoot = resolve(process.cwd());
|
||||
const electronEntry = join(repoRoot, 'dist-electron/main/index.js');
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
return await new Promise((resolvePort, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate an ephemeral port')));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolvePort(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function launchClawXElectron(homeDir: string, userDataDir: string): Promise<ElectronApplication> {
|
||||
const hostApiPort = await allocatePort();
|
||||
const electronEnv = process.platform === 'linux'
|
||||
? { ELECTRON_DISABLE_SANDBOX: '1' }
|
||||
: {};
|
||||
return await electron.launch({
|
||||
executablePath: electronBinaryPath,
|
||||
args: [electronEntry],
|
||||
env: {
|
||||
...process.env,
|
||||
...electronEnv,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
APPDATA: join(homeDir, 'AppData', 'Roaming'),
|
||||
LOCALAPPDATA: join(homeDir, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: join(homeDir, '.config'),
|
||||
CLAWX_E2E: '1',
|
||||
CLAWX_USER_DATA_DIR: userDataDir,
|
||||
CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort),
|
||||
},
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
homeDir: async ({ browserName: _browserName }, provideHomeDir) => {
|
||||
const homeDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-home-'));
|
||||
await mkdir(join(homeDir, '.config'), { recursive: true });
|
||||
await mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true });
|
||||
await mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true });
|
||||
try {
|
||||
await provideHomeDir(homeDir);
|
||||
} finally {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
|
||||
userDataDir: async ({ browserName: _browserName }, provideUserDataDir) => {
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-user-data-'));
|
||||
try {
|
||||
await provideUserDataDir(userDataDir);
|
||||
} finally {
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
|
||||
launchElectronApp: async ({ homeDir, userDataDir }, provideLauncher) => {
|
||||
await provideLauncher(async () => await launchClawXElectron(homeDir, userDataDir));
|
||||
},
|
||||
|
||||
electronApp: async ({ launchElectronApp }, provideElectronApp) => {
|
||||
const app = await launchElectronApp();
|
||||
let appClosed = false;
|
||||
app.once('close', () => {
|
||||
appClosed = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await provideElectronApp(app);
|
||||
} finally {
|
||||
if (!appClosed) {
|
||||
await app.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
page: async ({ electronApp }, providePage) => {
|
||||
const page = await electronApp.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await providePage(page);
|
||||
},
|
||||
});
|
||||
|
||||
export async function completeSetup(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
}
|
||||
|
||||
export { expect };
|
||||
65
tests/e2e/provider-lifecycle.spec.ts
Normal file
65
tests/e2e/provider-lifecycle.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const TEST_PROVIDER_ID = 'moonshot-e2e';
|
||||
const TEST_PROVIDER_LABEL = 'Moonshot E2E';
|
||||
|
||||
async function seedTestProvider(page: Parameters<typeof completeSetup>[0]): Promise<void> {
|
||||
await page.evaluate(async ({ providerId, providerLabel }) => {
|
||||
const now = new Date().toISOString();
|
||||
await window.electron.ipcRenderer.invoke('provider:save', {
|
||||
id: providerId,
|
||||
name: providerLabel,
|
||||
type: 'moonshot',
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
model: 'kimi-k2.5',
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}, { providerId: TEST_PROVIDER_ID, providerLabel: TEST_PROVIDER_LABEL });
|
||||
}
|
||||
|
||||
test.describe('ClawX provider lifecycle', () => {
|
||||
test('shows a saved provider and removes it cleanly after deletion', async ({ page }) => {
|
||||
await completeSetup(page);
|
||||
await seedTestProvider(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('providers-settings')).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toContainText(TEST_PROVIDER_LABEL);
|
||||
|
||||
await page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`).hover();
|
||||
await page.getByTestId(`provider-delete-${TEST_PROVIDER_ID}`).click();
|
||||
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toHaveCount(0);
|
||||
await expect(page.getByText(TEST_PROVIDER_LABEL)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('does not redisplay a deleted provider after relaunch', async ({ electronApp, launchElectronApp, page }) => {
|
||||
await completeSetup(page);
|
||||
await seedTestProvider(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toContainText(TEST_PROVIDER_LABEL);
|
||||
|
||||
await page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`).hover();
|
||||
await page.getByTestId(`provider-delete-${TEST_PROVIDER_ID}`).click();
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toHaveCount(0);
|
||||
|
||||
await electronApp.close();
|
||||
|
||||
const relaunchedApp = await launchElectronApp();
|
||||
try {
|
||||
const relaunchedPage = await relaunchedApp.firstWindow();
|
||||
await relaunchedPage.waitForLoadState('domcontentloaded');
|
||||
await expect(relaunchedPage.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
await relaunchedPage.getByTestId('sidebar-nav-models').click();
|
||||
await expect(relaunchedPage.getByTestId('providers-settings')).toBeVisible();
|
||||
await expect(relaunchedPage.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toHaveCount(0);
|
||||
await expect(relaunchedPage.getByText(TEST_PROVIDER_LABEL)).toHaveCount(0);
|
||||
} finally {
|
||||
await relaunchedApp.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe('provider metadata', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes OpenRouter and SiliconFlow model overrides by default', () => {
|
||||
it('exposes OpenRouter model overrides by default and gates SiliconFlow behind dev mode', () => {
|
||||
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
|
||||
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
|
||||
|
||||
@@ -110,11 +110,12 @@ describe('provider metadata', () => {
|
||||
});
|
||||
expect(siliconflow).toMatchObject({
|
||||
showModelId: true,
|
||||
showModelIdInDevModeOnly: true,
|
||||
defaultModelId: 'deepseek-ai/DeepSeek-V3',
|
||||
});
|
||||
|
||||
expect(shouldShowProviderModelId(openrouter, false)).toBe(true);
|
||||
expect(shouldShowProviderModelId(siliconflow, false)).toBe(true);
|
||||
expect(shouldShowProviderModelId(siliconflow, false)).toBe(false);
|
||||
expect(shouldShowProviderModelId(openrouter, true)).toBe(true);
|
||||
expect(shouldShowProviderModelId(siliconflow, true)).toBe(true);
|
||||
});
|
||||
@@ -151,19 +152,20 @@ describe('provider metadata', () => {
|
||||
expect(resolveProviderModelForSave(qwen, ' ', true)).toBe('coder-model');
|
||||
});
|
||||
|
||||
it('saves OpenRouter and SiliconFlow model overrides by default', () => {
|
||||
it('saves OpenRouter model overrides by default and SiliconFlow only in dev mode', () => {
|
||||
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
|
||||
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
|
||||
const ark = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'ark');
|
||||
|
||||
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', false)).toBe('openai/gpt-5');
|
||||
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', false)).toBe('Qwen/Qwen3-Coder-480B-A35B-Instruct');
|
||||
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', false)).toBeUndefined();
|
||||
|
||||
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', true)).toBe('openai/gpt-5');
|
||||
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', true)).toBe('Qwen/Qwen3-Coder-480B-A35B-Instruct');
|
||||
|
||||
expect(resolveProviderModelForSave(openrouter, ' ', false)).toBe('openai/gpt-5.4');
|
||||
expect(resolveProviderModelForSave(openrouter, ' ', true)).toBe('openai/gpt-5.4');
|
||||
expect(resolveProviderModelForSave(siliconflow, ' ', false)).toBeUndefined();
|
||||
expect(resolveProviderModelForSave(siliconflow, ' ', true)).toBe('deepseek-ai/DeepSeek-V3');
|
||||
expect(resolveProviderModelForSave(ark, ' ep-custom-model ', false)).toBe('ep-custom-model');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user