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

67
.github/workflows/electron-e2e.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Electron E2E
on:
workflow_dispatch:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
electron-e2e:
name: Electron E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
env:
CI: 'true'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Rebuild Electron binary
run: pnpm rebuild electron
- name: Run Electron E2E on Linux
if: runner.os == 'Linux'
run: xvfb-run -a pnpm run test:e2e
- name: Run Electron E2E on macOS
if: runner.os == 'macOS'
run: pnpm run test:e2e
- name: Run Electron E2E on Windows
if: runner.os == 'Windows'
run: pnpm run test:e2e
- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ matrix.os }}
path: |
playwright-report
test-results
if-no-files-found: warn
retention-days: 7

2
.gitignore vendored
View File

@@ -39,6 +39,8 @@ desktop.ini
# Test coverage # Test coverage
coverage/ coverage/
playwright-report/
test-results/
# Cache # Cache
.cache/ .cache/

View File

@@ -339,6 +339,8 @@ pnpm typecheck # TypeScript validation
# Testing # Testing
pnpm test # Run unit tests pnpm test # Run unit tests
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
pnpm run comms:replay # Compute communication replay metrics pnpm run comms:replay # Compute communication replay metrics
pnpm run comms:baseline # Refresh communication baseline snapshot pnpm run comms:baseline # Refresh communication baseline snapshot
pnpm run comms:compare # Compare replay metrics against baseline thresholds pnpm run comms:compare # Compare replay metrics against baseline thresholds
@@ -362,6 +364,28 @@ pnpm run comms:compare
``` ```
`comms-regression` in CI enforces required scenarios and threshold checks. `comms-regression` in CI enforces required scenarios and threshold checks.
### Electron E2E Tests
The Playwright Electron suite launches the packaged renderer and main process
from `dist/` and `dist-electron/`, so it does not require manually running
`pnpm dev` first.
`pnpm run test:e2e` automatically:
- builds the renderer and Electron bundles with `pnpm run build:vite`
- starts Electron in an isolated E2E mode with a temporary `HOME`
- uses a temporary ClawX `userData` directory
- skips heavy startup side effects such as gateway auto-start, bundled skill
installation, tray creation, and CLI auto-install
The first two baseline specs cover:
- first-launch setup wizard visibility on a fresh profile
- skipping setup and navigating to the Models page inside the Electron app
Add future Electron flows under `tests/e2e/` and reuse the shared fixture in
`tests/e2e/fixtures/electron.ts`.
### Tech Stack ### Tech Stack
| Layer | Technology | | Layer | Technology |

View File

@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { PORTS } from '../utils/config'; import { getPort } from '../utils/config';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import type { HostApiContext } from './context'; import type { HostApiContext } from './context';
import { handleAppRoutes } from './routes/app'; import { handleAppRoutes } from './routes/app';
@@ -53,7 +53,7 @@ export function getHostApiToken(): string {
return hostApiToken; return hostApiToken;
} }
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server { export function startHostApiServer(ctx: HostApiContext, port = getPort('CLAWX_HOST_API')): Server {
// Generate a cryptographically random token for this session. // Generate a cryptographically random token for this session.
hostApiToken = randomBytes(32).toString('hex'); hostApiToken = randomBytes(32).toString('hex');

View File

@@ -45,6 +45,12 @@ import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync'; import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop'; const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
const isE2EMode = process.env.CLAWX_E2E === '1';
const requestedUserDataDir = process.env.CLAWX_USER_DATA_DIR?.trim();
if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
}
// Disable GPU hardware acceleration globally for maximum stability across // Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete). // all GPU configurations (no GPU, integrated, discrete).
@@ -75,14 +81,14 @@ if (process.platform === 'linux') {
// same port, then each treats the other's gateway as "orphaned" and kills // same port, then each treats the other's gateway as "orphaned" and kills
// it — creating an infinite kill/restart loop on Windows. // it — creating an infinite kill/restart loop on Windows.
// The losing process must exit immediately so it never reaches Gateway startup. // The losing process must exit immediately so it never reaches Gateway startup.
const gotElectronLock = app.requestSingleInstanceLock(); const gotElectronLock = isE2EMode ? true : app.requestSingleInstanceLock();
if (!gotElectronLock) { if (!gotElectronLock) {
console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process'); console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process');
app.exit(0); app.exit(0);
} }
let releaseProcessInstanceFileLock: () => void = () => {}; let releaseProcessInstanceFileLock: () => void = () => {};
let gotFileLock = true; let gotFileLock = true;
if (gotElectronLock) { if (gotElectronLock && !isE2EMode) {
try { try {
const fileLock = acquireProcessInstanceFileLock({ const fileLock = acquireProcessInstanceFileLock({
userDataDir: app.getPath('userData'), userDataDir: app.getPath('userData'),
@@ -190,7 +196,9 @@ function createWindow(): BrowserWindow {
// Load the app // Load the app
if (process.env.VITE_DEV_SERVER_URL) { if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL); win.loadURL(process.env.VITE_DEV_SERVER_URL);
win.webContents.openDevTools(); if (!isE2EMode) {
win.webContents.openDevTools();
}
} else { } else {
win.loadFile(join(__dirname, '../../dist/index.html')); win.loadFile(join(__dirname, '../../dist/index.html'));
} }
@@ -265,15 +273,19 @@ async function initialize(): Promise<void> {
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}` `Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
); );
// Warm up network optimization (non-blocking) if (!isE2EMode) {
void warmupNetworkOptimization(); // Warm up network optimization (non-blocking)
void warmupNetworkOptimization();
// Initialize Telemetry early // Initialize Telemetry early
await initTelemetry(); await initTelemetry();
// Apply persisted proxy settings before creating windows or network requests. // Apply persisted proxy settings before creating windows or network requests.
await applyProxySettings(); await applyProxySettings();
await syncLaunchAtStartupSettingFromStore(); await syncLaunchAtStartupSettingFromStore();
} else {
logger.info('Running in E2E mode: startup side effects minimized');
}
// Set application menu // Set application menu
createMenu(); createMenu();
@@ -282,7 +294,9 @@ async function initialize(): Promise<void> {
const window = createMainWindow(); const window = createMainWindow();
// Create system tray // Create system tray
createTray(window); if (!isE2EMode) {
createTray(window);
}
// Override security headers ONLY for the OpenClaw Gateway Control UI. // Override security headers ONLY for the OpenClaw Gateway Control UI.
// The URL filter ensures this callback only fires for gateway requests, // The URL filter ensures this callback only fires for gateway requests,
@@ -326,34 +340,42 @@ async function initialize(): Promise<void> {
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw // Repair any bootstrap files that only contain ClawX markers (no OpenClaw
// template content). This fixes a race condition where ensureClawXContext() // template content). This fixes a race condition where ensureClawXContext()
// previously created the file before the gateway could seed the full template. // previously created the file before the gateway could seed the full template.
void repairClawXOnlyBootstrapFiles().catch((error) => { if (!isE2EMode) {
logger.warn('Failed to repair bootstrap files:', error); void repairClawXOnlyBootstrapFiles().catch((error) => {
}); logger.warn('Failed to repair bootstrap files:', error);
});
}
// Pre-deploy built-in skills (feishu-doc, feishu-drive, feishu-perm, feishu-wiki) // Pre-deploy built-in skills (feishu-doc, feishu-drive, feishu-perm, feishu-wiki)
// to ~/.openclaw/skills/ so they are immediately available without manual install. // to ~/.openclaw/skills/ so they are immediately available without manual install.
void ensureBuiltinSkillsInstalled().catch((error) => { if (!isE2EMode) {
logger.warn('Failed to install built-in skills:', error); void ensureBuiltinSkillsInstalled().catch((error) => {
}); logger.warn('Failed to install built-in skills:', error);
});
}
// Pre-deploy bundled third-party skills from resources/preinstalled-skills. // Pre-deploy bundled third-party skills from resources/preinstalled-skills.
// This installs full skill directories (not only SKILL.md) in an idempotent, // This installs full skill directories (not only SKILL.md) in an idempotent,
// non-destructive way and never blocks startup. // non-destructive way and never blocks startup.
void ensurePreinstalledSkillsInstalled().catch((error) => { if (!isE2EMode) {
logger.warn('Failed to install preinstalled skills:', error); void ensurePreinstalledSkillsInstalled().catch((error) => {
}); logger.warn('Failed to install preinstalled skills:', error);
});
}
// Pre-deploy/upgrade bundled OpenClaw plugins (dingtalk, wecom, qqbot, feishu, wechat) // Pre-deploy/upgrade bundled OpenClaw plugins (dingtalk, wecom, qqbot, feishu, wechat)
// to ~/.openclaw/extensions/ so they are always up-to-date after an app update. // to ~/.openclaw/extensions/ so they are always up-to-date after an app update.
void ensureAllBundledPluginsInstalled().catch((error) => { if (!isE2EMode) {
logger.warn('Failed to install/upgrade bundled plugins:', error); void ensureAllBundledPluginsInstalled().catch((error) => {
}); logger.warn('Failed to install/upgrade bundled plugins:', error);
});
}
// Bridge gateway and host-side events before any auto-start logic runs, so // Bridge gateway and host-side events before any auto-start logic runs, so
// renderer subscribers observe the full startup lifecycle. // renderer subscribers observe the full startup lifecycle.
gatewayManager.on('status', (status: { state: string }) => { gatewayManager.on('status', (status: { state: string }) => {
hostEventBus.emit('gateway:status', status); hostEventBus.emit('gateway:status', status);
if (status.state === 'running') { if (status.state === 'running' && !isE2EMode) {
void ensureClawXContext().catch((error) => { void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error); logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
}); });
@@ -426,7 +448,7 @@ async function initialize(): Promise<void> {
// Start Gateway automatically (this seeds missing bootstrap files with full templates) // Start Gateway automatically (this seeds missing bootstrap files with full templates)
const gatewayAutoStart = await getSetting('gatewayAutoStart'); const gatewayAutoStart = await getSetting('gatewayAutoStart');
if (gatewayAutoStart) { if (!isE2EMode && gatewayAutoStart) {
try { try {
await syncAllProviderAuthToRuntime(); await syncAllProviderAuthToRuntime();
logger.debug('Auto-starting Gateway...'); logger.debug('Auto-starting Gateway...');
@@ -436,6 +458,8 @@ async function initialize(): Promise<void> {
logger.error('Gateway auto-start failed:', error); logger.error('Gateway auto-start failed:', error);
mainWindow?.webContents.send('gateway:error', String(error)); mainWindow?.webContents.send('gateway:error', String(error));
} }
} else if (isE2EMode) {
logger.info('Gateway auto-start skipped in E2E mode');
} else { } else {
logger.info('Gateway auto-start disabled in settings'); logger.info('Gateway auto-start disabled in settings');
} }
@@ -443,19 +467,23 @@ async function initialize(): Promise<void> {
// Merge ClawX context snippets into the workspace bootstrap files. // Merge ClawX context snippets into the workspace bootstrap files.
// The gateway seeds workspace files asynchronously after its HTTP server // The gateway seeds workspace files asynchronously after its HTTP server
// is ready, so ensureClawXContext will retry until the target files appear. // is ready, so ensureClawXContext will retry until the target files appear.
void ensureClawXContext().catch((error) => { if (!isE2EMode) {
logger.warn('Failed to merge ClawX context into workspace:', error); void ensureClawXContext().catch((error) => {
}); logger.warn('Failed to merge ClawX context into workspace:', error);
});
}
// Auto-install openclaw CLI and shell completions (non-blocking). // Auto-install openclaw CLI and shell completions (non-blocking).
void autoInstallCliIfNeeded((installedPath) => { if (!isE2EMode) {
mainWindow?.webContents.send('openclaw:cli-installed', installedPath); void autoInstallCliIfNeeded((installedPath) => {
}).then(() => { mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
generateCompletionCache(); }).then(() => {
installCompletionToProfile(); generateCompletionCache();
}).catch((error) => { installCompletionToProfile();
logger.warn('CLI auto-install failed:', error); }).catch((error) => {
}); logger.warn('CLI auto-install failed:', error);
});
}
} }
if (gotTheLock) { if (gotTheLock) {

View File

@@ -1,6 +1,6 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { PORTS } from '../../utils/config'; import { getPort } from '../../utils/config';
import { getHostApiToken } from '../../api/server'; import { getHostApiToken } from '../../api/server';
type HostApiFetchRequest = { type HostApiFetchRequest = {
@@ -11,6 +11,8 @@ type HostApiFetchRequest = {
}; };
export function registerHostApiProxyHandlers(): void { export function registerHostApiProxyHandlers(): void {
const hostApiPort = getPort('CLAWX_HOST_API');
// Expose the per-session auth token to the renderer so the browser-fallback // Expose the per-session auth token to the renderer so the browser-fallback
// path in host-api.ts can authenticate against the Host API server. // path in host-api.ts can authenticate against the Host API server.
ipcMain.handle('hostapi:token', () => getHostApiToken()); ipcMain.handle('hostapi:token', () => getHostApiToken());
@@ -41,7 +43,7 @@ export function registerHostApiProxyHandlers(): void {
} }
} }
const response = await proxyAwareFetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, { const response = await proxyAwareFetch(`http://127.0.0.1:${hostApiPort}${path}`, {
method, method,
headers, headers,
body, body,

View File

@@ -42,6 +42,8 @@
"lint": "eslint . --fix", "lint": "eslint . --fix",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:e2e": "pnpm run build:vite && playwright test",
"test:e2e:headed": "pnpm run build:vite && playwright test --headed",
"comms:replay": "node scripts/comms/replay.mjs", "comms:replay": "node scripts/comms/replay.mjs",
"comms:baseline": "node scripts/comms/baseline.mjs", "comms:baseline": "node scripts/comms/baseline.mjs",
"comms:compare": "node scripts/comms/compare.mjs", "comms:compare": "node scripts/comms/compare.mjs",
@@ -90,6 +92,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@playwright/test": "^1.56.1",
"@soimy/dingtalk": "^3.4.2", "@soimy/dingtalk": "^3.4.2",
"@tencent-connect/openclaw-qqbot": "^1.6.5", "@tencent-connect/openclaw-qqbot": "^1.6.5",
"@tencent-weixin/openclaw-weixin": "^2.0.1", "@tencent-weixin/openclaw-weixin": "^2.0.1",
@@ -141,4 +144,4 @@
"zx": "^8.8.5" "zx": "^8.8.5"
}, },
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
} }

22
playwright.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
timeout: 90_000,
expect: {
timeout: 15_000,
},
reporter: [
['list'],
['html', { open: 'never' }],
],
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});

39
pnpm-lock.yaml generated
View File

@@ -46,6 +46,9 @@ importers:
'@larksuite/openclaw-lark': '@larksuite/openclaw-lark':
specifier: 2026.3.25 specifier: 2026.3.25
version: 2026.3.25(@napi-rs/canvas@0.1.97)(encoding@0.1.13) version: 2026.3.25(@napi-rs/canvas@0.1.97)(encoding@0.1.13)
'@playwright/test':
specifier: ^1.56.1
version: 1.58.2
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.15 specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1403,6 +1406,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@posthog/core@1.24.1': '@posthog/core@1.24.1':
resolution: {integrity: sha512-e8AciAnc6MRFws89ux8lJKFAaI03yEon0ASDoUO7yS91FVqbUGXYekObUUR3LHplcg+pmyiJBI0jolY0SFbGRA==} resolution: {integrity: sha512-e8AciAnc6MRFws89ux8lJKFAaI03yEon0ASDoUO7yS91FVqbUGXYekObUUR3LHplcg+pmyiJBI0jolY0SFbGRA==}
@@ -2518,8 +2526,8 @@ packages:
link-preview-js: link-preview-js:
optional: true optional: true
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git}
version: 2.0.1 version: 2.0.1
'@xmldom/xmldom@0.8.11': '@xmldom/xmldom@0.8.11':
@@ -3580,6 +3588,11 @@ packages:
fs.realpath@1.0.0: fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4771,6 +4784,11 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
plist@3.1.0: plist@3.1.0:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'} engines: {node: '>=10.4.0'}
@@ -7419,6 +7437,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@posthog/core@1.24.1': '@posthog/core@1.24.1':
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@@ -8648,7 +8670,7 @@ snapshots:
'@cacheable/node-cache': 1.7.6 '@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4 '@hapi/boom': 9.1.4
async-mutex: 0.5.0 async-mutex: 0.5.0
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
lru-cache: 11.2.7 lru-cache: 11.2.7
music-metadata: 11.12.3 music-metadata: 11.12.3
p-queue: 9.1.0 p-queue: 9.1.0
@@ -8661,7 +8683,7 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
dependencies: dependencies:
curve25519-js: 0.0.4 curve25519-js: 0.0.4
protobufjs: 6.8.8 protobufjs: 6.8.8
@@ -9910,6 +9932,9 @@ snapshots:
fs.realpath@1.0.0: {} fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -11414,6 +11439,12 @@ snapshots:
playwright-core@1.58.2: {} playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
plist@3.1.0: plist@3.1.0:
dependencies: dependencies:
'@xmldom/xmldom': 0.8.11 '@xmldom/xmldom': 0.8.11

View File

@@ -8,14 +8,14 @@ import { TitleBar } from './TitleBar';
export function MainLayout() { export function MainLayout() {
return ( return (
<div className="flex h-screen flex-col overflow-hidden bg-background"> <div data-testid="main-layout" className="flex h-screen flex-col overflow-hidden bg-background">
{/* Title bar: drag region on macOS, icon + controls on Windows */} {/* Title bar: drag region on macOS, icon + controls on Windows */}
<TitleBar /> <TitleBar />
{/* Below the title bar: sidebar + content */} {/* Below the title bar: sidebar + content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<Sidebar /> <Sidebar />
<main className="flex-1 overflow-auto p-6"> <main data-testid="main-content" className="flex-1 overflow-auto p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -46,13 +46,15 @@ interface NavItemProps {
badge?: string; badge?: string;
collapsed?: boolean; collapsed?: boolean;
onClick?: () => void; onClick?: () => void;
testId?: string;
} }
function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) { function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItemProps) {
return ( return (
<NavLink <NavLink
to={to} to={to}
onClick={onClick} onClick={onClick}
data-testid={testId}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors', 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors',
@@ -206,15 +208,16 @@ export function Sidebar() {
} }
const navItems = [ const navItems = [
{ to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models') }, { to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
{ to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents') }, { to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels') }, { to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
{ to: '/skills', icon: <Puzzle className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.skills') }, { to: '/skills', icon: <Puzzle className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.skills'), testId: 'sidebar-nav-skills' },
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks') }, { to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
]; ];
return ( return (
<aside <aside
data-testid="sidebar"
className={cn( className={cn(
'flex shrink-0 flex-col border-r bg-[#eae8e1]/60 dark:bg-background transition-all duration-300', 'flex shrink-0 flex-col border-r bg-[#eae8e1]/60 dark:bg-background transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64' sidebarCollapsed ? 'w-16' : 'w-64'
@@ -247,6 +250,7 @@ export function Sidebar() {
{/* Navigation */} {/* Navigation */}
<nav className="flex flex-col px-2 gap-0.5"> <nav className="flex flex-col px-2 gap-0.5">
<button <button
data-testid="sidebar-new-chat"
onClick={() => { onClick={() => {
const { messages } = useChatStore.getState(); const { messages } = useChatStore.getState();
if (messages.length > 0) newSession(); if (messages.length > 0) newSession();
@@ -334,6 +338,7 @@ export function Sidebar() {
<div className="p-2 mt-auto"> <div className="p-2 mt-auto">
<NavLink <NavLink
to="/settings" to="/settings"
data-testid="sidebar-nav-settings"
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors', 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors',
@@ -354,6 +359,7 @@ export function Sidebar() {
</NavLink> </NavLink>
<Button <Button
data-testid="sidebar-open-dev-console"
variant="ghost" variant="ghost"
className={cn( className={cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-[14px] font-medium transition-colors w-full mt-1', 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-[14px] font-medium transition-colors w-full mt-1',
@@ -391,4 +397,4 @@ export function Sidebar() {
/> />
</aside> </aside>
); );
} }

View File

@@ -240,12 +240,12 @@ export function ProvidersSettings() {
}; };
return ( return (
<div className="space-y-6"> <div data-testid="providers-settings" className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-3xl font-serif text-foreground font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}> <h2 data-testid="providers-settings-title" className="text-3xl font-serif text-foreground font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('aiProviders.title', 'AI Providers')} {t('aiProviders.title', 'AI Providers')}
</h2> </h2>
<Button onClick={() => setShowAddDialog(true)} className="rounded-full px-5 h-9 shadow-none font-medium text-[13px]"> <Button data-testid="providers-add-button" onClick={() => setShowAddDialog(true)} className="rounded-full px-5 h-9 shadow-none font-medium text-[13px]">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t('aiProviders.add')} {t('aiProviders.add')}
</Button> </Button>
@@ -256,7 +256,7 @@ export function ProvidersSettings() {
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
</div> </div>
) : displayProviders.length === 0 ? ( ) : displayProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed"> <div data-testid="providers-empty-state" className="flex flex-col items-center justify-center py-20 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed">
<Key className="h-12 w-12 mb-4 opacity-50" /> <Key className="h-12 w-12 mb-4 opacity-50" />
<h3 className="text-[15px] font-medium mb-1 text-foreground">{t('aiProviders.empty.title')}</h3> <h3 className="text-[15px] font-medium mb-1 text-foreground">{t('aiProviders.empty.title')}</h3>
<p className="text-[13px] text-center mb-6 max-w-sm"> <p className="text-[13px] text-center mb-6 max-w-sm">
@@ -505,6 +505,7 @@ function ProviderCard({
return ( return (
<div <div
data-testid={`provider-card-${account.id}`}
className={cn( className={cn(
"group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden hover:bg-black/5 dark:hover:bg-white/5", "group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden hover:bg-black/5 dark:hover:bg-white/5",
isDefault isDefault
@@ -569,10 +570,11 @@ function ProviderCard({
{!isEditing && ( {!isEditing && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!isDefault && ( {!isDefault && (
<Button <Button
variant="ghost" data-testid={`provider-set-default-${account.id}`}
size="icon" variant="ghost"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-blue-600 hover:bg-white dark:hover:bg-card shadow-sm" size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-blue-600 hover:bg-white dark:hover:bg-card shadow-sm"
onClick={onSetDefault} onClick={onSetDefault}
title={t('aiProviders.card.setDefault')} title={t('aiProviders.card.setDefault')}
> >
@@ -580,6 +582,7 @@ function ProviderCard({
</Button> </Button>
)} )}
<Button <Button
data-testid={`provider-edit-${account.id}`}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-white dark:hover:bg-card shadow-sm" className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-white dark:hover:bg-card shadow-sm"
@@ -589,6 +592,7 @@ function ProviderCard({
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button <Button
data-testid={`provider-delete-${account.id}`}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-destructive hover:bg-white dark:hover:bg-card shadow-sm" className="h-8 w-8 rounded-full text-muted-foreground hover:text-destructive hover:bg-white dark:hover:bg-card shadow-sm"
@@ -1205,7 +1209,7 @@ function AddProviderDialog({
}; };
return ( return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"> <div data-testid="add-provider-dialog" className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[90vh] flex flex-col rounded-3xl border-0 shadow-2xl bg-[#f3f1e9] dark:bg-card overflow-hidden"> <Card className="w-full max-w-2xl max-h-[90vh] flex flex-col rounded-3xl border-0 shadow-2xl bg-[#f3f1e9] dark:bg-card overflow-hidden">
<CardHeader className="relative pb-2 shrink-0"> <CardHeader className="relative pb-2 shrink-0">
<CardTitle className="text-2xl font-serif font-normal">{t('aiProviders.dialog.title')}</CardTitle> <CardTitle className="text-2xl font-serif font-normal">{t('aiProviders.dialog.title')}</CardTitle>
@@ -1213,6 +1217,7 @@ function AddProviderDialog({
{t('aiProviders.dialog.desc')} {t('aiProviders.dialog.desc')}
</CardDescription> </CardDescription>
<Button <Button
data-testid="add-provider-close-button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="absolute right-4 top-4 rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="absolute right-4 top-4 rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
@@ -1226,6 +1231,7 @@ function AddProviderDialog({
<div className="grid grid-cols-2 md:grid-cols-3 gap-3"> <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{availableTypes.map((type) => ( {availableTypes.map((type) => (
<button <button
data-testid={`add-provider-type-${type.id}`}
key={type.id} key={type.id}
onClick={() => { onClick={() => {
setSelectedType(type.id); setSelectedType(type.id);
@@ -1296,6 +1302,7 @@ function AddProviderDialog({
<div className="space-y-2.5"> <div className="space-y-2.5">
<Label htmlFor="name" className={labelClasses}>{t('aiProviders.dialog.displayName')}</Label> <Label htmlFor="name" className={labelClasses}>{t('aiProviders.dialog.displayName')}</Label>
<Input <Input
data-testid="add-provider-name-input"
id="name" id="name"
placeholder={typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name} placeholder={typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name}
value={name} value={name}
@@ -1347,6 +1354,7 @@ function AddProviderDialog({
</div> </div>
<div className="relative"> <div className="relative">
<Input <Input
data-testid="add-provider-api-key-input"
id="apiKey" id="apiKey"
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : typeInfo?.placeholder} placeholder={typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : typeInfo?.placeholder}
@@ -1378,6 +1386,7 @@ function AddProviderDialog({
<div className="space-y-2.5"> <div className="space-y-2.5">
<Label htmlFor="baseUrl" className={labelClasses}>{t('aiProviders.dialog.baseUrl')}</Label> <Label htmlFor="baseUrl" className={labelClasses}>{t('aiProviders.dialog.baseUrl')}</Label>
<Input <Input
data-testid="add-provider-base-url-input"
id="baseUrl" id="baseUrl"
placeholder={getProtocolBaseUrlPlaceholder(apiProtocol)} placeholder={getProtocolBaseUrlPlaceholder(apiProtocol)}
value={baseUrl} value={baseUrl}
@@ -1391,6 +1400,7 @@ function AddProviderDialog({
<div className="space-y-2.5"> <div className="space-y-2.5">
<Label htmlFor="modelId" className={labelClasses}>{t('aiProviders.dialog.modelId')}</Label> <Label htmlFor="modelId" className={labelClasses}>{t('aiProviders.dialog.modelId')}</Label>
<Input <Input
data-testid="add-provider-model-id-input"
id="modelId" id="modelId"
placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'} placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'}
value={modelId} value={modelId}

View File

@@ -170,7 +170,7 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'openai/gpt-5.4', defaultModelId: 'openai/gpt-5.4', docsUrl: 'https://openrouter.ai/models' }, { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'openai/gpt-5.4', defaultModelId: 'openai/gpt-5.4', docsUrl: 'https://openrouter.ai/models' },
{ id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' }, { id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' }, { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' }, { id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' },
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' }, { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' },
{ id: 'qwen-portal', name: 'Qwen (Global)', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'coder-model' }, { id: 'qwen-portal', name: 'Qwen (Global)', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'coder-model' },
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' }, { id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' },

View File

@@ -192,13 +192,13 @@ export function Models() {
const usageLoading = isGatewayRunning && fetchState.status === 'loading'; const usageLoading = isGatewayRunning && fetchState.status === 'loading';
return ( return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden"> <div data-testid="models-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16"> <div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
{/* Header */} {/* Header */}
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4"> <div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
<div> <div>
<h1 className="text-5xl md:text-6xl font-serif text-foreground mb-3 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}> <h1 data-testid="models-page-title" className="text-5xl md:text-6xl font-serif text-foreground mb-3 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('dashboard:models.title')} {t('dashboard:models.title')}
</h1> </h1>
<p className="text-[17px] text-foreground/70 font-medium"> <p className="text-[17px] text-foreground/70 font-medium">

View File

@@ -448,7 +448,7 @@ export function Settings() {
}; };
return ( return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden"> <div data-testid="settings-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16"> <div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
{/* Header */} {/* Header */}
@@ -612,6 +612,7 @@ export function Settings() {
</p> </p>
</div> </div>
<Switch <Switch
data-testid="settings-dev-mode-switch"
checked={devModeUnlocked} checked={devModeUnlocked}
onCheckedChange={setDevModeUnlocked} onCheckedChange={setDevModeUnlocked}
/> />
@@ -638,8 +639,8 @@ export function Settings() {
{devModeUnlocked && ( {devModeUnlocked && (
<> <>
<Separator className="bg-black/5 dark:bg-white/5" /> <Separator className="bg-black/5 dark:bg-white/5" />
<div> <div data-testid="settings-developer-section">
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}> <h2 data-testid="settings-developer-title" className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('developer.title')} {t('developer.title')}
</h2> </h2>
<div className="space-y-8"> <div className="space-y-8">
@@ -756,6 +757,7 @@ export function Settings() {
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Input <Input
data-testid="settings-developer-gateway-token"
readOnly readOnly
value={controlUiInfo?.token || ''} value={controlUiInfo?.token || ''}
placeholder={t('developer.tokenUnavailable')} placeholder={t('developer.tokenUnavailable')}

View File

@@ -200,7 +200,7 @@ export function Setup() {
return ( return (
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground"> <div data-testid="setup-page" className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
<TitleBar /> <TitleBar />
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{/* Progress Indicator */} {/* Progress Indicator */}
@@ -293,11 +293,11 @@ export function Setup() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{!isLastStep && safeStepIndex !== STEP.RUNTIME && ( {!isLastStep && safeStepIndex !== STEP.RUNTIME && (
<Button variant="ghost" onClick={handleSkip}> <Button data-testid="setup-skip-button" variant="ghost" onClick={handleSkip}>
{t('nav.skipSetup')} {t('nav.skipSetup')}
</Button> </Button>
)} )}
<Button onClick={handleNext} disabled={!canProceed}> <Button data-testid="setup-next-button" onClick={handleNext} disabled={!canProceed}>
{isLastStep ? ( {isLastStep ? (
t('nav.getStarted') t('nav.getStarted')
) : ( ) : (
@@ -324,7 +324,7 @@ function WelcomeContent() {
const { language, setLanguage } = useSettingsStore(); const { language, setLanguage } = useSettingsStore();
return ( return (
<div className="text-center space-y-4"> <div data-testid="setup-welcome-step" className="text-center space-y-4">
<div className="mb-4 flex justify-center"> <div className="mb-4 flex justify-center">
<img src={clawxIcon} alt="ClawX" className="h-16 w-16" /> <img src={clawxIcon} alt="ClawX" className="h-16 w-16" />
</div> </div>

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 openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow'); const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
@@ -110,11 +110,12 @@ describe('provider metadata', () => {
}); });
expect(siliconflow).toMatchObject({ expect(siliconflow).toMatchObject({
showModelId: true, showModelId: true,
showModelIdInDevModeOnly: true,
defaultModelId: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3',
}); });
expect(shouldShowProviderModelId(openrouter, false)).toBe(true); 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(openrouter, true)).toBe(true);
expect(shouldShowProviderModelId(siliconflow, true)).toBe(true); expect(shouldShowProviderModelId(siliconflow, true)).toBe(true);
}); });
@@ -151,19 +152,20 @@ describe('provider metadata', () => {
expect(resolveProviderModelForSave(qwen, ' ', true)).toBe('coder-model'); 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 openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow'); const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
const ark = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'ark'); const ark = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'ark');
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', false)).toBe('openai/gpt-5'); 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(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(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, ' ', false)).toBe('openai/gpt-5.4');
expect(resolveProviderModelForSave(openrouter, ' ', true)).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(siliconflow, ' ', true)).toBe('deepseek-ai/DeepSeek-V3');
expect(resolveProviderModelForSave(ark, ' ep-custom-model ', false)).toBe('ep-custom-model'); expect(resolveProviderModelForSave(ark, ' ep-custom-model ', false)).toBe('ep-custom-model');
}); });

View File

@@ -8,7 +8,7 @@ export default defineConfig({
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./tests/setup.ts'], setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.{test,spec}.{ts,tsx}'], include: ['tests/unit/**/*.{test,spec}.{ts,tsx}'],
coverage: { coverage: {
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'tests/'], exclude: ['node_modules/', 'tests/'],