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 break detection results (21/30 pass, 63%): - SaaS API: 5/5 pass (S3 skip no LLM key) - Admin V2: 5/6 pass (A6 flaky auth guard) - Desktop Chat: 3/6 pass (D1 no chat response in browser; D2/D3 skip non-Tauri) - Desktop Feature: 6/6 pass - Cross-System: 2/6 pass (4 blocked by login rate limit 429) Bugs found: - P0-01: Account lockout not enforced (locked_until set but not checked) - P1-01: Refresh token still valid after logout - P1-02: Desktop browser chat no response (stores not exposed) - P1-03: Provider API requires display_name (undocumented) Fixes applied: - desktop/src/index.css: @import -> @plugin for Tailwind v4 compatibility - Admin tests: correct credentials admin/admin123 from .env - Cross tests: correct dashboard endpoint /stats/dashboard
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* 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('/');
|
|
// 等待 React 挂载 — 检查 #root 有内容
|
|
await page.waitForSelector('#root > *', { timeout: 15000 });
|
|
await page.waitForTimeout(1000); // 给 React 额外渲染时间
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
});
|