From 26680828091d80b117b3b7e255925098e39e3683 Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Sat, 28 Mar 2026 15:34:20 +0800 Subject: [PATCH] add electron e2e harness and regression coverage (#697) --- .github/workflows/electron-e2e.yml | 67 ++++++++++ .gitignore | 2 + README.md | 24 ++++ electron/api/server.ts | 4 +- electron/main/index.ts | 100 +++++++++------ electron/main/ipc/host-api-proxy.ts | 6 +- package.json | 5 +- playwright.config.ts | 22 ++++ pnpm-lock.yaml | 39 +++++- src/components/layout/MainLayout.tsx | 4 +- src/components/layout/Sidebar.tsx | 20 ++- src/components/settings/ProvidersSettings.tsx | 28 ++-- src/lib/providers.ts | 2 +- src/pages/Models/index.tsx | 4 +- src/pages/Settings/index.tsx | 8 +- src/pages/Setup/index.tsx | 8 +- tests/e2e/app-smoke.spec.ts | 41 ++++++ tests/e2e/developer-mode.spec.ts | 32 +++++ tests/e2e/fixtures/electron.ts | 120 ++++++++++++++++++ tests/e2e/provider-lifecycle.spec.ts | 65 ++++++++++ tests/unit/providers.test.ts | 10 +- vitest.config.ts | 2 +- 22 files changed, 535 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/electron-e2e.yml create mode 100644 playwright.config.ts create mode 100644 tests/e2e/app-smoke.spec.ts create mode 100644 tests/e2e/developer-mode.spec.ts create mode 100644 tests/e2e/fixtures/electron.ts create mode 100644 tests/e2e/provider-lifecycle.spec.ts diff --git a/.github/workflows/electron-e2e.yml b/.github/workflows/electron-e2e.yml new file mode 100644 index 000000000..b1ad8705d --- /dev/null +++ b/.github/workflows/electron-e2e.yml @@ -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 diff --git a/.gitignore b/.gitignore index 710c17300..301135b5e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ desktop.ini # Test coverage coverage/ +playwright-report/ +test-results/ # Cache .cache/ diff --git a/README.md b/README.md index 322d31381..f4c6cc0fb 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,8 @@ pnpm typecheck # TypeScript validation # Testing 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:baseline # Refresh communication baseline snapshot 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. + +### 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 | Layer | Technology | diff --git a/electron/api/server.ts b/electron/api/server.ts index 73838d4d7..aca42e750 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; 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 type { HostApiContext } from './context'; import { handleAppRoutes } from './routes/app'; @@ -53,7 +53,7 @@ export function getHostApiToken(): string { 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. hostApiToken = randomBytes(32).toString('hex'); diff --git a/electron/main/index.ts b/electron/main/index.ts index b90cfdb2d..f2dc1c854 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -45,6 +45,12 @@ import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync'; 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 // 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 // it — creating an infinite kill/restart loop on Windows. // The losing process must exit immediately so it never reaches Gateway startup. -const gotElectronLock = app.requestSingleInstanceLock(); +const gotElectronLock = isE2EMode ? true : app.requestSingleInstanceLock(); if (!gotElectronLock) { console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process'); app.exit(0); } let releaseProcessInstanceFileLock: () => void = () => {}; let gotFileLock = true; -if (gotElectronLock) { +if (gotElectronLock && !isE2EMode) { try { const fileLock = acquireProcessInstanceFileLock({ userDataDir: app.getPath('userData'), @@ -190,7 +196,9 @@ function createWindow(): BrowserWindow { // Load the app if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL); - win.webContents.openDevTools(); + if (!isE2EMode) { + win.webContents.openDevTools(); + } } else { win.loadFile(join(__dirname, '../../dist/index.html')); } @@ -265,15 +273,19 @@ async function initialize(): Promise { `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) - void warmupNetworkOptimization(); + if (!isE2EMode) { + // Warm up network optimization (non-blocking) + void warmupNetworkOptimization(); - // Initialize Telemetry early - await initTelemetry(); + // Initialize Telemetry early + await initTelemetry(); - // Apply persisted proxy settings before creating windows or network requests. - await applyProxySettings(); - await syncLaunchAtStartupSettingFromStore(); + // Apply persisted proxy settings before creating windows or network requests. + await applyProxySettings(); + await syncLaunchAtStartupSettingFromStore(); + } else { + logger.info('Running in E2E mode: startup side effects minimized'); + } // Set application menu createMenu(); @@ -282,7 +294,9 @@ async function initialize(): Promise { const window = createMainWindow(); // Create system tray - createTray(window); + if (!isE2EMode) { + createTray(window); + } // Override security headers ONLY for the OpenClaw Gateway Control UI. // The URL filter ensures this callback only fires for gateway requests, @@ -326,34 +340,42 @@ async function initialize(): Promise { // Repair any bootstrap files that only contain ClawX markers (no OpenClaw // template content). This fixes a race condition where ensureClawXContext() // previously created the file before the gateway could seed the full template. - void repairClawXOnlyBootstrapFiles().catch((error) => { - logger.warn('Failed to repair bootstrap files:', error); - }); + if (!isE2EMode) { + 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) // to ~/.openclaw/skills/ so they are immediately available without manual install. - void ensureBuiltinSkillsInstalled().catch((error) => { - logger.warn('Failed to install built-in skills:', error); - }); + if (!isE2EMode) { + void ensureBuiltinSkillsInstalled().catch((error) => { + logger.warn('Failed to install built-in skills:', error); + }); + } // Pre-deploy bundled third-party skills from resources/preinstalled-skills. // This installs full skill directories (not only SKILL.md) in an idempotent, // non-destructive way and never blocks startup. - void ensurePreinstalledSkillsInstalled().catch((error) => { - logger.warn('Failed to install preinstalled skills:', error); - }); + if (!isE2EMode) { + void ensurePreinstalledSkillsInstalled().catch((error) => { + logger.warn('Failed to install preinstalled skills:', error); + }); + } // 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. - void ensureAllBundledPluginsInstalled().catch((error) => { - logger.warn('Failed to install/upgrade bundled plugins:', error); - }); + if (!isE2EMode) { + 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 // renderer subscribers observe the full startup lifecycle. gatewayManager.on('status', (status: { state: string }) => { hostEventBus.emit('gateway:status', status); - if (status.state === 'running') { + if (status.state === 'running' && !isE2EMode) { void ensureClawXContext().catch((error) => { logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error); }); @@ -426,7 +448,7 @@ async function initialize(): Promise { // Start Gateway automatically (this seeds missing bootstrap files with full templates) const gatewayAutoStart = await getSetting('gatewayAutoStart'); - if (gatewayAutoStart) { + if (!isE2EMode && gatewayAutoStart) { try { await syncAllProviderAuthToRuntime(); logger.debug('Auto-starting Gateway...'); @@ -436,6 +458,8 @@ async function initialize(): Promise { logger.error('Gateway auto-start failed:', error); mainWindow?.webContents.send('gateway:error', String(error)); } + } else if (isE2EMode) { + logger.info('Gateway auto-start skipped in E2E mode'); } else { logger.info('Gateway auto-start disabled in settings'); } @@ -443,19 +467,23 @@ async function initialize(): Promise { // Merge ClawX context snippets into the workspace bootstrap files. // The gateway seeds workspace files asynchronously after its HTTP server // is ready, so ensureClawXContext will retry until the target files appear. - void ensureClawXContext().catch((error) => { - logger.warn('Failed to merge ClawX context into workspace:', error); - }); + if (!isE2EMode) { + void ensureClawXContext().catch((error) => { + logger.warn('Failed to merge ClawX context into workspace:', error); + }); + } // Auto-install openclaw CLI and shell completions (non-blocking). - void autoInstallCliIfNeeded((installedPath) => { - mainWindow?.webContents.send('openclaw:cli-installed', installedPath); - }).then(() => { - generateCompletionCache(); - installCompletionToProfile(); - }).catch((error) => { - logger.warn('CLI auto-install failed:', error); - }); + if (!isE2EMode) { + void autoInstallCliIfNeeded((installedPath) => { + mainWindow?.webContents.send('openclaw:cli-installed', installedPath); + }).then(() => { + generateCompletionCache(); + installCompletionToProfile(); + }).catch((error) => { + logger.warn('CLI auto-install failed:', error); + }); + } } if (gotTheLock) { diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts index a82f56520..09f606e77 100644 --- a/electron/main/ipc/host-api-proxy.ts +++ b/electron/main/ipc/host-api-proxy.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; -import { PORTS } from '../../utils/config'; +import { getPort } from '../../utils/config'; import { getHostApiToken } from '../../api/server'; type HostApiFetchRequest = { @@ -11,6 +11,8 @@ type HostApiFetchRequest = { }; export function registerHostApiProxyHandlers(): void { + const hostApiPort = getPort('CLAWX_HOST_API'); + // 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. 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, headers, body, diff --git a/package.json b/package.json index e3d33bf66..99132bcd5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "lint": "eslint . --fix", "typecheck": "tsc --noEmit", "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:baseline": "node scripts/comms/baseline.mjs", "comms:compare": "node scripts/comms/compare.mjs", @@ -90,6 +92,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@playwright/test": "^1.56.1", "@soimy/dingtalk": "^3.4.2", "@tencent-connect/openclaw-qqbot": "^1.6.5", "@tencent-weixin/openclaw-weixin": "^2.0.1", @@ -141,4 +144,4 @@ "zx": "^8.8.5" }, "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..7c5e8331f --- /dev/null +++ b/playwright.config.ts @@ -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', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f27b6e3d..cd3ba60ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: '@larksuite/openclaw-lark': specifier: 2026.3.25 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': 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) @@ -1403,6 +1406,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@posthog/core@1.24.1': resolution: {integrity: sha512-e8AciAnc6MRFws89ux8lJKFAaI03yEon0ASDoUO7yS91FVqbUGXYekObUUR3LHplcg+pmyiJBI0jolY0SFbGRA==} @@ -2518,8 +2526,8 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} + '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git} version: 2.0.1 '@xmldom/xmldom@0.8.11': @@ -3580,6 +3588,11 @@ packages: fs.realpath@1.0.0: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4771,6 +4784,11 @@ packages: engines: {node: '>=18'} hasBin: true + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -7419,6 +7437,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@posthog/core@1.24.1': dependencies: cross-spawn: 7.0.6 @@ -8648,7 +8670,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 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 music-metadata: 11.12.3 p-queue: 9.1.0 @@ -8661,7 +8683,7 @@ snapshots: - supports-color - 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: curve25519-js: 0.0.4 protobufjs: 6.8.8 @@ -9910,6 +9932,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -11414,6 +11439,12 @@ snapshots: playwright-core@1.58.2: {} + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 942184966..ba2ac05c7 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -8,14 +8,14 @@ import { TitleBar } from './TitleBar'; export function MainLayout() { return ( -
+
{/* Title bar: drag region on macOS, icon + controls on Windows */} {/* Below the title bar: sidebar + content */}
-
+
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 86e6eb1a0..c8f39009a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -46,13 +46,15 @@ interface NavItemProps { badge?: string; collapsed?: boolean; onClick?: () => void; + testId?: string; } -function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) { +function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItemProps) { return ( cn( '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 = [ - { to: '/models', icon: , label: t('sidebar.models') }, - { to: '/agents', icon: , label: t('sidebar.agents') }, - { to: '/channels', icon: , label: t('sidebar.channels') }, - { to: '/skills', icon: , label: t('sidebar.skills') }, - { to: '/cron', icon: , label: t('sidebar.cronTasks') }, + { to: '/models', icon: , label: t('sidebar.models'), testId: 'sidebar-nav-models' }, + { to: '/agents', icon: , label: t('sidebar.agents'), testId: 'sidebar-nav-agents' }, + { to: '/channels', icon: , label: t('sidebar.channels'), testId: 'sidebar-nav-channels' }, + { to: '/skills', icon: , label: t('sidebar.skills'), testId: 'sidebar-nav-skills' }, + { to: '/cron', icon: , label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' }, ]; return ( ); -} \ No newline at end of file +} diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index b54f7436b..4638408f3 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -240,12 +240,12 @@ export function ProvidersSettings() { }; return ( -
+
-

+

{t('aiProviders.title', 'AI Providers')}

- @@ -256,7 +256,7 @@ export function ProvidersSettings() {
) : displayProviders.length === 0 ? ( -
+

{t('aiProviders.empty.title')}

@@ -505,6 +505,7 @@ function ProviderCard({ return (

{!isDefault && ( - )} )} -