test: add 30 smoke tests for break detection across SaaS/Admin/Desktop
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Layer 1 断裂探测矩阵: - S1-S6: SaaS API 端到端 (auth/lockout/relay/permissions/billing/knowledge) - A1-A6: Admin V2 连通性 (login/dashboard/CRUD/knowledge/roles/models) - D1-D6: Desktop 聊天流 (gateway/kernel/relay/cancel/offline/error) - F1-F6: Desktop 功能闭环 (agent/hands/pipeline/memory/butler/skills) - X1-X6: 跨系统闭环 (provider→desktop/disabled user/knowledge/stats/totp/billing) Also adds: admin-v2 Playwright config, updated spec doc with cross-reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
50
admin-v2/playwright.config.ts
Normal file
50
admin-v2/playwright.config.ts
Normal file
@@ -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',
|
||||||
|
});
|
||||||
182
admin-v2/tests/e2e/smoke_admin.spec.ts
Normal file
182
admin-v2/tests/e2e/smoke_admin.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
331
crates/zclaw-saas/tests/smoke_saas.rs
Normal file
331
crates/zclaw-saas/tests/smoke_saas.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
326
desktop/tests/e2e/specs/smoke_chat.spec.ts
Normal file
326
desktop/tests/e2e/specs/smoke_chat.spec.ts
Normal file
@@ -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<boolean>((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<boolean>((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');
|
||||||
|
}
|
||||||
|
});
|
||||||
240
desktop/tests/e2e/specs/smoke_cross.spec.ts
Normal file
240
desktop/tests/e2e/specs/smoke_cross.spec.ts
Normal file
@@ -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<string> {
|
||||||
|
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'}`);
|
||||||
|
});
|
||||||
260
desktop/tests/e2e/specs/smoke_features.spec.ts
Normal file
260
desktop/tests/e2e/specs/smoke_features.spec.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
> **目标**: 设计覆盖 SaaS Admin + Tauri Desktop 的全面测试方案,验证所有功能形成闭环工作流
|
> **目标**: 设计覆盖 SaaS Admin + Tauri Desktop 的全面测试方案,验证所有功能形成闭环工作流
|
||||||
> **产出物**: 测试设计文档(本文件),指导后续测试执行
|
> **产出物**: 测试设计文档(本文件),指导后续测试执行
|
||||||
> **范围**: 功能测试 / 集成测试 / 端到端测试 / 数据一致性 / 权限验证
|
> **范围**: 功能测试 / 集成测试 / 端到端测试 / 数据一致性 / 权限验证
|
||||||
|
> **执行策略**: 本文档的详细测试矩阵由 [断裂探测测试方案](../../plans/noble-swinging-lynx.md) 驱动执行——先探测断裂,修复后按本文档做全面回归
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user