diff --git a/admin-v2/playwright.config.ts b/admin-v2/playwright.config.ts new file mode 100644 index 0000000..7dc53a7 --- /dev/null +++ b/admin-v2/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Admin V2 E2E 测试配置 + * + * 断裂探测冒烟测试 — 验证 Admin V2 页面与 SaaS 后端的连通性 + * + * 前提条件: + * - SaaS Server 运行在 http://localhost:8080 + * - Admin V2 dev server 运行在 http://localhost:5173 + * - 数据库有种子数据 (super_admin: testadmin/Admin123456) + */ +export default defineConfig({ + testDir: './tests/e2e', + timeout: 60000, + expect: { + timeout: 10000, + }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: [ + ['list'], + ['html', { outputFolder: 'test-results/html-report' }], + ], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 10000, + navigationTimeout: 30000, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 720 }, + }, + }, + ], + webServer: { + command: 'pnpm dev --port 5173', + url: 'http://localhost:5173', + reuseExistingServer: true, + timeout: 30000, + }, + outputDir: 'test-results/artifacts', +}); diff --git a/admin-v2/tests/e2e/smoke_admin.spec.ts b/admin-v2/tests/e2e/smoke_admin.spec.ts new file mode 100644 index 0000000..754d0b6 --- /dev/null +++ b/admin-v2/tests/e2e/smoke_admin.spec.ts @@ -0,0 +1,182 @@ +/** + * Smoke Tests — Admin V2 连通性断裂探测 + * + * 6 个冒烟测试验证 Admin V2 页面与 SaaS 后端的完整连通性。 + * 所有测试使用真实浏览器 + 真实 SaaS Server。 + * + * 前提条件: + * - SaaS Server 运行在 http://localhost:8080 + * - Admin V2 dev server 运行在 http://localhost:5173 + * - 种子用户: testadmin / Admin123456 (super_admin) + * + * 运行: cd admin-v2 && npx playwright test smoke_admin + */ + +import { test, expect, type Page } from '@playwright/test'; + +const SaaS_BASE = 'http://localhost:8080/api/v1'; +const ADMIN_USER = 'testadmin'; +const ADMIN_PASS = 'Admin123456'; + +// Helper: 通过 API 登录获取 HttpOnly cookie,避免 UI 登录的复杂性 +async function apiLogin(page: Page) { + await page.request.post(`${SaaS_BASE}/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); +} + +// Helper: 通过 API 登录 + 导航到指定路径 +async function loginAndGo(page: Page, path: string) { + await apiLogin(page); + await page.goto(path); + // 等待主内容区加载 + await page.waitForSelector('#main-content', { timeout: 15000 }); +} + +// ── A1: 登录→Dashboard ──────────────────────────────────────────── + +test('A1: 登录→Dashboard 5个统计卡片', async ({ page }) => { + // 导航到登录页 + await page.goto('/login'); + await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 10000 }); + + // 填写表单 + await page.getByPlaceholder('请输入用户名').fill(ADMIN_USER); + await page.getByPlaceholder('请输入密码').fill(ADMIN_PASS); + + // 提交 + await page.getByRole('button', { name: /登录/ }).click(); + + // 验证跳转到 Dashboard + await expect(page).toHaveURL(/\/$/, { timeout: 15000 }); + + // 验证 5 个统计卡片 + await expect(page.getByText('总账号')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('活跃服务商')).toBeVisible(); + await expect(page.getByText('活跃模型')).toBeVisible(); + await expect(page.getByText('今日请求')).toBeVisible(); + await expect(page.getByText('今日 Token')).toBeVisible(); + + // 验证统计卡片有数值 (不是 loading 状态) + const statCards = page.locator('.ant-statistic-content-value'); + await expect(statCards.first()).not.toBeEmpty({ timeout: 10000 }); +}); + +// ── A2: Provider CRUD ────────────────────────────────────────────── + +test('A2: Provider 创建→列表可见→禁用', async ({ page }) => { + // 通过 API 创建 Provider + await apiLogin(page); + const createRes = await page.request.post(`${SaaS_BASE}/providers`, { + data: { + name: `smoke_provider_${Date.now()}`, + provider_type: 'openai', + base_url: 'https://api.smoke.test/v1', + enabled: true, + }, + }); + expect(createRes.ok()).toBeTruthy(); + + // 导航到 Model Services 页面 + await page.goto('/model-services'); + await page.waitForSelector('#main-content', { timeout: 15000 }); + + // 切换到 Provider tab (如果存在 tab 切换) + const providerTab = page.getByRole('tab', { name: /服务商|Provider/i }); + if (await providerTab.isVisible()) { + await providerTab.click(); + } + + // 验证 Provider 列表非空 + const tableRows = page.locator('.ant-table-row'); + await expect(tableRows.first()).toBeVisible({ timeout: 10000 }); + expect(await tableRows.count()).toBeGreaterThan(0); +}); + +// ── A3: Account 管理 ─────────────────────────────────────────────── + +test('A3: Account 列表加载→角色可见', async ({ page }) => { + await loginAndGo(page, '/accounts'); + + // 验证表格加载 + const tableRows = page.locator('.ant-table-row'); + await expect(tableRows.first()).toBeVisible({ timeout: 10000 }); + + // 至少有 testadmin 自己 + expect(await tableRows.count()).toBeGreaterThanOrEqual(1); + + // 验证有角色列 + const roleText = await page.locator('.ant-table').textContent(); + expect(roleText).toMatch(/super_admin|admin|user/); +}); + +// ── A4: 知识管理 ─────────────────────────────────────────────────── + +test('A4: 知识分类→条目→搜索', async ({ page }) => { + // 通过 API 创建分类和条目 + await apiLogin(page); + + const catRes = await page.request.post(`${SaaS_BASE}/knowledge/categories`, { + data: { name: `smoke_cat_${Date.now()}`, description: 'Smoke test category' }, + }); + expect(catRes.ok()).toBeTruthy(); + const catJson = await catRes.json(); + + const itemRes = await page.request.post(`${SaaS_BASE}/knowledge/items`, { + data: { + title: 'Smoke Test Knowledge Item', + content: 'This is a smoke test knowledge entry for E2E testing.', + category_id: catJson.id, + tags: ['smoke', 'test'], + }, + }); + expect(itemRes.ok()).toBeTruthy(); + + // 导航到知识库页面 + await page.goto('/knowledge'); + await page.waitForSelector('#main-content', { timeout: 15000 }); + + // 验证页面加载 (有内容) + const content = await page.locator('#main-content').textContent(); + expect(content!.length).toBeGreaterThan(0); +}); + +// ── A5: 角色权限 ─────────────────────────────────────────────────── + +test('A5: 角色页面加载→角色列表非空', async ({ page }) => { + await loginAndGo(page, '/roles'); + + // 验证角色内容加载 + await page.waitForTimeout(1000); + + // 检查页面有角色相关内容 (可能是表格或卡片) + const content = await page.locator('#main-content').textContent(); + expect(content!.length).toBeGreaterThan(0); + + // 通过 API 验证角色存在 + const rolesRes = await page.request.get(`${SaaS_BASE}/roles`); + expect(rolesRes.ok()).toBeTruthy(); + const rolesJson = await rolesRes.json(); + expect(Array.isArray(rolesJson) || rolesJson.roles).toBeTruthy(); +}); + +// ── A6: 模型+Key池 ──────────────────────────────────────────────── + +test('A6: 模型服务页面加载→Provider和Model tab可见', async ({ page }) => { + await loginAndGo(page, '/model-services'); + + // 验证页面标题或内容 + const content = await page.locator('#main-content').textContent(); + expect(content!.length).toBeGreaterThan(0); + + // 检查是否有 Tab 切换 (服务商/模型/API Key) + const tabs = page.locator('.ant-tabs-tab'); + if (await tabs.first().isVisible()) { + const tabCount = await tabs.count(); + expect(tabCount).toBeGreaterThanOrEqual(1); + } + + // 通过 API 验证能列出 Provider + const provRes = await page.request.get(`${SaaS_BASE}/providers`); + expect(provRes.ok()).toBeTruthy(); +}); diff --git a/crates/zclaw-saas/tests/smoke_saas.rs b/crates/zclaw-saas/tests/smoke_saas.rs new file mode 100644 index 0000000..a16019c --- /dev/null +++ b/crates/zclaw-saas/tests/smoke_saas.rs @@ -0,0 +1,331 @@ +//! Smoke Tests — SaaS API 端到端断裂探测 +//! +//! 6 个冒烟测试验证 SaaS 后端的完整业务闭环。 +//! 每个测试追踪从请求到响应到 DB 状态的完整路径。 +//! +//! 运行: cargo test -p zclaw-saas --test smoke_saas -- --test-threads=1 + +mod common; +use common::*; +use axum::http::StatusCode; +use serde_json::json; + +// ── S1: 认证闭环 ────────────────────────────────────────────────── + +#[tokio::test] +async fn s1_auth_full_lifecycle() { + let (app, pool) = build_test_app().await; + + // Step 1: Register + let (access, refresh, reg_json) = register(&app, "smoke_user", "smoke@test.io", DEFAULT_PASSWORD).await; + assert!(!access.is_empty(), "register should return access token"); + assert!(!refresh.is_empty(), "register should return refresh token"); + assert_eq!(reg_json["username"].as_str(), Some("smoke_user")); + + // Step 2: GET /me with access token + let (status, me) = send(&app, get("/api/v1/auth/me", &access)).await; + assert_eq!(status, StatusCode::OK, "GET /me should succeed"); + assert_eq!(me["username"].as_str(), Some("smoke_user")); + assert!(me["pwv"].is_number(), "me should include pwv (password version)"); + + // Step 3: Refresh token + let (status, refresh_json) = send( + &app, + post("/api/v1/auth/refresh", "", json!({ "refresh_token": refresh })), + ).await; + assert_eq!(status, StatusCode::OK, "refresh should succeed"); + let new_access = refresh_json["token"].as_str().expect("refresh should return new token"); + let new_refresh = refresh_json["refresh_token"].as_str().expect("refresh should return new refresh"); + assert_ne!(new_access, access, "new access token should differ"); + + // Step 4: Old refresh token is one-time-use (should fail) + let (status, _) = send( + &app, + post("/api/v1/auth/refresh", "", json!({ "refresh_token": refresh })), + ).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "old refresh token should be rejected"); + + // Step 5: Logout with new refresh + let (status, _) = send( + &app, + post("/api/v1/auth/logout", new_access, json!({ "refresh_token": new_refresh })), + ).await; + assert_eq!(status, StatusCode::OK, "logout should succeed"); + + // Step 6: After logout, refresh should fail + let (status, _) = send( + &app, + post("/api/v1/auth/refresh", "", json!({ "refresh_token": new_refresh })), + ).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "refresh after logout should fail"); + + // DB verification: account exists with correct role + let row: (String,) = sqlx::query_as("SELECT role FROM accounts WHERE username = $1") + .bind("smoke_user") + .fetch_one(&pool) + .await + .expect("user should exist in DB"); + assert_eq!(row.0, "user", "new user should have role=user"); + + println!("✅ S1 PASS: Auth full lifecycle — register→login→me→refresh→logout"); +} + +// ── S2: 账户锁定 ────────────────────────────────────────────────── + +#[tokio::test] +async fn s2_account_lockout() { + let (app, _pool) = build_test_app().await; + + // Register user + register(&app, "lockout_user", "lockout@test.io", DEFAULT_PASSWORD).await; + + // Step 1-4: Wrong password 4 times → should still be allowed + for i in 1..=4 { + let resp = app.clone().oneshot(post_public( + "/api/v1/auth/login", + json!({ "username": "lockout_user", "password": "wrong_password" }), + )).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "attempt {i}: wrong password should be 401"); + } + + // Step 5: 5th wrong password → account locked + let resp = app.clone().oneshot(post_public( + "/api/v1/auth/login", + json!({ "username": "lockout_user", "password": "wrong_password" }), + )).await.unwrap(); + let status = resp.status(); + let body = body_json(resp.into_body()).await; + // Account should be locked (either 401 with lock message or 403) + assert!( + status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN, + "5th failed attempt should lock account, got {status}: {body}" + ); + + // Step 6: Even correct password should fail during lockout + let resp = app.clone().oneshot(post_public( + "/api/v1/auth/login", + json!({ "username": "lockout_user", "password": DEFAULT_PASSWORD }), + )).await.unwrap(); + assert!( + resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN, + "correct password during lockout should still fail" + ); + + println!("✅ S2 PASS: Account lockout — 5 failures trigger lock"); +} + +// ── S3: Relay 路由闭环 (需 LLM API Key) ────────────────────────── + +#[tokio::test] +async fn s3_relay_routing() { + let llm_key = match std::env::var("LLM_API_KEY") { + Ok(k) if !k.is_empty() => k, + _ => { + eprintln!("⚠️ S3 SKIP: LLM_API_KEY not set, skipping relay routing test"); + return; + } + }; + + let (app, pool) = build_test_app().await; + let admin = super_admin_token(&app, &pool, "relay_admin").await; + + // Step 1: Create Provider + let (status, provider) = send(&app, post("/api/v1/providers", &admin, json!({ + "name": "smoke_test_provider", + "provider_type": "openai", + "base_url": "https://api.deepseek.com/v1", + "enabled": true + }))).await; + assert_eq!(status, StatusCode::CREATED, "create provider should succeed: {provider}"); + let provider_id = provider["id"].as_str().expect("provider should have id"); + + // Step 2: Add API Key + let (status, _key) = send(&app, post(&format!("/api/v1/providers/{provider_id}/keys"), &admin, json!({ + "key_value": llm_key + }))).await; + assert_eq!(status, StatusCode::CREATED, "add API key should succeed"); + + // Step 3: Create Model + let (status, model) = send(&app, post("/api/v1/models", &admin, json!({ + "name": "smoke-test-model", + "provider_id": provider_id, + "model_id": "deepseek-chat", + "enabled": true + }))).await; + assert_eq!(status, StatusCode::CREATED, "create model should succeed: {model}"); + + // Step 4: Create regular user for relay + let user_token = register_token(&app, "relay_user"); + + // Step 5: Relay chat completion (SSE) + let resp = app.clone().oneshot(post( + "/api/v1/relay/chat/completions", + &user_token, + json!({ + "model": "smoke-test-model", + "messages": [{ "role": "user", "content": "Say 'hello' in one word" }], + "stream": true + }), + )).await.unwrap(); + + let status = resp.status(); + // Accept 200 (streaming) or create task + assert!( + status == StatusCode::OK || status == StatusCode::ACCEPTED, + "relay chat should return 200/202, got {status}" + ); + + // Verify relay_task was created in DB + let task_count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM relay_tasks WHERE account_id = (SELECT id FROM accounts WHERE username = 'relay_user')" + ) + .fetch_one(&pool) + .await + .expect("should query relay_tasks"); + assert!(task_count.0 > 0, "relay_task should be created in DB"); + + println!("✅ S3 PASS: Relay routing — provider→model→SSE chat→task created"); +} + +// ── S4: 权限矩阵 ────────────────────────────────────────────────── + +#[tokio::test] +async fn s4_permission_matrix() { + let (app, pool) = build_test_app().await; + + let super_admin = super_admin_token(&app, &pool, "perm_superadmin").await; + let user_token = register_token(&app, "perm_user"); + + // super_admin should access all protected endpoints + let protected_endpoints = vec![ + ("GET", "/api/v1/accounts"), + ("GET", "/api/v1/providers"), + ("GET", "/api/v1/models"), + ("GET", "/api/v1/roles"), + ("GET", "/api/v1/knowledge/categories"), + ("GET", "/api/v1/prompts"), + ]; + + for (method, path) in &protected_endpoints { + let req = match *method { + "GET" => get(path, &super_admin), + _ => panic!("unsupported method"), + }; + let (status, body) = send(&app, req).await; + assert!( + status == StatusCode::OK, + "super_admin GET {path} should be 200, got {status}: {body}" + ); + } + + // Regular user should be restricted from admin endpoints + let restricted_endpoints = vec![ + ("GET", "/api/v1/accounts"), + ("GET", "/api/v1/roles"), + ("POST", "/api/v1/providers"), + ]; + + for (method, path) in &restricted_endpoints { + let req = match *method { + "GET" => get(path, &user_token), + "POST" => post(path, &user_token, json!({})), + _ => panic!("unsupported method"), + }; + let (status, _body) = send(&app, req).await; + assert!( + status == StatusCode::FORBIDDEN, + "user {method} {path} should be 403, got {status}" + ); + } + + // User should access relay and self-info + let (status, _) = send(&app, get("/api/v1/auth/me", &user_token)).await; + assert_eq!(status, StatusCode::OK, "user should access /me"); + + let (status, _) = send(&app, get("/api/v1/relay/models", &user_token)).await; + assert_eq!(status, StatusCode::OK, "user should access relay/models"); + + // Unauthenticated should get 401 on protected + let (status, _) = send(&app, get("/api/v1/accounts", "")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "unauthenticated should get 401"); + + println!("✅ S4 PASS: Permission matrix — super_admin/user/unauth roles verified"); +} + +// ── S5: 计费闭环 ────────────────────────────────────────────────── + +#[tokio::test] +async fn s5_billing_loop() { + let (app, pool) = build_test_app().await; + let admin = super_admin_token(&app, &pool, "billing_admin").await; + + // Step 1: Get initial dashboard stats + let (status, stats) = send(&app, get("/api/v1/dashboard/stats", &admin)).await; + assert_eq!(status, StatusCode::OK, "dashboard stats should succeed"); + let initial_tasks = stats["tasks_today"].as_i64().unwrap_or(0); + + // Step 2: Get billing usage (should exist even if empty) + let user_token = register_token(&app, "billing_user"); + let (status, _usage) = send(&app, get("/api/v1/billing/usage", &user_token)).await; + assert_eq!(status, StatusCode::OK, "billing usage should be accessible"); + + // Step 3: Get billing plans + let (status, plans) = send(&app, get("/api/v1/billing/plans", &user_token)).await; + assert_eq!(status, StatusCode::OK, "billing plans should be accessible"); + assert!(plans.as_array().is_some() || plans.is_object(), "plans should return data"); + + // Verify dashboard stats structure + assert!(stats["total_accounts"].is_number(), "stats should have total_accounts"); + assert!(stats["active_providers"].is_number(), "stats should have active_providers"); + + println!("✅ S5 PASS: Billing loop — stats/usage/plans accessible, structure valid"); +} + +// ── S6: 知识检索闭环 ────────────────────────────────────────────── + +#[tokio::test] +async fn s6_knowledge_search() { + let (app, pool) = build_test_app().await; + let admin = super_admin_token(&app, &pool, "knowledge_admin").await; + + // Step 1: Create category + let (status, category) = send(&app, post("/api/v1/knowledge/categories", &admin, json!({ + "name": "smoke_test_category", + "description": "Smoke test category" + }))).await; + assert_eq!(status, StatusCode::CREATED, "create category should succeed: {category}"); + let category_id = category["id"].as_str().expect("category should have id"); + + // Step 2: Create knowledge item + let (status, item) = send(&app, post("/api/v1/knowledge/items", &admin, json!({ + "title": "API Key 配置指南", + "content": "在 Model Services 页面添加 Provider 后,点击 API Key 池添加密钥", + "category_id": category_id, + "tags": ["api", "key", "配置"] + }))).await; + assert_eq!(status, StatusCode::CREATED, "create knowledge item should succeed: {item}"); + let item_id = item["id"].as_str().expect("item should have id"); + + // Step 3: Search for the item + let (status, results) = send(&app, post("/api/v1/knowledge/search", &admin, json!({ + "query": "API Key 配置", + "limit": 10 + }))).await; + assert_eq!(status, StatusCode::OK, "search should succeed"); + let items = results["items"].as_array().or_else(|| results.as_array()); + assert!(items.is_some(), "search should return results array"); + let found = items.unwrap().iter().any(|i| { + i["id"].as_str() == Some(item_id) || i["title"].as_str() == Some("API Key 配置指南") + }); + assert!(found, "search should find the created item"); + + // DB verification: item exists with category + let row: (String,) = sqlx::query_as("SELECT title FROM knowledge_items WHERE id = $1") + .bind(item_id) + .fetch_one(&pool) + .await + .expect("knowledge item should exist in DB"); + assert_eq!(row.0, "API Key 配置指南"); + + println!("✅ S6 PASS: Knowledge search — category→item→search→found"); +} diff --git a/desktop/tests/e2e/specs/smoke_chat.spec.ts b/desktop/tests/e2e/specs/smoke_chat.spec.ts new file mode 100644 index 0000000..2bbdcc1 --- /dev/null +++ b/desktop/tests/e2e/specs/smoke_chat.spec.ts @@ -0,0 +1,326 @@ +/** + * Smoke Tests — Desktop 聊天流断裂探测 + * + * 6 个冒烟测试验证 Desktop 聊天功能的完整连通性。 + * 覆盖 3 种聊天模式 + 流取消 + 离线恢复 + 错误处理。 + * + * 前提条件: + * - Desktop App 运行在 http://localhost:1420 (pnpm tauri dev) + * - 后端服务可用 (SaaS 或 Kernel) + * + * 运行: cd desktop && npx playwright test smoke_chat + */ + +import { test, expect, type Page } from '@playwright/test'; + +test.setTimeout(120000); + +// Helper: 等待应用就绪 +async function waitForAppReady(page: Page) { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('.h-screen', { timeout: 15000 }); +} + +// Helper: 查找聊天输入框 +async function findChatInput(page: Page) { + // 多种选择器尝试,兼容不同 UI 状态 + const selectors = [ + 'textarea[placeholder*="消息"]', + 'textarea[placeholder*="输入"]', + 'textarea[placeholder*="chat"]', + 'textarea', + 'input[type="text"]', + '.chat-input textarea', + '[data-testid="chat-input"]', + ]; + for (const sel of selectors) { + const el = page.locator(sel).first(); + if (await el.isVisible().catch(() => false)) { + return el; + } + } + // 截图帮助调试 + await page.screenshot({ path: 'test-results/smoke_chat_input_not_found.png' }); + throw new Error('Chat input not found — screenshot saved'); +} + +// Helper: 发送消息 +async function sendMessage(page: Page, message: string) { + const input = await findChatInput(page); + await input.fill(message); + // 按回车发送或点击发送按钮 + await input.press('Enter').catch(async () => { + const sendBtn = page.locator('button[aria-label*="发送"], button[aria-label*="send"], button:has-text("发送")').first(); + if (await sendBtn.isVisible().catch(() => false)) { + await sendBtn.click(); + } + }); +} + +// ── D1: Gateway 模式聊天 ────────────────────────────────────────── + +test('D1: Gateway 模式 — 发送消息→接收响应', async ({ page }) => { + await waitForAppReady(page); + + // 检查连接状态 + const connectionStatus = page.locator('[data-testid="connection-status"], .connection-status, [aria-label*="连接"]'); + const isConnected = await connectionStatus.isVisible().catch(() => false); + + if (!isConnected) { + // 尝试通过 Store 检查连接状态 + const state = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + return stores ? { hasStores: true } : { hasStores: false }; + }); + console.log('Store state:', JSON.stringify(state)); + } + + // 发送测试消息 + await sendMessage(page, '你好,这是一个冒烟测试'); + + // 等待响应 (流式消息或错误) + // 检查是否有新的消息气泡出现 + const messageBubble = page.locator('.message-bubble, [class*="message"], [data-role="assistant"]').last(); + const gotResponse = await messageBubble.waitFor({ state: 'visible', timeout: 30000 }).catch(() => false); + + if (!gotResponse) { + await page.screenshot({ path: 'test-results/smoke_d1_no_response.png' }); + } + + // 记录结果但不硬性失败 — Smoke 的目的是探测 + console.log(`D1 result: ${gotResponse ? 'PASS — response received' : 'BROKEN — no response'}`); + expect(gotResponse).toBeTruthy(); +}); + +// ── D2: Kernel 模式 (Tauri IPC) ─────────────────────────────────── + +test('D2: Kernel 模式 — Tauri invoke 检查', async ({ page }) => { + await waitForAppReady(page); + + // 检查 Tauri 是否可用 + const tauriAvailable = await page.evaluate(() => { + return !!(window as any).__TAURI__; + }); + + if (!tauriAvailable) { + console.log('D2 SKIP: Not running in Tauri context (browser mode)'); + test.skip(); + return; + } + + // 检查 connectionStore 模式 + const connectionMode = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores?.connection) { + const state = stores.connection.getState?.(); + return state?.connectionMode || state?.mode || 'unknown'; + } + return 'no_store'; + }); + + console.log(`Connection mode: ${connectionMode}`); + + // 尝试通过 Kernel 发送消息 (需要 Kernel 模式) + if (connectionMode === 'tauri' || connectionMode === 'unknown') { + await sendMessage(page, 'Kernel 模式冒烟测试'); + + // 等待流式响应 + const gotChunk = await page.evaluate(() => { + return new Promise((resolve) => { + const stores = (window as any).__ZCLAW_STORES__; + if (!stores?.stream) { + resolve(false); + return; + } + const unsub = stores.stream.subscribe((state: any) => { + if (state.chunks?.length > 0) { + unsub(); + resolve(true); + } + }); + // 超时 15s + setTimeout(() => { unsub(); resolve(false); }, 15000); + }); + }); + + console.log(`D2 result: ${gotChunk ? 'PASS' : 'BROKEN'}`); + } +}); + +// ── D3: SaaS Relay 模式 ─────────────────────────────────────────── + +test('D3: SaaS Relay — SSE 流式检查', async ({ page }) => { + await waitForAppReady(page); + + // 检查 SaaS 登录状态 + const saasState = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (!stores) return { hasStores: false }; + const saas = stores.saas?.getState?.(); + return { + hasStores: true, + isAuthenticated: saas?.isAuthenticated || false, + connectionMode: stores.connection?.getState?.()?.connectionMode || 'unknown', + }; + }); + + if (saasState.connectionMode !== 'saas' && saasState.connectionMode !== 'browser') { + console.log(`D3 SKIP: Not in SaaS mode (current: ${saasState.connectionMode})`); + test.skip(); + return; + } + + // 发送消息 + await sendMessage(page, 'SaaS Relay 冒烟测试'); + + // 检查是否有 relay 请求发出 + const relayEvents = await page.evaluate(() => { + return new Promise((resolve) => { + const stores = (window as any).__ZCLAW_STORES__; + if (!stores?.stream) { resolve(false); return; } + const unsub = stores.stream.subscribe((state: any) => { + if (state.isStreaming || state.chunks?.length > 0) { + unsub(); + resolve(true); + } + }); + setTimeout(() => { unsub(); resolve(false); }, 20000); + }); + }); + + if (!relayEvents) { + await page.screenshot({ path: 'test-results/smoke_d3_no_relay.png' }); + } + + console.log(`D3 result: ${relayEvents ? 'PASS' : 'BROKEN'}`); + expect(relayEvents).toBeTruthy(); +}); + +// ── D4: 流取消 ──────────────────────────────────────────────────── + +test('D4: 流取消 — 发送→取消→停止', async ({ page }) => { + await waitForAppReady(page); + + // 发送一条长回复请求 + await sendMessage(page, '请详细解释量子计算的基本原理,包括量子比特、叠加态和纠缠'); + + // 等待流式开始 + await page.waitForTimeout(1000); + + // 查找取消按钮 + const cancelBtn = page.locator( + 'button[aria-label*="取消"], button[aria-label*="停止"], button:has-text("停止"), [data-testid="cancel-stream"]' + ).first(); + + if (await cancelBtn.isVisible().catch(() => false)) { + await cancelBtn.click(); + + // 验证流状态变为 cancelled/stopped + const streamState = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + return stores?.stream?.getState?.()?.isStreaming ?? 'no_store'; + }); + + console.log(`D4 result: stream cancelled, isStreaming=${streamState}`); + expect(streamState).toBe(false); + } else { + // 如果没有取消按钮,尝试通过 Store 取消 + const cancelled = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores?.stream?.getState?.()?.cancelStream) { + stores.stream.getState().cancelStream(); + return true; + } + return false; + }); + + console.log(`D4 result: ${cancelled ? 'cancelled via Store' : 'no cancel mechanism found'}`); + } +}); + +// ── D5: 离线→在线 ───────────────────────────────────────────────── + +test('D5: 离线队列 — 断网→发消息→恢复→重放', async ({ page, context }) => { + await waitForAppReady(page); + + // 模拟离线 + await context.setOffline(true); + + // 发送消息 (应该被排队) + await sendMessage(page, '离线测试消息'); + + // 检查 offlineStore 是否有队列 + const offlineState = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + // 没有 offlineStore 暴露在 window 上,通过 connection 状态检查 + return { + hasOfflineStore: !!stores?.offline, + isOnline: stores?.connection?.getState?.()?.isOnline ?? 'unknown', + }; + }); + + console.log('Offline state:', JSON.stringify(offlineState)); + + // 恢复网络 + await context.setOffline(false); + + // 等待重连 + await page.waitForTimeout(3000); + + // 检查消息是否被重放 (或至少没有被丢失) + const afterReconnect = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + return { + isOnline: stores?.connection?.getState?.()?.isOnline ?? 'unknown', + }; + }); + + console.log('After reconnect:', JSON.stringify(afterReconnect)); +}); + +// ── D6: 错误恢复 ────────────────────────────────────────────────── + +test('D6: 错误恢复 — 无效模型→错误提示→恢复', async ({ page }) => { + await waitForAppReady(page); + + // 尝试通过 Store 设置无效模型 + const errorTriggered = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (!stores?.chat) return false; + + // 有些 Store 允许直接设置 model + const state = stores.chat.getState?.(); + if (state?.setSelectedModel) { + state.setSelectedModel('nonexistent-model-xyz'); + return true; + } + return false; + }); + + if (errorTriggered) { + // 尝试发送消息,期望看到错误 + await sendMessage(page, '测试错误恢复'); + + // 等待错误 UI 或错误消息 + await page.waitForTimeout(5000); + + // 检查是否有错误提示 + const errorVisible = await page.locator( + '.error-message, [class*="error"], [role="alert"], .ant-message-error' + ).first().isVisible().catch(() => false); + + // 恢复有效模型 + await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const state = stores?.chat?.getState?.(); + if (state?.setSelectedModel && state?.availableModels?.length > 0) { + state.setSelectedModel(state.availableModels[0]); + } + }); + + console.log(`D6 result: error=${errorVisible}, recovery attempted`); + } else { + console.log('D6 SKIP: Cannot trigger model error via Store'); + } +}); diff --git a/desktop/tests/e2e/specs/smoke_cross.spec.ts b/desktop/tests/e2e/specs/smoke_cross.spec.ts new file mode 100644 index 0000000..6845de1 --- /dev/null +++ b/desktop/tests/e2e/specs/smoke_cross.spec.ts @@ -0,0 +1,240 @@ +/** + * Smoke Tests — 跨系统闭环断裂探测 + * + * 6 个冒烟测试验证 Admin→SaaS→Desktop 的跨系统闭环。 + * 同时操作 Admin API 和 Desktop UI,验证数据一致性。 + * + * 前提条件: + * - SaaS Server: http://localhost:8080 + * - Desktop App: http://localhost:1420 + * - Admin V2: http://localhost:5173 + * - 真实 LLM API Key (部分测试需要) + * + * 运行: cd desktop && npx playwright test smoke_cross + */ + +import { test, expect, type Page } from '@playwright/test'; + +test.setTimeout(180000); + +const SaaS_BASE = 'http://localhost:8080/api/v1'; +const ADMIN_USER = 'testadmin'; +const ADMIN_PASS = 'Admin123456'; + +async function saasLogin(page: Page): Promise { + const res = await page.request.post(`${SaaS_BASE}/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + expect(res.ok()).toBeTruthy(); + const json = await res.json(); + return json.token; +} + +async function waitForDesktopReady(page: Page) { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('.h-screen', { timeout: 15000 }); +} + +// ── X1: Admin 配 Provider → Desktop 可用 ────────────────────────── + +test('X1: Admin 创建 Provider → Desktop 模型列表包含', async ({ page }) => { + const token = await saasLogin(page); + const providerName = `cross_provider_${Date.now()}`; + + // Step 1: 通过 SaaS API 创建 Provider + const provRes = await page.request.post(`${SaaS_BASE}/providers`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + name: providerName, + provider_type: 'openai', + base_url: 'https://api.cross.test/v1', + enabled: true, + }, + }); + expect(provRes.ok()).toBeTruthy(); + const provJson = await provRes.json(); + const providerId = provJson.id; + + // Step 2: 创建 Model + const modelRes = await page.request.post(`${SaaS_BASE}/models`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + name: `cross_model_${Date.now()}`, + provider_id: providerId, + model_id: 'test-model', + enabled: true, + }, + }); + expect(modelRes.ok()).toBeTruthy(); + + // Step 3: 等待缓存刷新 (最多 60s) + console.log('Waiting for cache refresh...'); + await page.waitForTimeout(5000); + + // Step 4: 在 Desktop 检查模型列表 + await waitForDesktopReady(page); + + // 通过 SaaS API 验证模型可被 relay 获取 + const relayModels = await page.request.get(`${SaaS_BASE}/relay/models`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(relayModels.ok()).toBeTruthy(); + const modelsJson = await relayModels.json(); + const models = modelsJson.models || modelsJson.data || modelsJson; + const found = Array.isArray(models) && models.some( + (m: any) => m.id === providerId || m.name?.includes('cross_model') + ); + + console.log(`X1: Model found in relay list: ${found}`); + if (!found) { + console.log('Available models:', JSON.stringify(models).slice(0, 500)); + } +}); + +// ── X2: Admin 改权限 → Desktop 受限 ─────────────────────────────── + +test('X2: Admin 禁用 user → Desktop 请求失败', async ({ page }) => { + const token = await saasLogin(page); + + // 创建一个测试用户 + const regRes = await page.request.post(`${SaaS_BASE}/auth/register`, { + data: { + username: `cross_user_${Date.now()}`, + email: `cross_${Date.now()}@test.io`, + password: 'TestPassword123', + }, + }); + expect(regRes.ok()).toBeTruthy(); + const regJson = await regRes.json(); + const userId = regJson.account?.id || regJson.id; + + // 禁用该用户 + if (userId) { + const disableRes = await page.request.patch(`${SaaS_BASE}/accounts/${userId}`, { + headers: { Authorization: `Bearer ${token}` }, + data: { status: 'disabled' }, + }); + console.log(`Disable user: ${disableRes.status()}`); + + // 验证被禁用的用户无法登录 + const loginRes = await page.request.post(`${SaaS_BASE}/auth/login`, { + data: { + username: regJson.username || `cross_user_${Date.now()}`, + password: 'TestPassword123', + }, + }); + console.log(`Disabled user login: ${loginRes.status()} (expected 401/403)`); + expect([401, 403]).toContain(loginRes.status()); + } +}); + +// ── X3: Admin 创建知识 → Desktop 检索 ────────────────────────────── + +test('X3: Admin 创建知识条目 → SaaS 搜索可找到', async ({ page }) => { + const token = await saasLogin(page); + const uniqueContent = `跨系统测试知识_${Date.now()}_唯一标识`; + + // Step 1: 创建分类 + const catRes = await page.request.post(`${SaaS_BASE}/knowledge/categories`, { + headers: { Authorization: `Bearer ${token}` }, + data: { name: `cross_cat_${Date.now()}`, description: 'Cross-system test' }, + }); + expect(catRes.ok()).toBeTruthy(); + const catJson = await catRes.json(); + + // Step 2: 创建知识条目 + const itemRes = await page.request.post(`${SaaS_BASE}/knowledge/items`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + title: uniqueContent, + content: '这是跨系统知识检索测试的内容,包含特定关键词。', + category_id: catJson.id, + tags: ['cross-system', 'test'], + }, + }); + expect(itemRes.ok()).toBeTruthy(); + + // Step 3: 通过 SaaS API 搜索 + const searchRes = await page.request.post(`${SaaS_BASE}/knowledge/search`, { + headers: { Authorization: `Bearer ${token}` }, + data: { query: uniqueContent, limit: 5 }, + }); + expect(searchRes.ok()).toBeTruthy(); + const searchJson = await searchRes.json(); + + const items = searchJson.items || searchJson; + const found = Array.isArray(items) && items.some( + (i: any) => i.title === uniqueContent + ); + expect(found).toBeTruthy(); + + console.log(`X3: Knowledge item found via search: ${found}`); +}); + +// ── X4: Desktop 聊天 → Admin 统计更新 ───────────────────────────── + +test('X4: Dashboard 统计 — API 验证结构', async ({ page }) => { + const token = await saasLogin(page); + + // Step 1: 获取初始统计 + const statsRes = await page.request.get(`${SaaS_BASE}/dashboard/stats`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(statsRes.ok()).toBeTruthy(); + const stats = await statsRes.json(); + + // 验证统计结构 + expect(stats).toHaveProperty('total_accounts'); + expect(stats).toHaveProperty('active_providers'); + expect(stats).toHaveProperty('active_models'); + expect(stats).toHaveProperty('tasks_today'); + + console.log(`X4: Dashboard stats = ${JSON.stringify(stats).slice(0, 300)}`); +}); + +// ── X5: TOTP 全流程 ────────────────────────────────────────────── + +test('X5: TOTP 设置→验证→登录', async ({ page }) => { + const token = await saasLogin(page); + + // Step 1: 设置 TOTP + const setupRes = await page.request.post(`${SaaS_BASE}/auth/totp/setup`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const setupStatus = setupRes.status(); + + if (setupStatus === 200 || setupStatus === 201) { + const setupJson = await setupRes.json(); + console.log(`X5: TOTP setup returned secret (encrypted): ${setupJson.secret ? 'yes' : 'no'}`); + expect(setupJson.secret || setupJson.totp_secret).toBeTruthy(); + } else { + console.log(`X5: TOTP setup returned ${setupStatus} (may need verification endpoint)`); + const body = await setupRes.text(); + console.log(`Response: ${body.slice(0, 200)}`); + } +}); + +// ── X6: 计费一致性 ─────────────────────────────────────────────── + +test('X6: 计费一致性 — billing_usage 查询', async ({ page }) => { + const token = await saasLogin(page); + + // 查询计费用量 + const usageRes = await page.request.get(`${SaaS_BASE}/billing/usage`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(usageRes.ok()).toBeTruthy(); + const usageJson = await usageRes.json(); + + // 验证结构 + console.log(`X6: Billing usage = ${JSON.stringify(usageJson).slice(0, 300)}`); + + // 查询计划 + const plansRes = await page.request.get(`${SaaS_BASE}/billing/plans`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(plansRes.ok()).toBeTruthy(); + const plansJson = await plansRes.json(); + console.log(`X6: Billing plans count = ${Array.isArray(plansJson) ? plansJson.length : 'object'}`); +}); diff --git a/desktop/tests/e2e/specs/smoke_features.spec.ts b/desktop/tests/e2e/specs/smoke_features.spec.ts new file mode 100644 index 0000000..f477a34 --- /dev/null +++ b/desktop/tests/e2e/specs/smoke_features.spec.ts @@ -0,0 +1,260 @@ +/** + * Smoke Tests — Desktop 功能闭环断裂探测 + * + * 6 个冒烟测试验证 Desktop 功能的完整闭环。 + * 覆盖 Agent/Hands/Pipeline/记忆/管家/技能。 + * + * 前提条件: + * - Desktop App 运行在 http://localhost:1420 (pnpm tauri dev) + * - 后端服务可用 (SaaS 或 Kernel) + * + * 运行: cd desktop && npx playwright test smoke_features + */ + +import { test, expect, type Page } from '@playwright/test'; + +test.setTimeout(120000); + +async function waitForAppReady(page: Page) { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('.h-screen', { timeout: 15000 }); +} + +// ── F1: Agent 全生命周期 ────────────────────────────────────────── + +test('F1: Agent 生命周期 — 创建→切换→删除', async ({ page }) => { + await waitForAppReady(page); + + // 检查 Agent 列表 + const agentCount = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const agents = stores?.agent?.getState?.()?.agents || stores?.chat?.getState?.()?.agents || []; + return agents.length; + }); + + console.log(`Current agent count: ${agentCount}`); + + // 查找新建 Agent 按钮 + const newAgentBtn = page.locator( + 'button[aria-label*="新建"], button[aria-label*="创建"], button:has-text("新建"), [data-testid="new-agent"]' + ).first(); + + if (await newAgentBtn.isVisible().catch(() => false)) { + await newAgentBtn.click(); + + // 等待创建对话框/向导 + await page.waitForTimeout(2000); + await page.screenshot({ path: 'test-results/smoke_f1_agent_create.png' }); + + // 检查是否有创建表单 + const formVisible = await page.locator( + '.ant-modal, [role="dialog"], .agent-wizard, [class*="create"]' + ).first().isVisible().catch(() => false); + + console.log(`F1: Agent create form visible: ${formVisible}`); + } else { + // 通过 Store 直接检查 Agent 列表 + const agents = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const state = stores?.agent?.getState?.() || stores?.chat?.getState?.(); + return state?.agents || state?.clones || []; + }); + console.log(`F1: ${agents.length} agents found, no create button`); + } +}); + +// ── F2: Hands 触发 ──────────────────────────────────────────────── + +test('F2: Hands 触发 — 面板加载→列表非空', async ({ page }) => { + await waitForAppReady(page); + + // 查找 Hands 面板入口 + const handsBtn = page.locator( + 'button[aria-label*="Hands"], button[aria-label*="自动化"], [data-testid="hands-panel"], :text("Hands")' + ).first(); + + if (await handsBtn.isVisible().catch(() => false)) { + await handsBtn.click(); + await page.waitForTimeout(2000); + + // 检查 Hands 列表 + const handsCount = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const hands = stores?.hand?.getState?.()?.hands || []; + return hands.length; + }); + + console.log(`F2: ${handsCount} Hands available`); + expect(handsCount).toBeGreaterThan(0); + } else { + // 通过 Store 检查 + const handsCount = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + return stores?.hand?.getState?.()?.hands?.length ?? -1; + }); + + console.log(`F2: handsCount from Store = ${handsCount}`); + if (handsCount >= 0) { + expect(handsCount).toBeGreaterThan(0); + } + } + + await page.screenshot({ path: 'test-results/smoke_f2_hands.png' }); +}); + +// ── F3: Pipeline 执行 ───────────────────────────────────────────── + +test('F3: Pipeline — 模板列表加载', async ({ page }) => { + await waitForAppReady(page); + + // 查找 Pipeline/Workflow 入口 + const workflowBtn = page.locator( + 'button[aria-label*="Pipeline"], button[aria-label*="工作流"], [data-testid="workflow"], :text("Pipeline")' + ).first(); + + if (await workflowBtn.isVisible().catch(() => false)) { + await workflowBtn.click(); + await page.waitForTimeout(2000); + } + + // 通过 Store 检查 Pipeline 状态 + const pipelineState = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const wf = stores?.workflow?.getState?.(); + return { + workflowCount: wf?.workflows?.length ?? -1, + templates: wf?.templates?.length ?? -1, + }; + }); + + console.log(`F3: Pipeline state = ${JSON.stringify(pipelineState)}`); + await page.screenshot({ path: 'test-results/smoke_f3_pipeline.png' }); +}); + +// ── F4: 记忆闭环 ────────────────────────────────────────────────── + +test('F4: 记忆 — 提取器检查+FTS5 索引', async ({ page }) => { + await waitForAppReady(page); + + // 检查记忆相关 Store + const memoryState = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const memGraph = stores?.memoryGraph?.getState?.(); + return { + hasMemoryStore: !!stores?.memoryGraph, + memoryCount: memGraph?.memories?.length ?? -1, + }; + }); + + console.log(`F4: Memory state = ${JSON.stringify(memoryState)}`); + + // 查找记忆面板 + const memoryBtn = page.locator( + 'button[aria-label*="记忆"], [data-testid="memory"], :text("记忆")' + ).first(); + + if (await memoryBtn.isVisible().catch(() => false)) { + await memoryBtn.click(); + await page.waitForTimeout(2000); + await page.screenshot({ path: 'test-results/smoke_f4_memory.png' }); + } +}); + +// ── F5: 管家路由 ────────────────────────────────────────────────── + +test('F5: 管家路由 — ButlerRouter 分类检查', async ({ page }) => { + await waitForAppReady(page); + + // 检查管家模式是否激活 + const butlerState = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const chat = stores?.chat?.getState?.(); + return { + hasButlerStore: !!stores?.butler, + uiMode: chat?.uiMode || stores?.uiMode?.getState?.()?.mode || 'unknown', + }; + }); + + console.log(`F5: Butler state = ${JSON.stringify(butlerState)}`); + + // 发送一个 healthcare 相关消息测试路由 + await sendMessage(page, '帮我整理上周的科室会议记录'); + + // 等待响应 + await page.waitForTimeout(5000); + + // 检查 Butler 分类结果 (通过 Store) + const routing = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + const butler = stores?.butler?.getState?.(); + return { + lastDomain: butler?.lastDomain || butler?.domain || null, + lastConfidence: butler?.lastConfidence || null, + }; + }); + + console.log(`F5: Butler routing = ${JSON.stringify(routing)}`); + await page.screenshot({ path: 'test-results/smoke_f5_butler.png' }); +}); + +// ── F6: 技能发现 ────────────────────────────────────────────────── + +test('F6: 技能 — 列表加载→搜索', async ({ page }) => { + await waitForAppReady(page); + + // 查找技能面板入口 + const skillBtn = page.locator( + 'button[aria-label*="技能"], [data-testid="skills"], :text("技能")' + ).first(); + + if (await skillBtn.isVisible().catch(() => false)) { + await skillBtn.click(); + await page.waitForTimeout(2000); + } + + // 通过 Store 或 Tauri invoke 检查技能列表 + const skillState = await page.evaluate(async () => { + const stores = (window as any).__ZCLAW_STORES__; + + // 方法 1: Store + if (stores?.skill?.getState?.()?.skills) { + return { source: 'store', count: stores.skill.getState().skills.length }; + } + + // 方法 2: Tauri invoke + if ((window as any).__TAURI__) { + try { + const { invoke } = (window as any).__TAURI__.core || (window as any).__TAURI__; + const skills = await invoke('list_skills'); + return { source: 'tauri', count: Array.isArray(skills) ? skills.length : 0 }; + } catch (e) { + return { source: 'tauri_error', error: String(e) }; + } + } + + return { source: 'none', count: 0 }; + }); + + console.log(`F6: Skill state = ${JSON.stringify(skillState)}`); + await page.screenshot({ path: 'test-results/smoke_f6_skills.png' }); +}); + +// Helper for F5/F6: send message through chat +async function sendMessage(page: Page, message: string) { + const selectors = [ + 'textarea[placeholder*="消息"]', + 'textarea[placeholder*="输入"]', + 'textarea', + '.chat-input textarea', + ]; + for (const sel of selectors) { + const el = page.locator(sel).first(); + if (await el.isVisible().catch(() => false)) { + await el.fill(message); + await el.press('Enter'); + return; + } + } + console.log('sendMessage: no chat input found'); +} diff --git a/docs/superpowers/specs/2026-04-10-e2e-comprehensive-test-design.md b/docs/superpowers/specs/2026-04-10-e2e-comprehensive-test-design.md index fe2a1dd..8cd21b0 100644 --- a/docs/superpowers/specs/2026-04-10-e2e-comprehensive-test-design.md +++ b/docs/superpowers/specs/2026-04-10-e2e-comprehensive-test-design.md @@ -3,6 +3,7 @@ > **目标**: 设计覆盖 SaaS Admin + Tauri Desktop 的全面测试方案,验证所有功能形成闭环工作流 > **产出物**: 测试设计文档(本文件),指导后续测试执行 > **范围**: 功能测试 / 集成测试 / 端到端测试 / 数据一致性 / 权限验证 +> **执行策略**: 本文档的详细测试矩阵由 [断裂探测测试方案](../../plans/noble-swinging-lynx.md) 驱动执行——先探测断裂,修复后按本文档做全面回归 ---