Preserve stable snapshots and stabilize Electron e2e (#734)
This commit is contained in:
committed by
GitHub
Unverified
parent
34bbb039d3
commit
5a3da41562
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ElectronApplication>;
|
||||
launchElectronApp: (options?: LaunchElectronOptions) => Promise<ElectronApplication>;
|
||||
};
|
||||
|
||||
const repoRoot = resolve(process.cwd());
|
||||
@@ -38,7 +42,77 @@ async function allocatePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function launchClawXElectron(homeDir: string, userDataDir: string): Promise<ElectronApplication> {
|
||||
async function getStableWindow(app: ElectronApplication): Promise<Page> {
|
||||
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<void> {
|
||||
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<ElectronApplication> {
|
||||
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<ElectronFixtures>({
|
||||
},
|
||||
|
||||
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<ElectronFixtures>({
|
||||
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<void> {
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
}
|
||||
|
||||
export { closeElectronApp };
|
||||
export { getStableWindow };
|
||||
export { expect };
|
||||
|
||||
25
tests/e2e/main-navigation.spec.ts
Normal file
25
tests/e2e/main-navigation.spec.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
96
tests/unit/models-page.test.tsx
Normal file
96
tests/unit/models-page.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user