From 5a3da41562cc72bfe726a167ae514d259a201aad Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Wed, 1 Apr 2026 20:35:01 +0800 Subject: [PATCH] Preserve stable snapshots and stabilize Electron e2e (#734) --- README.ja-JP.md | 5 + README.md | 3 + README.zh-CN.md | 5 + electron/main/index.ts | 17 ++- package.json | 2 +- pnpm-lock.yaml | 25 +++-- src/App.tsx | 6 +- src/pages/Agents/index.tsx | 29 +++-- src/pages/Channels/index.tsx | 24 +++-- src/pages/Models/index.tsx | 77 ++++++++++++-- src/pages/Models/usage-history.ts | 24 +++++ src/stores/chat.ts | 57 +++++++--- src/stores/chat/history-actions.ts | 56 +++++++--- tests/e2e/app-smoke.spec.ts | 6 +- tests/e2e/fixtures/electron.ts | 88 +++++++++++++-- tests/e2e/main-navigation.spec.ts | 25 +++++ tests/unit/agents-page.test.tsx | 42 ++++++++ tests/unit/channels-page.test.tsx | 90 ++++++++++++++++ tests/unit/chat-history-actions.test.ts | 136 ++++++++++++++++++++++++ tests/unit/models-page.test.tsx | 96 +++++++++++++++++ tests/unit/models-usage-history.test.ts | 23 ++++ 21 files changed, 758 insertions(+), 78 deletions(-) create mode 100644 tests/e2e/main-navigation.spec.ts create mode 100644 tests/unit/models-page.test.tsx diff --git a/README.ja-JP.md b/README.ja-JP.md index 673927dc7..e258333fc 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -318,6 +318,7 @@ AI を開発ワークフローに統合できます。エージェントを使 │ ├── i18n/ # ローカライズリソース │ └── types/ # TypeScript 型定義 ├── tests/ +│ ├── e2e/ # Playwright による Electron E2E スモークテスト │ └── unit/ # Vitest ユニット/統合寄りテスト ├── resources/ # 静的アセット(アイコン、画像) └── scripts/ # ビルド/ユーティリティスクリプト @@ -335,6 +336,8 @@ pnpm typecheck # TypeScriptの型チェック # テスト pnpm test # ユニットテストを実行 +pnpm run test:e2e # Electron E2E スモークテストを実行 +pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行 pnpm run comms:replay # 通信リプレイ指標を算出 pnpm run comms:baseline # 通信ベースラインを更新 pnpm run comms:compare # リプレイ指標をベースライン閾値と比較 @@ -348,6 +351,8 @@ pnpm package:win # Windows向けにパッケージ化 pnpm package:linux # Linux向けにパッケージ化 ``` +ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。 + ### 通信回帰チェック PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。 diff --git a/README.md b/README.md index f4c6cc0fb..de9a1f44b 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro │ ├── i18n/ # Localization resources │ └── types/ # TypeScript type definitions ├── tests/ +│ ├── e2e/ # Playwright Electron end-to-end smoke tests │ └── unit/ # Vitest unit/integration-like tests ├── resources/ # Static assets (icons/images) └── scripts/ # Build and utility scripts @@ -354,6 +355,8 @@ pnpm package:win # Package for Windows pnpm package:linux # Package for Linux ``` +On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`. + ### Communication Regression Checks When a PR changes communication paths (gateway events, chat runtime send/receive flow, channel delivery, or transport fallback), run: diff --git a/README.zh-CN.md b/README.zh-CN.md index 264010877..dfb357331 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -322,6 +322,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 │ ├── i18n/ # 国际化资源 │ └── types/ # TypeScript 类型定义 ├── tests/ +│ ├── e2e/ # Playwright Electron 端到端冒烟测试 │ └── unit/ # Vitest 单元/集成型测试 ├── resources/ # 静态资源(图标、图片) └── scripts/ # 构建与工具脚本 @@ -339,6 +340,8 @@ pnpm typecheck # TypeScript 类型检查 # 测试 pnpm test # 运行单元测试 +pnpm run test:e2e # 运行 Electron E2E 冒烟测试 +pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试 pnpm run comms:replay # 计算通信回放指标 pnpm run comms:baseline # 刷新通信基线快照 pnpm run comms:compare # 将回放指标与基线阈值对比 @@ -352,6 +355,8 @@ pnpm package:win # 为 Windows 打包 pnpm package:linux # 为 Linux 打包 ``` +在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。 + ### 通信回归检查 当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行: diff --git a/electron/main/index.ts b/electron/main/index.ts index f2dc1c854..6fbc1522c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -157,6 +157,7 @@ function createWindow(): BrowserWindow { const isMac = process.platform === 'darwin'; const isWindows = process.platform === 'win32'; const useCustomTitleBar = isWindows; + const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1'; const win = new BrowserWindow({ width: 1280, @@ -195,12 +196,20 @@ function createWindow(): BrowserWindow { // Load the app if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(process.env.VITE_DEV_SERVER_URL); + const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL); + if (shouldSkipSetupForE2E) { + rendererUrl.searchParams.set('e2eSkipSetup', '1'); + } + win.loadURL(rendererUrl.toString()); if (!isE2EMode) { win.webContents.openDevTools(); } } else { - win.loadFile(join(__dirname, '../../dist/index.html')); + win.loadFile(join(__dirname, '../../dist/index.html'), { + query: shouldSkipSetupForE2E + ? { e2eSkipSetup: '1' } + : undefined, + }); } return win; @@ -246,7 +255,7 @@ function createMainWindow(): BrowserWindow { }); win.on('close', (event) => { - if (!isQuitting()) { + if (!isQuitting() && !isE2EMode) { event.preventDefault(); win.hide(); } @@ -546,7 +555,7 @@ if (gotTheLock) { }); app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { + if (process.platform !== 'darwin' || isE2EMode) { app.quit(); } }); diff --git a/package.json b/package.json index 0f8d879f0..08c3e7b99 100644 --- a/package.json +++ b/package.json @@ -144,4 +144,4 @@ "zx": "^8.8.5" }, "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c46987328..1a2e7197b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,7 +48,7 @@ importers: version: 2026.3.30(openclaw@2026.3.28(@napi-rs/canvas@0.1.97)(encoding@0.1.13)) '@playwright/test': specifier: ^1.56.1 - version: 1.58.2 + version: 1.59.0 '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1489,8 +1489,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + '@playwright/test@1.59.0': + resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==} engines: {node: '>=18'} hasBin: true @@ -4948,8 +4948,13 @@ packages: engines: {node: '>=18'} hasBin: true - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + playwright-core@1.59.0: + resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.0: + resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} engines: {node: '>=18'} hasBin: true @@ -7826,9 +7831,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.58.2': + '@playwright/test@1.59.0': dependencies: - playwright: 1.58.2 + playwright: 1.59.0 '@posthog/core@1.24.1': dependencies: @@ -11973,9 +11978,11 @@ snapshots: playwright-core@1.58.2: {} - playwright@1.58.2: + playwright-core@1.59.0: {} + + playwright@1.59.0: dependencies: - playwright-core: 1.58.2 + playwright-core: 1.59.0 optionalDependencies: fsevents: 2.3.2 diff --git a/src/App.tsx b/src/App.tsx index 5832ec381..202ed93bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -90,6 +90,8 @@ class ErrorBoundary extends Component< function App() { const navigate = useNavigate(); const location = useLocation(); + const skipSetupForE2E = typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('e2eSkipSetup') === '1'; const initSettings = useSettingsStore((state) => state.init); const theme = useSettingsStore((state) => state.theme); const language = useSettingsStore((state) => state.language); @@ -120,10 +122,10 @@ function App() { // Redirect to setup wizard if not complete useEffect(() => { - if (!setupComplete && !location.pathname.startsWith('/setup')) { + if (!setupComplete && !skipSetupForE2E && !location.pathname.startsWith('/setup')) { navigate('/setup'); } - }, [setupComplete, location.pathname, navigate]); + }, [setupComplete, skipSetupForE2E, location.pathname, navigate]); // Listen for navigation events from main process useEffect(() => { diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 91ce74d71..4ee5b7a2d 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -106,6 +106,7 @@ export function Agents() { deleteAgent, } = useAgentsStore(); const [channelGroups, setChannelGroups] = useState([]); + const [hasCompletedInitialLoad, setHasCompletedInitialLoad] = useState(() => agents.length > 0); const [showAddDialog, setShowAddDialog] = useState(false); const [activeAgentId, setActiveAgentId] = useState(null); @@ -116,13 +117,21 @@ export function Agents() { const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts'); setChannelGroups(response.channels || []); } catch { - setChannelGroups([]); + // Keep the last rendered snapshot when channel account refresh fails. } }, []); useEffect(() => { + let mounted = true; // eslint-disable-next-line react-hooks/set-state-in-effect - void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]); + void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]).finally(() => { + if (mounted) { + setHasCompletedInitialLoad(true); + } + }); + return () => { + mounted = false; + }; }, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]); useEffect(() => { @@ -150,11 +159,15 @@ export function Agents() { () => agents.find((agent) => agent.id === activeAgentId) ?? null, [activeAgentId, agents], ); + + const visibleAgents = agents; + const visibleChannelGroups = channelGroups; + const isUsingStableValue = loading && hasCompletedInitialLoad; const handleRefresh = () => { void Promise.all([fetchAgents(), fetchChannelAccounts()]); }; - if (loading) { + if (loading && !hasCompletedInitialLoad) { return (
@@ -163,7 +176,7 @@ export function Agents() { } return ( -
+
@@ -181,7 +194,7 @@ export function Agents() { onClick={handleRefresh} className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors" > - + {t('refresh')}
@@ -368,7 +372,7 @@ export function Channels() { }} > - {agents.map((agent) => ( + {visibleAgents.map((agent) => ( ))} diff --git a/src/pages/Models/index.tsx b/src/pages/Models/index.tsx index 59aa2e343..5a6bf346d 100644 --- a/src/pages/Models/index.tsx +++ b/src/pages/Models/index.tsx @@ -15,6 +15,8 @@ import { FeedbackState } from '@/components/common/FeedbackState'; import { filterUsageHistoryByWindow, groupUsageHistory, + resolveStableUsageHistory, + resolveVisibleUsageHistory, type UsageGroupBy, type UsageHistoryEntry, type UsageWindow, @@ -22,6 +24,7 @@ import { const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2; const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3; const USAGE_FETCH_RETRY_DELAY_MS = 1500; +const USAGE_AUTO_REFRESH_INTERVAL_MS = 15_000; export function Models() { const { t } = useTranslation(['dashboard', 'settings']); @@ -36,6 +39,7 @@ export function Models() { const [usageWindow, setUsageWindow] = useState('7d'); const [usagePage, setUsagePage] = useState(1); const [selectedUsageEntry, setSelectedUsageEntry] = useState(null); + const [usageRefreshNonce, setUsageRefreshNonce] = useState(0); const HIDDEN_USAGE_SOURCES = new Set([ 'gateway-injected', 'delivery-mirror', @@ -71,35 +75,79 @@ export function Models() { type FetchState = { status: 'idle' | 'loading' | 'done'; data: UsageHistoryEntry[]; + stableData: UsageHistoryEntry[]; }; type FetchAction = | { type: 'start' } | { type: 'done'; data: UsageHistoryEntry[] } + | { type: 'failed' } | { type: 'reset' }; const [fetchState, dispatchFetch] = useReducer( (state: FetchState, action: FetchAction): FetchState => { switch (action.type) { case 'start': - return { status: 'loading', data: state.data }; + return { ...state, status: 'loading' }; case 'done': - return { status: 'done', data: action.data }; + return { + status: 'done', + data: action.data, + stableData: resolveStableUsageHistory(state.stableData, action.data), + }; + case 'failed': + return { ...state, status: 'done' }; case 'reset': - return { status: 'idle', data: [] }; + return { status: 'idle', data: [], stableData: [] }; default: return state; } }, - { status: 'idle' as const, data: [] as UsageHistoryEntry[] }, + { status: 'idle' as const, data: [] as UsageHistoryEntry[], stableData: [] as UsageHistoryEntry[] }, ); const usageFetchTimerRef = useRef | null>(null); const usageFetchGenerationRef = useRef(0); + const usageFetchStatusRef = useRef('idle'); + + useEffect(() => { + usageFetchStatusRef.current = fetchState.status; + }, [fetchState.status]); useEffect(() => { trackUiEvent('models.page_viewed'); }, []); + useEffect(() => { + if (!isGatewayRunning) { + return; + } + + const requestRefresh = () => { + if (usageFetchStatusRef.current === 'loading') return; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + setUsageRefreshNonce((value) => value + 1); + }; + + const intervalId = window.setInterval(requestRefresh, USAGE_AUTO_REFRESH_INTERVAL_MS); + const handleFocus = () => { + requestRefresh(); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + requestRefresh(); + } + }; + + window.addEventListener('focus', handleFocus); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.clearInterval(intervalId); + window.removeEventListener('focus', handleFocus); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isGatewayRunning]); + useEffect(() => { if (usageFetchTimerRef.current) { clearTimeout(usageFetchTimerRef.current); @@ -128,7 +176,7 @@ export function Models() { generation, restartMarker, }); - dispatchFetch({ type: 'done', data: [] }); + dispatchFetch({ type: 'failed' }); }, 30_000); const fetchUsageHistoryWithRetry = async (attempt: number) => { @@ -191,7 +239,7 @@ export function Models() { }, USAGE_FETCH_RETRY_DELAY_MS); return; } - dispatchFetch({ type: 'done', data: [] }); + dispatchFetch({ type: 'failed' }); trackUiEvent('models.token_usage_fetch_exhausted', { generation, attempt, @@ -210,18 +258,25 @@ export function Models() { usageFetchTimerRef.current = null; } }; - }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]); + }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts, usageRefreshNonce]); - const visibleUsageHistory = isGatewayRunning + const usageHistory = isGatewayRunning ? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry)) : []; + const stableUsageHistory = isGatewayRunning + ? fetchState.stableData.filter((entry) => !shouldHideUsageEntry(entry)) + : []; + const visibleUsageHistory = resolveVisibleUsageHistory(usageHistory, stableUsageHistory, { + preferStableOnEmpty: isGatewayRunning && fetchState.status === 'loading', + }); const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow); const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy); const usagePageSize = 5; const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize)); const safeUsagePage = Math.min(usagePage, usageTotalPages); const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize); - const usageLoading = isGatewayRunning && fetchState.status === 'loading'; + const usageLoading = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length === 0; + const usageRefreshing = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length > 0; return (
@@ -328,7 +383,9 @@ export function Models() {

- {t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })} + {usageRefreshing + ? t('dashboard:recentTokenHistory.loading') + : t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}

diff --git a/src/pages/Models/usage-history.ts b/src/pages/Models/usage-history.ts index df63c46f7..13cacdf03 100644 --- a/src/pages/Models/usage-history.ts +++ b/src/pages/Models/usage-history.ts @@ -26,6 +26,30 @@ export type UsageGroup = { sortKey: number | string; }; +export function resolveStableUsageHistory( + previousStableEntries: UsageHistoryEntry[], + nextEntries: UsageHistoryEntry[], + options: { preservePreviousOnEmpty?: boolean } = {}, +): UsageHistoryEntry[] { + if (nextEntries.length > 0) { + return nextEntries; + } + + return options.preservePreviousOnEmpty ? previousStableEntries : []; +} + +export function resolveVisibleUsageHistory( + currentEntries: UsageHistoryEntry[], + stableEntries: UsageHistoryEntry[], + options: { preferStableOnEmpty?: boolean } = {}, +): UsageHistoryEntry[] { + if (options.preferStableOnEmpty && currentEntries.length === 0) { + return stableEntries; + } + + return currentEntries; +} + export function formatUsageDay(timestamp: string): string { const date = new Date(timestamp); if (Number.isNaN(date.getTime())) return timestamp; diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 0d1e7bf34..0fd0ced83 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1316,11 +1316,48 @@ export const useChatStore = create((set, get) => ({ }, 15_000); const loadPromise = (async () => { + const isCurrentSession = () => get().currentSessionKey === currentSessionKey; + const getPreviewMergeKey = (message: RawMessage): string => ( + `${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}` + ); + const mergeHydratedMessages = ( + currentMessages: RawMessage[], + hydratedMessages: RawMessage[], + ): RawMessage[] => { + const hydratedFilesByKey = new Map( + hydratedMessages + .filter((message) => message._attachedFiles?.length) + .map((message) => [ + getPreviewMergeKey(message), + message._attachedFiles!.map((file) => ({ ...file })), + ]), + ); + + return currentMessages.map((message) => { + const attachedFiles = hydratedFilesByKey.get(getPreviewMergeKey(message)); + return attachedFiles + ? { ...message, _attachedFiles: attachedFiles } + : message; + }); + }; + + const applyLoadFailure = (errorMessage: string | null) => { + if (!isCurrentSession()) return; + set((state) => { + const hasMessages = state.messages.length > 0; + return { + loading: false, + error: !quiet && errorMessage ? errorMessage : state.error, + ...(hasMessages ? {} : { messages: [] as RawMessage[] }), + }; + }); + }; + const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => { // Guard: if the user switched sessions while this async load was in // flight, discard the result to prevent overwriting the new session's // messages with stale data from the old session. - if (get().currentSessionKey !== currentSessionKey) return; + if (!isCurrentSession()) return; // Before filtering: attach images/files from tool_result messages to the next assistant message const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); @@ -1379,17 +1416,11 @@ export const useChatStore = create((set, get) => ({ // Async: load missing image previews from disk (updates in background) loadMissingPreviews(finalMessages).then((updated) => { + if (!isCurrentSession()) return; if (updated) { - // Create new object references so React.memo detects changes. - // loadMissingPreviews mutates AttachedFileMeta in place, so we - // must produce fresh message + file references for each affected msg. - set({ - messages: finalMessages.map(msg => - msg._attachedFiles - ? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) } - : msg - ), - }); + set((state) => ({ + messages: mergeHydratedMessages(state.messages, finalMessages), + })); } }); const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get(); @@ -1445,7 +1476,7 @@ export const useChatStore = create((set, get) => ({ if (fallbackMessages.length > 0) { applyLoadedMessages(fallbackMessages, null); } else { - set({ messages: [], loading: false }); + applyLoadFailure('Failed to load chat history'); } } } catch (err) { @@ -1454,7 +1485,7 @@ export const useChatStore = create((set, get) => ({ if (fallbackMessages.length > 0) { applyLoadedMessages(fallbackMessages, null); } else { - set({ messages: [], loading: false }); + applyLoadFailure(String(err)); } } })(); diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index 5770a0397..d962cd533 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -37,7 +37,45 @@ export function createHistoryActions( const { currentSessionKey } = get(); if (!quiet) set({ loading: true, error: null }); + const isCurrentSession = () => get().currentSessionKey === currentSessionKey; + const getPreviewMergeKey = (message: RawMessage): string => ( + `${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}` + ); + const mergeHydratedMessages = ( + currentMessages: RawMessage[], + hydratedMessages: RawMessage[], + ): RawMessage[] => { + const hydratedFilesByKey = new Map( + hydratedMessages + .filter((message) => message._attachedFiles?.length) + .map((message) => [ + getPreviewMergeKey(message), + message._attachedFiles!.map((file) => ({ ...file })), + ]), + ); + + return currentMessages.map((message) => { + const attachedFiles = hydratedFilesByKey.get(getPreviewMergeKey(message)); + return attachedFiles + ? { ...message, _attachedFiles: attachedFiles } + : message; + }); + }; + + const applyLoadFailure = (errorMessage: string | null) => { + if (!isCurrentSession()) return; + set((state) => { + const hasMessages = state.messages.length > 0; + return { + loading: false, + error: !quiet && errorMessage ? errorMessage : state.error, + ...(hasMessages ? {} : { messages: [] as RawMessage[] }), + }; + }); + }; + const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => { + if (!isCurrentSession()) return; // Before filtering: attach images/files from tool_result messages to the next assistant message const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role) && !isInternalMessage(msg)); @@ -95,17 +133,11 @@ export function createHistoryActions( // Async: load missing image previews from disk (updates in background) loadMissingPreviews(finalMessages).then((updated) => { + if (!isCurrentSession()) return; if (updated) { - // Create new object references so React.memo detects changes. - // loadMissingPreviews mutates AttachedFileMeta in place, so we - // must produce fresh message + file references for each affected msg. - set({ - messages: finalMessages.map(msg => - msg._attachedFiles - ? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) } - : msg - ), - }); + set((state) => ({ + messages: mergeHydratedMessages(state.messages, finalMessages), + })); } }); const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get(); @@ -163,7 +195,7 @@ export function createHistoryActions( if (fallbackMessages.length > 0) { applyLoadedMessages(fallbackMessages, null); } else { - set({ messages: [], loading: false }); + applyLoadFailure(result.error || 'Failed to load chat history'); } } } catch (err) { @@ -172,7 +204,7 @@ export function createHistoryActions( if (fallbackMessages.length > 0) { applyLoadedMessages(fallbackMessages, null); } else { - set({ messages: [], loading: false }); + applyLoadFailure(String(err)); } } }, diff --git a/tests/e2e/app-smoke.spec.ts b/tests/e2e/app-smoke.spec.ts index de93a506b..9f262a749 100644 --- a/tests/e2e/app-smoke.spec.ts +++ b/tests/e2e/app-smoke.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from './fixtures/electron'; +import { closeElectronApp, expect, test } from './fixtures/electron'; test.describe('ClawX Electron smoke flows', () => { test('shows the setup wizard on a fresh profile', async ({ page }) => { @@ -25,7 +25,7 @@ test.describe('ClawX Electron smoke flows', () => { await firstWindow.getByTestId('setup-skip-button').click(); await expect(firstWindow.getByTestId('main-layout')).toBeVisible(); - await electronApp.close(); + await closeElectronApp(electronApp); const relaunchedApp = await launchElectronApp(); try { @@ -35,7 +35,7 @@ test.describe('ClawX Electron smoke flows', () => { await expect(relaunchedWindow.getByTestId('main-layout')).toBeVisible(); await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0); } finally { - await relaunchedApp.close(); + await closeElectronApp(relaunchedApp); } }); }); diff --git a/tests/e2e/fixtures/electron.ts b/tests/e2e/fixtures/electron.ts index 026f20a6d..c96da18a4 100644 --- a/tests/e2e/fixtures/electron.ts +++ b/tests/e2e/fixtures/electron.ts @@ -5,12 +5,16 @@ import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +type LaunchElectronOptions = { + skipSetup?: boolean; +}; + type ElectronFixtures = { electronApp: ElectronApplication; page: Page; homeDir: string; userDataDir: string; - launchElectronApp: () => Promise; + launchElectronApp: (options?: LaunchElectronOptions) => Promise; }; const repoRoot = resolve(process.cwd()); @@ -38,7 +42,77 @@ async function allocatePort(): Promise { }); } -async function launchClawXElectron(homeDir: string, userDataDir: string): Promise { +async function getStableWindow(app: ElectronApplication): Promise { + const deadline = Date.now() + 30_000; + let page = await app.firstWindow(); + + while (Date.now() < deadline) { + const openWindows = app.windows().filter((candidate) => !candidate.isClosed()); + const currentWindow = openWindows.at(-1) ?? page; + + if (currentWindow && !currentWindow.isClosed()) { + try { + await currentWindow.waitForLoadState('domcontentloaded', { timeout: 2_000 }); + return currentWindow; + } catch (error) { + if (!String(error).includes('has been closed')) { + throw error; + } + } + } + + try { + page = await app.waitForEvent('window', { timeout: 2_000 }); + } catch { + // Keep polling until a stable window is available or the deadline expires. + } + } + + throw new Error('No stable Electron window became available'); +} + +async function closeElectronApp(app: ElectronApplication, timeoutMs = 5_000): Promise { + let closed = false; + + await Promise.race([ + (async () => { + const [closeResult] = await Promise.allSettled([ + app.waitForEvent('close', { timeout: timeoutMs }), + app.evaluate(({ app: electronApp }) => { + electronApp.quit(); + }), + ]); + + if (closeResult.status === 'fulfilled') { + closed = true; + } + })(), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + + if (closed) { + return; + } + + try { + await app.close(); + return; + } catch { + // Fall through to process kill if Playwright cannot close the app cleanly. + } + + try { + app.process().kill('SIGKILL'); + } catch { + // Ignore process kill failures during e2e teardown. + } +} + +async function launchClawXElectron( + homeDir: string, + userDataDir: string, + options: LaunchElectronOptions = {}, +): Promise { const hostApiPort = await allocatePort(); const electronEnv = process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } @@ -56,6 +130,7 @@ async function launchClawXElectron(homeDir: string, userDataDir: string): Promis XDG_CONFIG_HOME: join(homeDir, '.config'), CLAWX_E2E: '1', CLAWX_USER_DATA_DIR: userDataDir, + ...(options.skipSetup ? { CLAWX_E2E_SKIP_SETUP: '1' } : {}), CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort), }, timeout: 90_000, @@ -85,7 +160,7 @@ export const test = base.extend({ }, launchElectronApp: async ({ homeDir, userDataDir }, provideLauncher) => { - await provideLauncher(async () => await launchClawXElectron(homeDir, userDataDir)); + await provideLauncher(async (options?: LaunchElectronOptions) => await launchClawXElectron(homeDir, userDataDir, options)); }, electronApp: async ({ launchElectronApp }, provideElectronApp) => { @@ -99,14 +174,13 @@ export const test = base.extend({ await provideElectronApp(app); } finally { if (!appClosed) { - await app.close().catch(() => {}); + await closeElectronApp(app); } } }, page: async ({ electronApp }, providePage) => { - const page = await electronApp.firstWindow(); - await page.waitForLoadState('domcontentloaded'); + const page = await getStableWindow(electronApp); await providePage(page); }, }); @@ -117,4 +191,6 @@ export async function completeSetup(page: Page): Promise { await expect(page.getByTestId('main-layout')).toBeVisible(); } +export { closeElectronApp }; +export { getStableWindow }; export { expect }; diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts new file mode 100644 index 000000000..e0d2a15c5 --- /dev/null +++ b/tests/e2e/main-navigation.spec.ts @@ -0,0 +1,25 @@ +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test.describe('ClawX main navigation without setup flow', () => { + test('navigates between core pages with setup bypassed', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + const page = await getStableWindow(app); + + await expect(page.getByTestId('main-layout')).toBeVisible(); + + await page.getByTestId('sidebar-nav-models').click(); + await expect(page.getByTestId('models-page')).toBeVisible(); + await expect(page.getByTestId('models-page-title')).toBeVisible(); + + await page.getByTestId('sidebar-nav-agents').click(); + await expect(page.getByTestId('agents-page')).toBeVisible(); + + await page.getByTestId('sidebar-nav-channels').click(); + await expect(page.getByTestId('channels-page')).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/unit/agents-page.test.tsx b/tests/unit/agents-page.test.tsx index 4be182bc9..0c18e8e0b 100644 --- a/tests/unit/agents-page.test.tsx +++ b/tests/unit/agents-page.test.tsx @@ -214,4 +214,46 @@ describe('Agents page status refresh', () => { expect((modelIdInput as HTMLInputElement).value).toBe('anthropic/claude-opus-4.6'); expect(useDefaultButton).toBeDisabled(); }); + + it('keeps the last agent snapshot visible while a refresh is in flight', async () => { + agentsState.agents = [ + { + id: 'main', + name: 'Main', + isDefault: true, + modelDisplay: 'gpt-5', + modelRef: 'openai/gpt-5', + overrideModelRef: null, + inheritedModel: true, + workspace: '~/.openclaw/workspace', + agentDir: '~/.openclaw/agents/main/agent', + mainSessionKey: 'agent:main:main', + channelTypes: [], + }, + ]; + + const { rerender } = render(); + + expect(await screen.findByText('Main')).toBeInTheDocument(); + + agentsState.loading = true; + await act(async () => { + rerender(); + }); + + expect(screen.getByText('Main')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('keeps the blocking spinner during the initial load before any stable snapshot exists', async () => { + agentsState.loading = true; + fetchAgentsMock.mockImplementation(() => new Promise(() => {})); + refreshProviderSnapshotMock.mockImplementation(() => new Promise(() => {})); + hostApiFetchMock.mockImplementation(() => new Promise(() => {})); + + const { container } = render(); + + expect(container.querySelector('svg.animate-spin')).toBeTruthy(); + expect(screen.queryByText('title')).not.toBeInTheDocument(); + }); }); diff --git a/tests/unit/channels-page.test.tsx b/tests/unit/channels-page.test.tsx index 66b694427..5c3cf5ea4 100644 --- a/tests/unit/channels-page.test.tsx +++ b/tests/unit/channels-page.test.tsx @@ -37,6 +37,14 @@ vi.mock('sonner', () => ({ }, })); +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + describe('Channels page status refresh', () => { beforeEach(() => { vi.clearAllMocks(); @@ -180,4 +188,86 @@ describe('Channels page status refresh', () => { expect(screen.queryByLabelText('account.customIdLabel')).not.toBeInTheDocument(); }); + + it('keeps the last channel snapshot visible while refresh is pending', async () => { + subscribeHostEventMock.mockImplementation(() => vi.fn()); + + const channelsDeferred = createDeferred<{ + success: boolean; + channels: Array>; + }>(); + const agentsDeferred = createDeferred<{ + success: boolean; + agents: Array>; + }>(); + + let refreshCallCount = 0; + hostApiFetchMock.mockImplementation((path: string) => { + if (path === '/api/channels/accounts') { + if (refreshCallCount === 0) { + refreshCallCount += 1; + return Promise.resolve({ + success: true, + channels: [ + { + channelType: 'feishu', + defaultAccountId: 'default', + status: 'connected', + accounts: [ + { + accountId: 'default', + name: 'Primary Account', + configured: true, + status: 'connected', + isDefault: true, + }, + ], + }, + ], + }); + } + return channelsDeferred.promise; + } + + if (path === '/api/agents') { + if (refreshCallCount === 1) { + return Promise.resolve({ success: true, agents: [] }); + } + return agentsDeferred.promise; + } + + throw new Error(`Unexpected host API path: ${path}`); + }); + + render(); + + expect(await screen.findByText('Feishu / Lark')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'refresh' })); + + expect(screen.getByText('Feishu / Lark')).toBeInTheDocument(); + + await act(async () => { + channelsDeferred.resolve({ + success: true, + channels: [ + { + channelType: 'feishu', + defaultAccountId: 'default', + status: 'connected', + accounts: [ + { + accountId: 'default', + name: 'Primary Account', + configured: true, + status: 'connected', + isDefault: true, + }, + ], + }, + ], + }); + agentsDeferred.resolve({ success: true, agents: [] }); + }); + }); }); diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts index 01645b11f..916bf1c50 100644 --- a/tests/unit/chat-history-actions.test.ts +++ b/tests/unit/chat-history-actions.test.ts @@ -137,6 +137,29 @@ describe('chat history actions', () => { expect(h.read().loading).toBe(false); }); + it('preserves existing messages when history refresh fails for the current session', async () => { + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + messages: [ + { + role: 'assistant', + content: 'still here', + timestamp: 1773281732, + }, + ], + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + invokeIpcMock.mockRejectedValueOnce(new Error('Gateway unavailable')); + + await actions.loadHistory(); + + expect(h.read().messages.map((message) => message.content)).toEqual(['still here']); + expect(h.read().error).toBe('Error: Gateway unavailable'); + expect(h.read().loading).toBe(false); + }); + it('filters out system messages from loaded history', async () => { const { createHistoryActions } = await import('@/stores/chat/history-actions'); const h = makeHarness(); @@ -231,4 +254,117 @@ describe('chat history actions', () => { 'HEARTBEAT_OK is a status code', ]); }); + + it('drops stale history results after the user switches sessions', async () => { + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + let resolveHistory: ((value: unknown) => void) | null = null; + invokeIpcMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveHistory = resolve; + })); + + const h = makeHarness({ + currentSessionKey: 'agent:main:session-a', + messages: [ + { + role: 'assistant', + content: 'session b content', + timestamp: 1773281732, + }, + ], + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + const loadPromise = actions.loadHistory(); + h.set({ + currentSessionKey: 'agent:main:session-b', + messages: [ + { + role: 'assistant', + content: 'session b content', + timestamp: 1773281733, + }, + ], + }); + resolveHistory?.({ + success: true, + result: { + messages: [ + { + role: 'assistant', + content: 'stale session a content', + timestamp: 1773281734, + }, + ], + }, + }); + + await loadPromise; + + expect(h.read().currentSessionKey).toBe('agent:main:session-b'); + expect(h.read().messages.map((message) => message.content)).toEqual(['session b content']); + }); + + it('preserves newer same-session messages when preview hydration finishes later', async () => { + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + let releasePreviewHydration: (() => void) | null = null; + loadMissingPreviews.mockImplementationOnce(async (messages) => { + await new Promise((resolve) => { + releasePreviewHydration = () => { + messages[0]!._attachedFiles = [ + { + fileName: 'image.png', + mimeType: 'image/png', + fileSize: 42, + preview: 'data:image/png;base64,abc', + filePath: '/tmp/image.png', + }, + ]; + resolve(); + }; + }); + return true; + }); + + invokeIpcMock.mockResolvedValueOnce({ + success: true, + result: { + messages: [ + { + id: 'history-1', + role: 'assistant', + content: 'older message', + timestamp: 1000, + }, + ], + }, + }); + + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + await actions.loadHistory(); + + h.set((state) => ({ + messages: [ + ...state.messages, + { + id: 'newer-1', + role: 'assistant', + content: 'newer message', + timestamp: 1001, + }, + ], + })); + + releasePreviewHydration?.(); + await Promise.resolve(); + + expect(h.read().messages.map((message) => message.content)).toEqual([ + 'older message', + 'newer message', + ]); + expect(h.read().messages[0]?._attachedFiles?.[0]?.preview).toBe('data:image/png;base64,abc'); + }); }); diff --git a/tests/unit/models-page.test.tsx b/tests/unit/models-page.test.tsx new file mode 100644 index 000000000..ad0913980 --- /dev/null +++ b/tests/unit/models-page.test.tsx @@ -0,0 +1,96 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Models } from '@/pages/Models/index'; + +const hostApiFetchMock = vi.fn(); +const trackUiEventMock = vi.fn(); + +const { gatewayState, settingsState } = vi.hoisted(() => ({ + gatewayState: { + status: { state: 'running', port: 18789, connectedAt: 1, pid: 1234 }, + }, + settingsState: { + devModeUnlocked: false, + }, +})); + +vi.mock('@/stores/gateway', () => ({ + useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState), +})); + +vi.mock('@/stores/settings', () => ({ + useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState), +})); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +vi.mock('@/lib/telemetry', () => ({ + trackUiEvent: (...args: unknown[]) => trackUiEventMock(...args), +})); + +vi.mock('@/components/settings/ProvidersSettings', () => ({ + ProvidersSettings: () => null, +})); + +vi.mock('@/components/common/FeedbackState', () => ({ + FeedbackState: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | { count?: number }) => { + if (typeof fallback === 'string') return fallback; + return key; + }, + }), +})); + +function createUsageEntry(totalTokens: number) { + return { + timestamp: '2026-04-01T12:00:00.000Z', + sessionId: `session-${totalTokens}`, + agentId: 'main', + model: 'gpt-5', + provider: 'openai', + inputTokens: totalTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens, + }; +} + +describe('Models page auto refresh', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + gatewayState.status = { state: 'running', port: 18789, connectedAt: 1, pid: 1234 }; + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + hostApiFetchMock.mockResolvedValue([createUsageEntry(27)]); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('refreshes token usage while the page stays open', async () => { + render(); + + await act(async () => { + await Promise.resolve(); + }); + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(15_000); + await Promise.resolve(); + }); + + expect(hostApiFetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/models-usage-history.test.ts b/tests/unit/models-usage-history.test.ts index 2907fdc75..2105832d9 100644 --- a/tests/unit/models-usage-history.test.ts +++ b/tests/unit/models-usage-history.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { filterUsageHistoryByWindow, groupUsageHistory, + resolveStableUsageHistory, + resolveVisibleUsageHistory, type UsageHistoryEntry, } from '@/pages/Models/usage-history'; @@ -65,4 +67,25 @@ describe('models usage history helpers', () => { expect(filtered).toHaveLength(2); expect(filtered.map((entry) => entry.totalTokens)).toEqual([12, 11]); }); + + it('clears the stable usage snapshot when a successful refresh returns empty', () => { + const stable = [createEntry(12, 12)]; + + expect(resolveStableUsageHistory(stable, [])).toEqual([]); + }); + + it('can preserve the last stable usage snapshot while a refresh is still in flight', () => { + const stable = [createEntry(12, 12)]; + + expect(resolveStableUsageHistory(stable, [], { preservePreviousOnEmpty: true })).toEqual(stable); + }); + + it('prefers fresh usage entries over the cached snapshot when available', () => { + const stable = [createEntry(12, 12)]; + const fresh = [createEntry(13, 13)]; + + expect(resolveVisibleUsageHistory([], stable)).toEqual([]); + expect(resolveVisibleUsageHistory([], stable, { preferStableOnEmpty: true })).toEqual(stable); + expect(resolveVisibleUsageHistory(fresh, stable, { preferStableOnEmpty: true })).toEqual(fresh); + }); });