add electron e2e harness and regression coverage (#697)

This commit is contained in:
Lingxuan Zuo
2026-03-28 15:34:20 +08:00
committed by GitHub
Unverified
parent 514a6c4112
commit 2668082809
22 changed files with 535 additions and 78 deletions

View 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();
}
});
});

View 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();
});
});

View 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 };

View 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();
}
});
});

View File

@@ -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');
});