Add complete Browser Hand UI system for browser automation: Components: - BrowserHandCard: Main card with status display and screenshot preview - TaskTemplateModal: Template selection and parameter configuration - ScreenshotPreview: Screenshot display with fullscreen capability Templates: - Basic operations: navigate, screenshot, form fill, click, execute JS - Scraping: text, list, images, links, tables - Automation: login+action, multi-page, monitoring, pagination Features: - 15 built-in task templates across 3 categories - Real-time execution status with progress bar - Screenshot preview with zoom and fullscreen - Integration with HandsPanel for seamless UX - Zustand store for state management - Comprehensive test coverage (16 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
274 lines
8.7 KiB
TypeScript
274 lines
8.7 KiB
TypeScript
/**
|
|
* Browser Hand Store Tests
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { useBrowserHandStore } from '../../desktop/src/store/browserHandStore';
|
|
import {
|
|
validateTemplateParams,
|
|
mergeParamsWithDefaults,
|
|
} from '../../desktop/src/components/BrowserHand/templates';
|
|
import type { TaskTemplateParam } from '../../desktop/src/components/BrowserHand/templates';
|
|
|
|
// Mock the browser-client module
|
|
vi.mock('../../desktop/src/lib/browser-client', () => ({
|
|
default: vi.fn().mockImplementation(() => ({
|
|
start: vi.fn().mockResolvedValue('test-session-id'),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
goto: vi.fn().mockResolvedValue({ url: 'https://example.com', title: 'Test Page' }),
|
|
screenshot: vi.fn().mockResolvedValue({ base64: 'test-base64', format: 'png' }),
|
|
url: vi.fn().mockResolvedValue('https://example.com'),
|
|
title: vi.fn().mockResolvedValue('Test Page'),
|
|
click: vi.fn().mockResolvedValue(undefined),
|
|
type: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue({}),
|
|
eval: vi.fn().mockResolvedValue(null),
|
|
})),
|
|
createSession: vi.fn().mockResolvedValue({ session_id: 'test-session-id' }),
|
|
closeSession: vi.fn().mockResolvedValue(undefined),
|
|
listSessions: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
// Mock uuid
|
|
vi.mock('uuid', () => ({
|
|
v4: vi.fn().mockReturnValue('test-uuid'),
|
|
}));
|
|
|
|
describe('browserHandStore', () => {
|
|
beforeEach(() => {
|
|
// Reset store state before each test
|
|
useBrowserHandStore.setState({
|
|
sessions: [],
|
|
activeSessionId: null,
|
|
execution: {
|
|
isRunning: false,
|
|
currentAction: null,
|
|
currentUrl: null,
|
|
lastScreenshot: null,
|
|
progress: 0,
|
|
startTime: null,
|
|
status: 'idle',
|
|
error: null,
|
|
},
|
|
logs: [],
|
|
templates: [],
|
|
recentTasks: [],
|
|
isTemplateModalOpen: false,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
describe('initial state', () => {
|
|
it('should have correct initial state', () => {
|
|
const state = useBrowserHandStore.getState();
|
|
|
|
expect(state.sessions).toEqual([]);
|
|
expect(state.activeSessionId).toBeNull();
|
|
expect(state.execution.isRunning).toBe(false);
|
|
expect(state.logs).toEqual([]);
|
|
expect(state.isTemplateModalOpen).toBe(false);
|
|
expect(state.isLoading).toBe(false);
|
|
expect(state.error).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('UI actions', () => {
|
|
it('should open template modal', () => {
|
|
const { openTemplateModal } = useBrowserHandStore.getState();
|
|
openTemplateModal();
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.isTemplateModalOpen).toBe(true);
|
|
});
|
|
|
|
it('should close template modal', () => {
|
|
const { openTemplateModal, closeTemplateModal } = useBrowserHandStore.getState();
|
|
openTemplateModal();
|
|
closeTemplateModal();
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.isTemplateModalOpen).toBe(false);
|
|
});
|
|
|
|
it('should set loading state', () => {
|
|
const { setLoading } = useBrowserHandStore.getState();
|
|
setLoading(true);
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.isLoading).toBe(true);
|
|
});
|
|
|
|
it('should set and clear error', () => {
|
|
const { setError, clearError } = useBrowserHandStore.getState();
|
|
setError('Test error');
|
|
|
|
let state = useBrowserHandStore.getState();
|
|
expect(state.error).toBe('Test error');
|
|
|
|
clearError();
|
|
state = useBrowserHandStore.getState();
|
|
expect(state.error).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('execution state', () => {
|
|
it('should update execution state', () => {
|
|
const { updateExecutionState } = useBrowserHandStore.getState();
|
|
updateExecutionState({
|
|
currentAction: 'Navigating...',
|
|
progress: 50,
|
|
});
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.execution.currentAction).toBe('Navigating...');
|
|
expect(state.execution.progress).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('logs', () => {
|
|
it('should add log entries', () => {
|
|
const { addLog } = useBrowserHandStore.getState();
|
|
addLog({ level: 'info', message: 'Test log' });
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.logs).toHaveLength(1);
|
|
expect(state.logs[0].level).toBe('info');
|
|
expect(state.logs[0].message).toBe('Test log');
|
|
});
|
|
|
|
it('should clear logs', () => {
|
|
const { addLog, clearLogs } = useBrowserHandStore.getState();
|
|
addLog({ level: 'info', message: 'Test log' });
|
|
clearLogs();
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.logs).toHaveLength(0);
|
|
});
|
|
|
|
it('should limit log entries', () => {
|
|
const store = useBrowserHandStore.getState();
|
|
|
|
// Add more than max logs
|
|
for (let i = 0; i < 150; i++) {
|
|
store.addLog({ level: 'info', message: `Log ${i}` });
|
|
}
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.logs.length).toBeLessThanOrEqual(state.maxLogs);
|
|
});
|
|
});
|
|
|
|
describe('recent tasks', () => {
|
|
it('should add recent task', () => {
|
|
const { addRecentTask } = useBrowserHandStore.getState();
|
|
addRecentTask({
|
|
templateId: 'basic_navigate_screenshot',
|
|
templateName: '打开网页并截图',
|
|
params: { url: 'https://example.com' },
|
|
status: 'success',
|
|
duration: 5000,
|
|
});
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.recentTasks).toHaveLength(1);
|
|
expect(state.recentTasks[0].templateId).toBe('basic_navigate_screenshot');
|
|
});
|
|
|
|
it('should clear recent tasks', () => {
|
|
const { addRecentTask, clearRecentTasks } = useBrowserHandStore.getState();
|
|
addRecentTask({
|
|
templateId: 'test',
|
|
templateName: 'Test',
|
|
params: {},
|
|
status: 'success',
|
|
duration: 100,
|
|
});
|
|
clearRecentTasks();
|
|
|
|
const state = useBrowserHandStore.getState();
|
|
expect(state.recentTasks).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Template validation utilities', () => {
|
|
describe('validateTemplateParams', () => {
|
|
it('should validate required params', () => {
|
|
const params: TaskTemplateParam[] = [
|
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
|
{ key: 'name', label: 'Name', type: 'text', required: false },
|
|
];
|
|
|
|
// Missing required param
|
|
let result = validateTemplateParams(params, {});
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors).toHaveLength(1);
|
|
|
|
// All params provided
|
|
result = validateTemplateParams(params, { url: 'https://example.com' });
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should validate URL type', () => {
|
|
const params: TaskTemplateParam[] = [
|
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
|
];
|
|
|
|
// Invalid URL
|
|
let result = validateTemplateParams(params, { url: 'not-a-url' });
|
|
expect(result.valid).toBe(false);
|
|
|
|
// Valid URL
|
|
result = validateTemplateParams(params, { url: 'https://example.com' });
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should validate number type with min/max', () => {
|
|
const params: TaskTemplateParam[] = [
|
|
{ key: 'count', label: 'Count', type: 'number', required: true, min: 1, max: 10 },
|
|
];
|
|
|
|
// Below min
|
|
let result = validateTemplateParams(params, { count: 0 });
|
|
expect(result.valid).toBe(false);
|
|
|
|
// Above max
|
|
result = validateTemplateParams(params, { count: 20 });
|
|
expect(result.valid).toBe(false);
|
|
|
|
// Valid
|
|
result = validateTemplateParams(params, { count: 5 });
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('mergeParamsWithDefaults', () => {
|
|
it('should merge with default values', () => {
|
|
const params: TaskTemplateParam[] = [
|
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
|
{ key: 'timeout', label: 'Timeout', type: 'number', required: false, default: 5000 },
|
|
{ key: 'headless', label: 'Headless', type: 'boolean', required: false, default: true },
|
|
];
|
|
|
|
const merged = mergeParamsWithDefaults(params, { url: 'https://example.com' });
|
|
|
|
expect(merged.url).toBe('https://example.com');
|
|
expect(merged.timeout).toBe(5000);
|
|
expect(merged.headless).toBe(true);
|
|
});
|
|
|
|
it('should override defaults with provided values', () => {
|
|
const params: TaskTemplateParam[] = [
|
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
|
{ key: 'timeout', label: 'Timeout', type: 'number', required: false, default: 5000 },
|
|
];
|
|
|
|
const merged = mergeParamsWithDefaults(params, { url: 'https://example.com', timeout: 10000 });
|
|
|
|
expect(merged.url).toBe('https://example.com');
|
|
expect(merged.timeout).toBe(10000);
|
|
});
|
|
});
|
|
});
|