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:
iven
2026-03-17 08:56:02 +08:00
parent 69c874ed59
commit 6bd9b841aa
13 changed files with 3729 additions and 9 deletions

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