feat(browser-hand): implement Browser Hand UI components
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>
This commit is contained in:
273
tests/desktop/browserHandStore.test.ts
Normal file
273
tests/desktop/browserHandStore.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user