Preserve stable snapshots and stabilize Electron e2e (#734)

This commit is contained in:
Lingxuan Zuo
2026-04-01 20:35:01 +08:00
committed by GitHub
Unverified
parent 34bbb039d3
commit 5a3da41562
21 changed files with 758 additions and 78 deletions

View File

@@ -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(<Agents />);
expect(await screen.findByText('Main')).toBeInTheDocument();
agentsState.loading = true;
await act(async () => {
rerender(<Agents />);
});
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(<Agents />);
expect(container.querySelector('svg.animate-spin')).toBeTruthy();
expect(screen.queryByText('title')).not.toBeInTheDocument();
});
});

View File

@@ -37,6 +37,14 @@ vi.mock('sonner', () => ({
},
}));
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((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<Record<string, unknown>>;
}>();
const agentsDeferred = createDeferred<{
success: boolean;
agents: Array<Record<string, unknown>>;
}>();
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(<Channels />);
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: [] });
});
});
});

View File

@@ -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<void>((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');
});
});

View File

@@ -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 }) => <div>{title}</div>,
}));
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(<Models />);
await act(async () => {
await Promise.resolve();
});
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(15_000);
await Promise.resolve();
});
expect(hostApiFetchMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -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);
});
});