mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
Add end-to-end tests for workspace switching and backend tests for ask_question tool
- Implemented E2E tests for workspace switching functionality, covering scenarios such as switching workspaces, data isolation, language preference maintenance, and UI updates. - Added tests to ensure workspace data is cleared on logout and handles unsaved changes during workspace switches. - Created comprehensive backend tests for the ask_question tool, validating question creation, execution, answer handling, cancellation, and timeout scenarios. - Included edge case tests to ensure robustness against duplicate questions and invalid answers.
This commit is contained in:
467
ccw/frontend/src/packages/a2ui-runtime/README.md
Normal file
467
ccw/frontend/src/packages/a2ui-runtime/README.md
Normal file
@@ -0,0 +1,467 @@
|
||||
# A2UI Runtime
|
||||
|
||||
A lightweight, framework-agnostic runtime for rendering A2UI (AI-to-UI) surfaces in React applications.
|
||||
|
||||
## Overview
|
||||
|
||||
A2UI Runtime enables AI agents to generate and update interactive UI components dynamically through a structured protocol. Based on Google's A2UI specification, this runtime provides:
|
||||
|
||||
- **Dynamic Surface Rendering**: Create and update UI components in real-time
|
||||
- **Component Registry**: Extensible system for custom component renderers
|
||||
- **Type Safety**: Full TypeScript support with Zod validation
|
||||
- **State Management**: Built-in state binding and action handling
|
||||
- **Protocol Validation**: Parse and validate A2UI surface updates
|
||||
|
||||
## Installation
|
||||
|
||||
Located at `src/packages/a2ui-runtime/`, this runtime is included as part of the CCW frontend codebase.
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. A2UIParser
|
||||
|
||||
Parse and validate A2UI surface updates from JSON.
|
||||
|
||||
```typescript
|
||||
import { a2uiParser } from '@/packages/a2ui-runtime/core';
|
||||
|
||||
// Parse JSON string
|
||||
const surfaceUpdate = a2uiParser.parse(jsonString);
|
||||
|
||||
// Validate object
|
||||
const isValid = a2uiParser.validate(surfaceUpdate);
|
||||
|
||||
// Safe parse with result
|
||||
const result = a2uiParser.safeParse(jsonString);
|
||||
if (result.success) {
|
||||
console.log(result.data);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. A2UIComponentRegistry
|
||||
|
||||
Register and manage component renderers.
|
||||
|
||||
```typescript
|
||||
import { a2uiRegistry } from '@/packages/a2ui-runtime/core';
|
||||
import { CustomRenderer } from './renderers/CustomRenderer';
|
||||
|
||||
// Register a component
|
||||
a2uiRegistry.register('CustomComponent', CustomRenderer);
|
||||
|
||||
// Check if registered
|
||||
if (a2uiRegistry.has('CustomComponent')) {
|
||||
const renderer = a2uiRegistry.get('CustomComponent');
|
||||
}
|
||||
|
||||
// Get all registered types
|
||||
const types = a2uiRegistry.getRegisteredTypes();
|
||||
```
|
||||
|
||||
### 3. A2UIRenderer
|
||||
|
||||
Render A2UI surfaces as React components.
|
||||
|
||||
```typescript
|
||||
import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer';
|
||||
|
||||
function MyComponent() {
|
||||
const [surface, setSurface] = useState<SurfaceUpdate | null>(null);
|
||||
|
||||
const handleAction = async (actionId: string, params: Record<string, unknown>) => {
|
||||
console.log(`Action: ${actionId}`, params);
|
||||
// Send action back to AI agent
|
||||
};
|
||||
|
||||
return surface ? (
|
||||
<A2UIRenderer
|
||||
surface={surface}
|
||||
onAction={handleAction}
|
||||
className="my-surface"
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Components
|
||||
|
||||
### Standard Components
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Text` | `text`, `usageHint` | Text with semantic hint (h1-h6, p, span, code, small) |
|
||||
| `Button` | `onClick`, `content`, `variant`, `disabled` | Button with variants (primary, secondary, destructive, ghost, outline) |
|
||||
| `Dropdown` | `options`, `selectedValue`, `onChange`, `placeholder` | Select dropdown with options |
|
||||
| `TextField` | `value`, `onChange`, `placeholder`, `type` | Text input (text, email, password, number, url) |
|
||||
| `TextArea` | `value`, `onChange`, `placeholder`, `rows` | Multi-line text input |
|
||||
| `Checkbox` | `checked`, `onChange`, `label` | Checkbox with label |
|
||||
| `Progress` | `value`, `max` | Progress bar |
|
||||
| `Card` | `title`, `description`, `content` | Container with title and nested content |
|
||||
|
||||
### Custom Components
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `CLIOutput` | `output`, `language`, `streaming`, `maxLines` | CLI output with syntax highlighting |
|
||||
| `DateTimeInput` | `value`, `onChange`, `placeholder`, `includeTime` | Date/time picker input |
|
||||
|
||||
## A2UI Protocol
|
||||
|
||||
### Surface Update Structure
|
||||
|
||||
```typescript
|
||||
interface SurfaceUpdate {
|
||||
surfaceId: string;
|
||||
components: SurfaceComponent[];
|
||||
initialState?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SurfaceComponent {
|
||||
id: string;
|
||||
component: A2UIComponent;
|
||||
}
|
||||
```
|
||||
|
||||
### Component Types
|
||||
|
||||
All components use a discriminated union structure:
|
||||
|
||||
```typescript
|
||||
// Text Component
|
||||
{
|
||||
Text: {
|
||||
text: { literalString: "Hello" } | { path: "state.key" };
|
||||
usageHint?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "code" | "small";
|
||||
};
|
||||
}
|
||||
|
||||
// Button Component
|
||||
{
|
||||
Button: {
|
||||
onClick: { actionId: "submit", parameters?: {} };
|
||||
content: A2UIComponent; // Nested component (usually Text)
|
||||
variant?: "primary" | "secondary" | "destructive" | "ghost" | "outline";
|
||||
disabled?: { literalBoolean: true } | { path: "state.disabled" };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Content Bindings
|
||||
|
||||
Values can be literal or bound to state:
|
||||
|
||||
```typescript
|
||||
// Literal string
|
||||
{ literalString: "Hello, World!" }
|
||||
|
||||
// Bound to state
|
||||
{ path: "user.name" }
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
Actions are triggered by user interactions:
|
||||
|
||||
```typescript
|
||||
{
|
||||
actionId: "save-form",
|
||||
parameters: {
|
||||
formId: "contact-form",
|
||||
validate: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Creating Custom Components
|
||||
|
||||
### 1. Define the Renderer
|
||||
|
||||
```typescript
|
||||
import type { ComponentRenderer } from '@/packages/a2ui-runtime/core/A2UIComponentRegistry';
|
||||
import type { A2UIComponent } from '@/packages/a2ui-runtime/core/A2UITypes';
|
||||
|
||||
interface CustomComponentConfig {
|
||||
title: { literalString: string } | { path: string };
|
||||
items: Array<{ label: string; value: string }>;
|
||||
onSelect: { actionId: string; parameters?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
export const CustomRenderer: ComponentRenderer = ({
|
||||
component,
|
||||
state,
|
||||
onAction,
|
||||
resolveBinding
|
||||
}) => {
|
||||
const customComp = component as { Custom: CustomComponentConfig };
|
||||
const { Custom: config } = customComp;
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
onAction(config.onSelect.actionId, {
|
||||
...config.onSelect.parameters,
|
||||
selectedValue: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="custom-component">
|
||||
{/* Your rendering logic */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Register the Component
|
||||
|
||||
```typescript
|
||||
import { a2uiRegistry } from '@/packages/a2ui-runtime/core/A2UIComponentRegistry';
|
||||
import { CustomRenderer } from './CustomRenderer';
|
||||
|
||||
a2uiRegistry.register('Custom', CustomRenderer);
|
||||
```
|
||||
|
||||
### 3. Add Type Definition (Optional)
|
||||
|
||||
For type safety, extend the A2UI types:
|
||||
|
||||
```typescript
|
||||
// In A2UITypes.ts
|
||||
export const CustomComponentSchema = z.object({
|
||||
Custom: z.object({
|
||||
title: TextContentSchema,
|
||||
items: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
})),
|
||||
onSelect: ActionSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
// Add to ComponentSchema union
|
||||
export const ComponentSchema = z.union([
|
||||
// ... existing components
|
||||
CustomComponentSchema,
|
||||
]);
|
||||
|
||||
// Add to A2UIComponentType
|
||||
export type A2UIComponentType =
|
||||
| 'Text' | 'Button' | // ... existing types
|
||||
| 'Custom';
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Rendering a Simple Form
|
||||
|
||||
```typescript
|
||||
const formSurface: SurfaceUpdate = {
|
||||
surfaceId: 'contact-form',
|
||||
components: [
|
||||
{
|
||||
id: 'title',
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: 'Contact Form' },
|
||||
usageHint: 'h2',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
component: {
|
||||
TextField: {
|
||||
value: { literalString: '' },
|
||||
onChange: { actionId: 'update-name', parameters: { field: 'name' } },
|
||||
placeholder: 'Your Name',
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
component: {
|
||||
TextField: {
|
||||
value: { literalString: '' },
|
||||
onChange: { actionId: 'update-email', parameters: { field: 'email' } },
|
||||
placeholder: 'your@email.com',
|
||||
type: 'email',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'submit',
|
||||
component: {
|
||||
Button: {
|
||||
onClick: { actionId: 'submit-form' },
|
||||
content: { Text: { text: { literalString: 'Submit' } } },
|
||||
variant: 'primary',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
initialState: {
|
||||
name: '',
|
||||
email: '',
|
||||
},
|
||||
};
|
||||
|
||||
function ContactForm() {
|
||||
const [surface] = useState(formSurface);
|
||||
|
||||
const handleAction = async (actionId: string, params: any) => {
|
||||
if (actionId === 'submit-form') {
|
||||
// Submit logic
|
||||
} else {
|
||||
// Update local state
|
||||
console.log(`${actionId}:`, params);
|
||||
}
|
||||
};
|
||||
|
||||
return <A2UIRenderer surface={surface} onAction={handleAction} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Using CLIOutput Component
|
||||
|
||||
```typescript
|
||||
const cliOutputSurface: SurfaceUpdate = {
|
||||
surfaceId: 'build-output',
|
||||
components: [
|
||||
{
|
||||
id: 'output',
|
||||
component: {
|
||||
CLIOutput: {
|
||||
output: { literalString: '$ npm run build\nBuilding...\nDone!' },
|
||||
language: 'bash',
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Using DateTimeInput Component
|
||||
|
||||
```typescript
|
||||
const datetimeSurface: SurfaceUpdate = {
|
||||
surfaceId: 'appointment-picker',
|
||||
components: [
|
||||
{
|
||||
id: 'datetime',
|
||||
component: {
|
||||
DateTimeInput: {
|
||||
value: { literalString: '2024-01-15T10:30:00Z' },
|
||||
onChange: { actionId: 'update-date' },
|
||||
placeholder: 'Select appointment time',
|
||||
includeTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Binding Resolution
|
||||
|
||||
State is resolved through the `resolveBinding` function:
|
||||
|
||||
```typescript
|
||||
// In your component renderer
|
||||
const value = resolveBinding({ path: 'user.name' });
|
||||
```
|
||||
|
||||
### State Updates
|
||||
|
||||
Send state updates through actions:
|
||||
|
||||
```typescript
|
||||
const handleAction = (actionId: string, params: any) => {
|
||||
// Update local state
|
||||
setLocalState(prev => ({ ...prev, ...params }));
|
||||
|
||||
// Send action to backend
|
||||
sendA2UIAction(actionId, surfaceId, params);
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### A2UIParser
|
||||
|
||||
| Method | Parameters | Returns | Description |
|
||||
|--------|------------|---------|-------------|
|
||||
| `parse()` | `json: string` | `SurfaceUpdate` | Parse and validate JSON string |
|
||||
| `parseObject()` | `data: unknown` | `SurfaceUpdate` | Validate object |
|
||||
| `validate()` | `value: unknown` | `boolean` | Type guard check |
|
||||
| `safeParse()` | `json: string` | `SafeParseResult` | Parse without throwing |
|
||||
| `safeParseObject()` | `data: unknown` | `SafeParseResult` | Validate without throwing |
|
||||
| `validateComponent()` | `component: unknown` | `boolean` | Check if valid component |
|
||||
|
||||
### A2UIComponentRegistry
|
||||
|
||||
| Method | Parameters | Returns | Description |
|
||||
|--------|------------|---------|-------------|
|
||||
| `register()` | `type, renderer` | `void` | Register component renderer |
|
||||
| `unregister()` | `type` | `void` | Remove component renderer |
|
||||
| `get()` | `type` | `ComponentRenderer \| undefined` | Get renderer |
|
||||
| `has()` | `type` | `boolean` | Check if registered |
|
||||
| `getRegisteredTypes()` | - | `A2UIComponentType[]` | List all types |
|
||||
| `clear()` | - | `void` | Remove all renderers |
|
||||
| `size` | - | `number` | Count of renderers |
|
||||
|
||||
## Error Handling
|
||||
|
||||
### A2UIParseError
|
||||
|
||||
Custom error class for parsing failures:
|
||||
|
||||
```typescript
|
||||
import { A2UIParseError } from '@/packages/a2ui-runtime/core/A2UIParser';
|
||||
|
||||
try {
|
||||
a2uiParser.parse(invalidJson);
|
||||
} catch (error) {
|
||||
if (error instanceof A2UIParseError) {
|
||||
console.error(error.message);
|
||||
console.error(error.getDetails()); // Detailed Zod errors
|
||||
console.error(error.originalError);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always validate surfaces** before rendering:
|
||||
```typescript
|
||||
if (a2uiParser.validate(surfaceUpdate)) {
|
||||
return <A2UIRenderer surface={surfaceUpdate} onAction={handleAction} />;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Handle unknown components** gracefully:
|
||||
```typescript
|
||||
const renderer = a2uiRegistry.get(componentType);
|
||||
if (!renderer) {
|
||||
return <FallbackComponent />;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use TypeScript types** for type safety:
|
||||
```typescript
|
||||
import type { SurfaceUpdate, A2UIComponent } from '@/packages/a2ui-runtime/core/A2UITypes';
|
||||
```
|
||||
|
||||
4. **Clean up resources** when surfaces are removed:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup timers, subscriptions, etc.
|
||||
};
|
||||
}, [surfaceId]);
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Part of the CCW project.
|
||||
@@ -0,0 +1,296 @@
|
||||
// ========================================
|
||||
// A2UI Component Registry Unit Tests
|
||||
// ========================================
|
||||
// Tests for component registry operations
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { A2UIComponentRegistry, a2uiRegistry } from '../core/A2UIComponentRegistry';
|
||||
import type { A2UIComponent, A2UIState, ActionHandler, BindingResolver } from '../core/A2UIComponentRegistry';
|
||||
|
||||
// Import component renderers to trigger auto-registration
|
||||
import '../renderer/components';
|
||||
|
||||
// Mock component renderer
|
||||
const mockRenderer: any = vi.fn(() => null);
|
||||
const anotherMockRenderer: any = vi.fn(() => null);
|
||||
|
||||
describe('A2UIComponentRegistry', () => {
|
||||
let registry: A2UIComponentRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh registry for each test
|
||||
registry = new A2UIComponentRegistry();
|
||||
});
|
||||
|
||||
describe('register()', () => {
|
||||
it('should register a component renderer', () => {
|
||||
registry.register('TestComponent', mockRenderer);
|
||||
expect(registry.has('TestComponent')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow overriding existing renderer', () => {
|
||||
registry.register('TestComponent', mockRenderer);
|
||||
registry.register('TestComponent', anotherMockRenderer);
|
||||
|
||||
const retrieved = registry.get('TestComponent');
|
||||
expect(retrieved).toBe(anotherMockRenderer);
|
||||
});
|
||||
|
||||
it('should register multiple component types', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
registry.register('Button', mockRenderer);
|
||||
registry.register('Card', anotherMockRenderer);
|
||||
|
||||
expect(registry.has('Text')).toBe(true);
|
||||
expect(registry.has('Button')).toBe(true);
|
||||
expect(registry.has('Card')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregister()', () => {
|
||||
it('should remove a registered renderer', () => {
|
||||
registry.register('TestComponent', mockRenderer);
|
||||
expect(registry.has('TestComponent')).toBe(true);
|
||||
|
||||
registry.unregister('TestComponent');
|
||||
expect(registry.has('TestComponent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be idempotent for non-existent components', () => {
|
||||
expect(() => registry.unregister('NonExistent')).not.toThrow();
|
||||
expect(registry.has('NonExistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
it('should return registered renderer', () => {
|
||||
registry.register('TestComponent', mockRenderer);
|
||||
const retrieved = registry.get('TestComponent');
|
||||
|
||||
expect(retrieved).toBe(mockRenderer);
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered component', () => {
|
||||
const retrieved = registry.get('NonExistent');
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return correct renderer after multiple registrations', () => {
|
||||
registry.register('First', mockRenderer);
|
||||
registry.register('Second', anotherMockRenderer);
|
||||
|
||||
expect(registry.get('First')).toBe(mockRenderer);
|
||||
expect(registry.get('Second')).toBe(anotherMockRenderer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('has()', () => {
|
||||
it('should return true for registered components', () => {
|
||||
registry.register('TestComponent', mockRenderer);
|
||||
expect(registry.has('TestComponent')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unregistered components', () => {
|
||||
expect(registry.has('NonExistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false after unregistering', () => {
|
||||
registry.register('TestComponent', mockRenderer);
|
||||
expect(registry.has('TestComponent')).toBe(true);
|
||||
|
||||
registry.unregister('TestComponent');
|
||||
expect(registry.has('TestComponent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRegisteredTypes()', () => {
|
||||
it('should return empty array for new registry', () => {
|
||||
const types = registry.getRegisteredTypes();
|
||||
expect(types).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all registered component types', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
registry.register('Button', mockRenderer);
|
||||
registry.register('Card', anotherMockRenderer);
|
||||
|
||||
const types = registry.getRegisteredTypes();
|
||||
expect(types).toHaveLength(3);
|
||||
expect(types).toContain('Text');
|
||||
expect(types).toContain('Button');
|
||||
expect(types).toContain('Card');
|
||||
});
|
||||
|
||||
it('should update after unregister', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
registry.register('Button', mockRenderer);
|
||||
registry.register('Card', anotherMockRenderer);
|
||||
|
||||
registry.unregister('Button');
|
||||
const types = registry.getRegisteredTypes();
|
||||
|
||||
expect(types).toHaveLength(2);
|
||||
expect(types).not.toContain('Button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('should remove all registered renderers', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
registry.register('Button', mockRenderer);
|
||||
registry.register('Card', anotherMockRenderer);
|
||||
|
||||
registry.clear();
|
||||
|
||||
expect(registry.has('Text')).toBe(false);
|
||||
expect(registry.has('Button')).toBe(false);
|
||||
expect(registry.has('Card')).toBe(false);
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should be idempotent', () => {
|
||||
registry.register('Test', mockRenderer);
|
||||
registry.clear();
|
||||
expect(registry.size).toBe(0);
|
||||
|
||||
registry.clear(); // Clear again
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('size', () => {
|
||||
it('should return 0 for new registry', () => {
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should increment on registration', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
expect(registry.size).toBe(1);
|
||||
|
||||
registry.register('Button', mockRenderer);
|
||||
expect(registry.size).toBe(2);
|
||||
});
|
||||
|
||||
it('should not increment on duplicate registration', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
expect(registry.size).toBe(1);
|
||||
|
||||
registry.register('Text', anotherMockRenderer);
|
||||
expect(registry.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should decrement on unregistration', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
registry.register('Button', mockRenderer);
|
||||
expect(registry.size).toBe(2);
|
||||
|
||||
registry.unregister('Text');
|
||||
expect(registry.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should reset on clear', () => {
|
||||
registry.register('Text', mockRenderer);
|
||||
registry.register('Button', mockRenderer);
|
||||
expect(registry.size).toBe(2);
|
||||
|
||||
registry.clear();
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global a2uiRegistry', () => {
|
||||
it('should be a singleton instance', () => {
|
||||
expect(a2uiRegistry).toBeInstanceOf(A2UIComponentRegistry);
|
||||
});
|
||||
|
||||
it('should have built-in components registered', () => {
|
||||
// The global registry should have components registered by registry.ts
|
||||
const types = a2uiRegistry.getRegisteredTypes();
|
||||
|
||||
// At minimum, these should be registered
|
||||
expect(types.length).toBeGreaterThan(0);
|
||||
expect(a2uiRegistry.has('Text')).toBe(true);
|
||||
expect(a2uiRegistry.has('Button')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow custom component registration', () => {
|
||||
const customRenderer: any = vi.fn(() => null);
|
||||
const testType = 'TestCustomComponent' as any;
|
||||
|
||||
// Register custom component
|
||||
a2uiRegistry.register(testType, customRenderer);
|
||||
expect(a2uiRegistry.has(testType)).toBe(true);
|
||||
|
||||
// Clean up
|
||||
a2uiRegistry.unregister(testType);
|
||||
expect(a2uiRegistry.has(testType)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Renderer Interface', () => {
|
||||
it('should accept all required parameters', () => {
|
||||
const mockComponent: A2UIComponent = {
|
||||
Text: { text: { literalString: 'Test' } },
|
||||
};
|
||||
|
||||
const mockState: A2UIState = { key: 'value' };
|
||||
const mockOnAction: ActionHandler = vi.fn();
|
||||
const mockResolveBinding: BindingResolver = vi.fn(() => 'resolved');
|
||||
|
||||
const renderer: any = (props: {
|
||||
component: A2UIComponent;
|
||||
state: A2UIState;
|
||||
onAction: ActionHandler;
|
||||
resolveBinding: BindingResolver;
|
||||
}) => {
|
||||
expect(props.component).toBeDefined();
|
||||
expect(props.state).toBeDefined();
|
||||
expect(props.onAction).toBeDefined();
|
||||
expect(props.resolveBinding).toBeDefined();
|
||||
return null;
|
||||
};
|
||||
|
||||
renderer({
|
||||
component: mockComponent,
|
||||
state: mockState,
|
||||
onAction: mockOnAction,
|
||||
resolveBinding: mockResolveBinding,
|
||||
});
|
||||
});
|
||||
|
||||
it('should support async action handlers', async () => {
|
||||
const asyncAction: ActionHandler = async (actionId, params) => {
|
||||
await Promise.resolve();
|
||||
return;
|
||||
};
|
||||
|
||||
const mockComponent: A2UIComponent = {
|
||||
Button: {
|
||||
onClick: { actionId: 'click' },
|
||||
content: { Text: { text: { literalString: 'Click' } } },
|
||||
},
|
||||
};
|
||||
|
||||
const mockState: A2UIState = {};
|
||||
const mockResolveBinding: BindingResolver = vi.fn();
|
||||
|
||||
const renderer: any = async (props: {
|
||||
component: A2UIComponent;
|
||||
state: A2UIState;
|
||||
onAction: ActionHandler;
|
||||
resolveBinding: BindingResolver;
|
||||
}) => {
|
||||
// Should not throw with async handler
|
||||
await props.onAction('test-action', {});
|
||||
return null;
|
||||
};
|
||||
|
||||
await renderer({
|
||||
component: mockComponent,
|
||||
state: mockState,
|
||||
onAction: asyncAction,
|
||||
resolveBinding: mockResolveBinding,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
// ========================================
|
||||
// A2UI Parser Unit Tests
|
||||
// ========================================
|
||||
// Tests for A2UI protocol parsing and validation
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { A2UIParser, a2uiParser, A2UIParseError } from '../core/A2UIParser';
|
||||
import type { SurfaceUpdate, A2UIComponent } from '../core/A2UITypes';
|
||||
|
||||
// Import component renderers to trigger auto-registration
|
||||
import '../renderer/components';
|
||||
|
||||
describe('A2UIParser', () => {
|
||||
describe('parse()', () => {
|
||||
it('should parse valid surface update JSON', () => {
|
||||
const validJson = JSON.stringify({
|
||||
surfaceId: 'test-surface',
|
||||
components: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: 'Hello, World!' },
|
||||
usageHint: 'h1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
initialState: { key: 'value' },
|
||||
});
|
||||
|
||||
const result = a2uiParser.parse(validJson);
|
||||
|
||||
expect(result).toEqual({
|
||||
surfaceId: 'test-surface',
|
||||
components: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
component: {
|
||||
Text: {
|
||||
text: { literalString: 'Hello, World!' },
|
||||
usageHint: 'h1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
initialState: { key: 'value' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw A2UIParseError on invalid JSON', () => {
|
||||
const invalidJson = '{ invalid json }';
|
||||
|
||||
expect(() => a2uiParser.parse(invalidJson)).toThrow(A2UIParseError);
|
||||
expect(() => a2uiParser.parse(invalidJson)).toThrow('Invalid JSON');
|
||||
});
|
||||
|
||||
it('should throw A2UIParseError on validation failure', () => {
|
||||
const invalidSchema = JSON.stringify({
|
||||
surfaceId: 'test',
|
||||
// Missing required components array
|
||||
});
|
||||
|
||||
expect(() => a2uiParser.parse(invalidSchema)).toThrow(A2UIParseError);
|
||||
expect(() => a2uiParser.parse(invalidSchema)).toThrow('A2UI validation failed');
|
||||
});
|
||||
|
||||
it('should parse surface with all component types', () => {
|
||||
const complexJson = JSON.stringify({
|
||||
surfaceId: 'complex-surface',
|
||||
components: [
|
||||
{
|
||||
id: 'text-1',
|
||||
component: { Text: { text: { literalString: 'Text' } } },
|
||||
},
|
||||
{
|
||||
id: 'button-1',
|
||||
component: {
|
||||
Button: {
|
||||
onClick: { actionId: 'click' },
|
||||
content: { Text: { text: { literalString: 'Click me' } } },
|
||||
variant: 'primary',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dropdown-1',
|
||||
component: {
|
||||
Dropdown: {
|
||||
options: [
|
||||
{ label: { literalString: 'Option 1' }, value: 'opt1' },
|
||||
{ label: { literalString: 'Option 2' }, value: 'opt2' },
|
||||
],
|
||||
onChange: { actionId: 'change' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'textfield-1',
|
||||
component: {
|
||||
TextField: {
|
||||
value: { literalString: 'input' },
|
||||
onChange: { actionId: 'input' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'textarea-1',
|
||||
component: {
|
||||
TextArea: {
|
||||
onChange: { actionId: 'textarea' },
|
||||
rows: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'checkbox-1',
|
||||
component: {
|
||||
Checkbox: {
|
||||
checked: { literalBoolean: true },
|
||||
onChange: { actionId: 'check' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'codeblock-1',
|
||||
component: {
|
||||
CodeBlock: {
|
||||
code: { literalString: 'console.log("hello");' },
|
||||
language: 'javascript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'progress-1',
|
||||
component: {
|
||||
Progress: {
|
||||
value: { literalNumber: 50 },
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'card-1',
|
||||
component: {
|
||||
Card: {
|
||||
title: { literalString: 'Card Title' },
|
||||
content: [
|
||||
{ Text: { text: { literalString: 'Card content' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clioutput-1',
|
||||
component: {
|
||||
CLIOutput: {
|
||||
output: { literalString: 'Command output' },
|
||||
language: 'bash',
|
||||
streaming: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'datetime-1',
|
||||
component: {
|
||||
DateTimeInput: {
|
||||
onChange: { actionId: 'datetime' },
|
||||
includeTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = a2uiParser.parse(complexJson);
|
||||
expect(result.components).toHaveLength(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseObject()', () => {
|
||||
it('should validate and return surface update object', () => {
|
||||
const validObject = {
|
||||
surfaceId: 'test-surface',
|
||||
components: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
component: {
|
||||
Text: { text: { literalString: 'Test' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = a2uiParser.parseObject(validObject);
|
||||
expect(result).toEqual(validObject);
|
||||
});
|
||||
|
||||
it('should throw A2UIParseError on invalid object', () => {
|
||||
const invalidObject = { surfaceId: 'test' }; // Missing components
|
||||
|
||||
expect(() => a2uiParser.parseObject(invalidObject)).toThrow(A2UIParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate()', () => {
|
||||
it('should return true for valid surface update', () => {
|
||||
const validUpdate = {
|
||||
surfaceId: 'test',
|
||||
components: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
component: { Text: { text: { literalString: 'Test' } } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(a2uiParser.validate(validUpdate)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid surface update', () => {
|
||||
const invalidUpdate = { surfaceId: 'test' };
|
||||
|
||||
expect(a2uiParser.validate(invalidUpdate)).toBe(false);
|
||||
});
|
||||
|
||||
it('should work as type guard', () => {
|
||||
const unknownValue: unknown = {
|
||||
surfaceId: 'test',
|
||||
components: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
component: { Text: { text: { literalString: 'Test' } } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (a2uiParser.validate(unknownValue)) {
|
||||
// TypeScript should know this is SurfaceUpdate
|
||||
expect(unknownValue.surfaceId).toBe('test');
|
||||
expect(unknownValue.components).toBeDefined();
|
||||
} else {
|
||||
expect.fail('Should have validated successfully');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeParse()', () => {
|
||||
it('should return success with data for valid JSON', () => {
|
||||
const validJson = JSON.stringify({
|
||||
surfaceId: 'test',
|
||||
components: [{ id: '1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
});
|
||||
|
||||
const result = a2uiParser.safeParse(validJson);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.surfaceId).toBe('test');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error for invalid JSON', () => {
|
||||
const invalidJson = '{ invalid }';
|
||||
|
||||
const result = a2uiParser.safeParse(invalidJson);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeParseObject()', () => {
|
||||
it('should return success with data for valid object', () => {
|
||||
const validObject = {
|
||||
surfaceId: 'test',
|
||||
components: [{ id: '1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
};
|
||||
|
||||
const result = a2uiParser.safeParseObject(validObject);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.surfaceId).toBe('test');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error for invalid object', () => {
|
||||
const invalidObject = { invalid: 'object' };
|
||||
|
||||
const result = a2uiParser.safeParseObject(invalidObject);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateComponent()', () => {
|
||||
it('should return true for valid Text component', () => {
|
||||
const validComponent = { Text: { text: { literalString: 'Hello' } } };
|
||||
|
||||
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid Button component', () => {
|
||||
const validComponent = {
|
||||
Button: {
|
||||
onClick: { actionId: 'click' },
|
||||
content: { Text: { text: { literalString: 'Click' } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid CLIOutput component', () => {
|
||||
const validComponent = {
|
||||
CLIOutput: {
|
||||
output: { literalString: 'Output' },
|
||||
language: 'bash',
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid DateTimeInput component', () => {
|
||||
const validComponent = {
|
||||
DateTimeInput: {
|
||||
onChange: { actionId: 'change' },
|
||||
includeTime: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid component', () => {
|
||||
const invalidComponent = { InvalidComponent: {} };
|
||||
|
||||
expect(a2uiParser.validateComponent(invalidComponent)).toBe(false);
|
||||
});
|
||||
|
||||
it('should work as type guard', () => {
|
||||
const unknownValue: unknown = { Text: { text: { literalString: 'Test' } } };
|
||||
|
||||
if (a2uiParser.validateComponent(unknownValue)) {
|
||||
// TypeScript should know this is A2UIComponent
|
||||
expect('Text' in unknownValue).toBe(true);
|
||||
} else {
|
||||
expect.fail('Should have validated successfully');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UIParseError', () => {
|
||||
it('should have correct name', () => {
|
||||
const error = new A2UIParseError('Test error');
|
||||
expect(error.name).toBe('A2UIParseError');
|
||||
});
|
||||
|
||||
it('should store original error', () => {
|
||||
const originalError = new Error('Original');
|
||||
const parseError = new A2UIParseError('Parse failed', originalError);
|
||||
|
||||
expect(parseError.originalError).toBe(originalError);
|
||||
});
|
||||
|
||||
it('should provide details for Zod errors', () => {
|
||||
// Create a mock ZodError
|
||||
const mockZodError = {
|
||||
issues: [
|
||||
{ path: ['components', 0, 'id'], message: 'Required' },
|
||||
{ path: ['surfaceId'], message: 'Invalid format' },
|
||||
],
|
||||
};
|
||||
|
||||
const parseError = new A2UIParseError('Validation failed', mockZodError as any);
|
||||
const details = parseError.getDetails();
|
||||
|
||||
expect(details).toContain('components.0.id: Required');
|
||||
expect(details).toContain('surfaceId: Invalid format');
|
||||
});
|
||||
|
||||
it('should provide message for Error original errors', () => {
|
||||
const originalError = new Error('Something went wrong');
|
||||
const parseError = new A2UIParseError('Parse failed', originalError);
|
||||
const details = parseError.getDetails();
|
||||
|
||||
expect(details).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should return basic message for unknown error types', () => {
|
||||
const parseError = new A2UIParseError('Unknown error', 'string error');
|
||||
const details = parseError.getDetails();
|
||||
|
||||
expect(details).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,636 @@
|
||||
// ========================================
|
||||
// A2UI Component Renderer Unit Tests
|
||||
// ========================================
|
||||
// Tests for all A2UI component renderers
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { A2UIComponent } from '../core/A2UITypes';
|
||||
import type { A2UIState, ActionHandler, BindingResolver } from '../core/A2UIComponentRegistry';
|
||||
import type { TextComponent, ButtonComponent, DropdownComponent, CLIOutputComponent, DateTimeInputComponent } from '../core/A2UITypes';
|
||||
|
||||
// Import component renderers to trigger auto-registration
|
||||
import '../renderer/components';
|
||||
|
||||
// Import component renderers
|
||||
import { A2UIText } from '../renderer/components/A2UIText';
|
||||
import { A2UIButton } from '../renderer/components/A2UIButton';
|
||||
import { A2UIDropdown } from '../renderer/components/A2UIDropdown';
|
||||
import { A2UITextField } from '../renderer/components/A2UITextField';
|
||||
import { A2UITextArea } from '../renderer/components/A2UITextArea';
|
||||
import { A2UICheckbox } from '../renderer/components/A2UICheckbox';
|
||||
import { A2UIProgress } from '../renderer/components/A2UIProgress';
|
||||
import { A2UICard } from '../renderer/components/A2UICard';
|
||||
import { A2UICLIOutput } from '../renderer/components/A2UICLIOutput';
|
||||
import { A2UIDateTimeInput } from '../renderer/components/A2UIDateTimeInput';
|
||||
|
||||
// Common test helpers
|
||||
function createMockProps(component: A2UIComponent) {
|
||||
const mockState: A2UIState = {};
|
||||
const mockOnAction: ActionHandler = vi.fn();
|
||||
const mockResolveBinding: BindingResolver = vi.fn((binding) => {
|
||||
if (binding.path === 'test.value') return 'resolved-value';
|
||||
return binding.path;
|
||||
});
|
||||
|
||||
return {
|
||||
component,
|
||||
state: mockState,
|
||||
onAction: mockOnAction,
|
||||
resolveBinding: mockResolveBinding,
|
||||
};
|
||||
}
|
||||
|
||||
// Wrapper for testing renderer components
|
||||
function RendererWrapper({ children }: { children: React.ReactNode }) {
|
||||
return <div data-testid="renderer-wrapper">{children}</div>;
|
||||
}
|
||||
|
||||
describe('A2UI Component Renderers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('A2UIText', () => {
|
||||
it('should render text with literal string', () => {
|
||||
const component: TextComponent = {
|
||||
Text: { text: { literalString: 'Hello, World!' } },
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIText(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render different usage hints', () => {
|
||||
const hints: Array<'h1' | 'h2' | 'p' | 'code' | 'span'> = ['h1', 'h2', 'p', 'code', 'span'];
|
||||
|
||||
hints.forEach((hint) => {
|
||||
const component: TextComponent = {
|
||||
Text: { text: { literalString: 'Test' }, usageHint: hint },
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIText(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve binding for text content', () => {
|
||||
const component: TextComponent = {
|
||||
Text: { text: { path: 'test.value' } },
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIText(props);
|
||||
expect(result).toBeTruthy();
|
||||
expect(props.resolveBinding).toHaveBeenCalledWith({ path: 'test.value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UIButton', () => {
|
||||
it('should render button with text content', () => {
|
||||
const component: ButtonComponent = {
|
||||
Button: {
|
||||
onClick: { actionId: 'click-action' },
|
||||
content: { Text: { text: { literalString: 'Click Me' } } },
|
||||
variant: 'primary',
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
render(<RendererWrapper>{A2UIButton(props)}</RendererWrapper>);
|
||||
expect(screen.getByText('Click Me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onAction when clicked', () => {
|
||||
const component: ButtonComponent = {
|
||||
Button: {
|
||||
onClick: { actionId: 'test-action', parameters: { key: 'value' } },
|
||||
content: { Text: { text: { literalString: 'Test' } } },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIButton(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render different variants', () => {
|
||||
const variants: Array<'primary' | 'secondary' | 'destructive' | 'ghost' | 'outline'> = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'destructive',
|
||||
'ghost',
|
||||
'outline',
|
||||
];
|
||||
|
||||
variants.forEach((variant) => {
|
||||
const component: ButtonComponent = {
|
||||
Button: {
|
||||
onClick: { actionId: 'click' },
|
||||
content: { Text: { text: { literalString: variant } } },
|
||||
variant,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIButton(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should be disabled when specified', () => {
|
||||
const component: ButtonComponent = {
|
||||
Button: {
|
||||
onClick: { actionId: 'click' },
|
||||
content: { Text: { text: { literalString: 'Disabled' } } },
|
||||
disabled: { literalBoolean: true },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIButton(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UIDropdown', () => {
|
||||
it('should render dropdown with options', () => {
|
||||
const component: DropdownComponent = {
|
||||
Dropdown: {
|
||||
options: [
|
||||
{ label: { literalString: 'Option 1' }, value: 'opt1' },
|
||||
{ label: { literalString: 'Option 2' }, value: 'opt2' },
|
||||
{ label: { literalString: 'Option 3' }, value: 'opt3' },
|
||||
],
|
||||
onChange: { actionId: 'change' },
|
||||
placeholder: 'Select an option',
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDropdown(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with selected value', () => {
|
||||
const component: DropdownComponent = {
|
||||
Dropdown: {
|
||||
options: [
|
||||
{ label: { literalString: 'A' }, value: 'a' },
|
||||
{ label: { literalString: 'B' }, value: 'b' },
|
||||
],
|
||||
selectedValue: { literalString: 'a' },
|
||||
onChange: { actionId: 'change' },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDropdown(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call onChange with actionId when selection changes', () => {
|
||||
const component: DropdownComponent = {
|
||||
Dropdown: {
|
||||
options: [{ label: { literalString: 'Test' }, value: 'test' }],
|
||||
onChange: { actionId: 'select-action' },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDropdown(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UITextField', () => {
|
||||
it('should render text input', () => {
|
||||
const component = {
|
||||
TextField: {
|
||||
value: { literalString: 'initial value' },
|
||||
onChange: { actionId: 'input' },
|
||||
placeholder: 'Enter text',
|
||||
type: 'text' as const,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UITextField(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render different input types', () => {
|
||||
const types: Array<'text' | 'email' | 'password' | 'number' | 'url'> = [
|
||||
'text',
|
||||
'email',
|
||||
'password',
|
||||
'number',
|
||||
'url',
|
||||
];
|
||||
|
||||
types.forEach((type) => {
|
||||
const component = {
|
||||
TextField: { onChange: { actionId: 'input' }, type },
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UITextField(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChange when value changes', () => {
|
||||
const component = {
|
||||
TextField: {
|
||||
value: { literalString: 'test' },
|
||||
onChange: { actionId: 'text-change' },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UITextField(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UITextArea', () => {
|
||||
it('should render textarea', () => {
|
||||
const component = {
|
||||
TextArea: {
|
||||
value: { literalString: 'Multi-line text' },
|
||||
onChange: { actionId: 'textarea-change' },
|
||||
rows: 5,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UITextArea(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with custom rows', () => {
|
||||
const component = {
|
||||
TextArea: {
|
||||
onChange: { actionId: 'change' },
|
||||
rows: 10,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UITextArea(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with placeholder', () => {
|
||||
const component = {
|
||||
TextArea: {
|
||||
onChange: { actionId: 'change' },
|
||||
placeholder: 'Enter description',
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UITextArea(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UICheckbox', () => {
|
||||
it('should render checkbox unchecked', () => {
|
||||
const component = {
|
||||
Checkbox: {
|
||||
checked: { literalBoolean: false },
|
||||
onChange: { actionId: 'check' },
|
||||
label: { literalString: 'Accept terms' },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICheckbox(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render checkbox checked', () => {
|
||||
const component = {
|
||||
Checkbox: {
|
||||
checked: { literalBoolean: true },
|
||||
onChange: { actionId: 'check' },
|
||||
label: { literalString: 'Checked' },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICheckbox(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const component = {
|
||||
Checkbox: {
|
||||
checked: { literalBoolean: false },
|
||||
onChange: { actionId: 'toggle' },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICheckbox(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UIProgress', () => {
|
||||
it('should render progress bar with value', () => {
|
||||
const component = {
|
||||
Progress: {
|
||||
value: { literalNumber: 50 },
|
||||
max: 100,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIProgress(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render indeterminate progress', () => {
|
||||
const component = {
|
||||
Progress: {},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIProgress(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle max value', () => {
|
||||
const component = {
|
||||
Progress: {
|
||||
value: { literalNumber: 75 },
|
||||
max: 200,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIProgress(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UICard', () => {
|
||||
it('should render card with title and content', () => {
|
||||
const component = {
|
||||
Card: {
|
||||
title: { literalString: 'Card Title' },
|
||||
description: { literalString: 'Card description' },
|
||||
content: [
|
||||
{ Text: { text: { literalString: 'Content 1' } } },
|
||||
{ Text: { text: { literalString: 'Content 2' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
render(<RendererWrapper>{A2UICard(props)}</RendererWrapper>);
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render card without title', () => {
|
||||
const component = {
|
||||
Card: {
|
||||
content: [{ Text: { text: { literalString: 'Content' } } }],
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICard(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render nested content', () => {
|
||||
const component = {
|
||||
Card: {
|
||||
title: { literalString: 'Nested' },
|
||||
content: [
|
||||
{
|
||||
Card: {
|
||||
title: { literalString: 'Inner Card' },
|
||||
content: [{ Text: { text: { literalString: 'Inner content' } } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICard(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UICLIOutput', () => {
|
||||
it('should render CLI output with syntax highlighting', () => {
|
||||
const component: CLIOutputComponent = {
|
||||
CLIOutput: {
|
||||
output: { literalString: '$ echo "Hello"\nHello\n' },
|
||||
language: 'bash',
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICLIOutput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with streaming indicator', () => {
|
||||
const component: CLIOutputComponent = {
|
||||
CLIOutput: {
|
||||
output: { literalString: 'Streaming output...' },
|
||||
language: 'bash',
|
||||
streaming: true,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
render(<RendererWrapper>{A2UICLIOutput(props)}</RendererWrapper>);
|
||||
// Should show streaming indicator
|
||||
expect(screen.getByText(/Streaming/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render different languages', () => {
|
||||
const languages = ['bash', 'javascript', 'python'];
|
||||
|
||||
languages.forEach((lang) => {
|
||||
const component: CLIOutputComponent = {
|
||||
CLIOutput: {
|
||||
output: { literalString: `Code in ${lang}` },
|
||||
language: lang,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICLIOutput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should truncate output when maxLines is set', () => {
|
||||
const component: CLIOutputComponent = {
|
||||
CLIOutput: {
|
||||
output: { literalString: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5' },
|
||||
language: 'bash',
|
||||
maxLines: 3,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICLIOutput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render empty output', () => {
|
||||
const component: CLIOutputComponent = {
|
||||
CLIOutput: {
|
||||
output: { literalString: '' },
|
||||
language: 'bash',
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
render(<RendererWrapper>{A2UICLIOutput(props)}</RendererWrapper>);
|
||||
expect(screen.getByText(/No output/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should highlight bash error patterns', () => {
|
||||
const component: CLIOutputComponent = {
|
||||
CLIOutput: {
|
||||
output: { literalString: '$ command\nError: command failed' },
|
||||
language: 'bash',
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UICLIOutput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UIDateTimeInput', () => {
|
||||
it('should render datetime input', () => {
|
||||
const component: DateTimeInputComponent = {
|
||||
DateTimeInput: {
|
||||
value: { literalString: '2024-01-15T10:30:00Z' },
|
||||
onChange: { actionId: 'datetime-change' },
|
||||
includeTime: true,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDateTimeInput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render date-only input', () => {
|
||||
const component: DateTimeInputComponent = {
|
||||
DateTimeInput: {
|
||||
onChange: { actionId: 'date-change' },
|
||||
includeTime: false,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDateTimeInput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call onChange when value changes', () => {
|
||||
const component: DateTimeInputComponent = {
|
||||
DateTimeInput: {
|
||||
onChange: { actionId: 'datetime-action', parameters: { field: 'date' } },
|
||||
includeTime: true,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDateTimeInput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should respect min and max date constraints', () => {
|
||||
const component: DateTimeInputComponent = {
|
||||
DateTimeInput: {
|
||||
onChange: { actionId: 'change' },
|
||||
minDate: { literalString: '2024-01-01T00:00:00Z' },
|
||||
maxDate: { literalString: '2024-12-31T23:59:59Z' },
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDateTimeInput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with placeholder', () => {
|
||||
const component: DateTimeInputComponent = {
|
||||
DateTimeInput: {
|
||||
onChange: { actionId: 'change' },
|
||||
placeholder: 'Select appointment time',
|
||||
includeTime: true,
|
||||
},
|
||||
};
|
||||
const props = createMockProps(component);
|
||||
|
||||
const result = A2UIDateTimeInput(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UI Component Integration', () => {
|
||||
it('should handle binding resolution across components', () => {
|
||||
const mockResolveBinding: BindingResolver = vi.fn((binding) => {
|
||||
if (binding.path === 'user.name') return 'Test User';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const textComponent: TextComponent = {
|
||||
Text: { text: { path: 'user.name' } },
|
||||
};
|
||||
|
||||
const props = {
|
||||
component: textComponent,
|
||||
state: {},
|
||||
onAction: vi.fn(),
|
||||
resolveBinding: mockResolveBinding,
|
||||
};
|
||||
|
||||
const result = A2UIText(props);
|
||||
expect(result).toBeTruthy();
|
||||
expect(mockResolveBinding).toHaveBeenCalledWith({ path: 'user.name' });
|
||||
});
|
||||
|
||||
it('should handle async action handlers', async () => {
|
||||
const asyncOnAction: ActionHandler = async (actionId, params) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
};
|
||||
|
||||
const buttonComponent: ButtonComponent = {
|
||||
Button: {
|
||||
onClick: { actionId: 'async-action' },
|
||||
content: { Text: { text: { literalString: 'Async' } } },
|
||||
},
|
||||
};
|
||||
|
||||
const props = {
|
||||
component: buttonComponent,
|
||||
state: {},
|
||||
onAction: asyncOnAction,
|
||||
resolveBinding: vi.fn(),
|
||||
};
|
||||
|
||||
const result = A2UIButton(props);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -111,8 +111,8 @@ export class A2UIParser {
|
||||
return SurfaceUpdateSchema.safeParse(data);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
success: false as const,
|
||||
error: error as z.ZodError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,20 +129,31 @@ export const CardComponentSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// ========== Component Union ==========
|
||||
/** CLIOutput component - for streaming CLI output with syntax highlighting */
|
||||
export const CLIOutputComponentSchema = z.object({
|
||||
CLIOutput: z.object({
|
||||
output: TextContentSchema,
|
||||
language: z.string().optional(),
|
||||
streaming: z.boolean().optional(),
|
||||
maxLines: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** DateTimeInput component - for date/time selection */
|
||||
export const DateTimeInputComponentSchema = z.object({
|
||||
DateTimeInput: z.object({
|
||||
value: TextContentSchema.optional(),
|
||||
onChange: ActionSchema,
|
||||
placeholder: z.string().optional(),
|
||||
minDate: TextContentSchema.optional(),
|
||||
maxDate: TextContentSchema.optional(),
|
||||
includeTime: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// ========== Component Union ==========
|
||||
/** All A2UI component types */
|
||||
export const ComponentSchema: z.ZodType<
|
||||
| z.infer<typeof TextComponentSchema>
|
||||
| z.infer<typeof ButtonComponentSchema>
|
||||
| z.infer<typeof DropdownComponentSchema>
|
||||
| z.infer<typeof TextFieldComponentSchema>
|
||||
| z.infer<typeof TextAreaComponentSchema>
|
||||
| z.infer<typeof CheckboxComponentSchema>
|
||||
| z.infer<typeof CodeBlockComponentSchema>
|
||||
| z.infer<typeof ProgressComponentSchema>
|
||||
| z.infer<typeof CardComponentSchema>
|
||||
> = z.union([
|
||||
export const ComponentSchema: z.ZodType<any> = z.union([
|
||||
TextComponentSchema,
|
||||
ButtonComponentSchema,
|
||||
DropdownComponentSchema,
|
||||
@@ -152,6 +163,8 @@ export const ComponentSchema: z.ZodType<
|
||||
CodeBlockComponentSchema,
|
||||
ProgressComponentSchema,
|
||||
CardComponentSchema,
|
||||
CLIOutputComponentSchema,
|
||||
DateTimeInputComponentSchema,
|
||||
]);
|
||||
|
||||
// ========== Surface Schemas ==========
|
||||
@@ -187,6 +200,8 @@ export type CheckboxComponent = z.infer<typeof CheckboxComponentSchema>;
|
||||
export type CodeBlockComponent = z.infer<typeof CodeBlockComponentSchema>;
|
||||
export type ProgressComponent = z.infer<typeof ProgressComponentSchema>;
|
||||
export type CardComponent = z.infer<typeof CardComponentSchema>;
|
||||
export type CLIOutputComponent = z.infer<typeof CLIOutputComponentSchema>;
|
||||
export type DateTimeInputComponent = z.infer<typeof DateTimeInputComponentSchema>;
|
||||
|
||||
export type A2UIComponent = z.infer<typeof ComponentSchema>;
|
||||
export type SurfaceComponent = z.infer<typeof SurfaceComponentSchema>;
|
||||
@@ -204,7 +219,9 @@ export type A2UIComponentType =
|
||||
| 'Checkbox'
|
||||
| 'CodeBlock'
|
||||
| 'Progress'
|
||||
| 'Card';
|
||||
| 'Card'
|
||||
| 'CLIOutput'
|
||||
| 'DateTimeInput';
|
||||
|
||||
/** Get component type from discriminated union */
|
||||
export function getComponentType(component: A2UIComponent): A2UIComponentType {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// ========================================
|
||||
// A2UI CLIOutput Component Renderer
|
||||
// ========================================
|
||||
// Displays CLI output with syntax highlighting and streaming indicator
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
|
||||
import { resolveTextContent } from '../A2UIRenderer';
|
||||
import type { CLIOutputComponent } from '../../core/A2UITypes';
|
||||
|
||||
/**
|
||||
* Lightweight syntax highlighter for CLI output
|
||||
* Uses simple regex-based highlighting for common patterns
|
||||
*/
|
||||
function highlightSyntax(output: string, language?: string): React.ReactNode {
|
||||
const lines = output.split('\n');
|
||||
|
||||
// Define syntax patterns by language
|
||||
const patterns: Record<string, RegExp[]> = {
|
||||
bash: [
|
||||
{ regex: /^(\$|>|\s)(\s*)/gm, className: 'text-muted-foreground' }, // Prompt
|
||||
{ regex: /\b(error|fail|failed|failure)\b/gi, className: 'text-destructive font-semibold' },
|
||||
{ regex: /\b(warn|warning)\b/gi, className: 'text-yellow-500 font-semibold' },
|
||||
{ regex: /\b(success|done|completed|passed)\b/gi, className: 'text-green-500 font-semibold' },
|
||||
{ regex: /\b(info|notice|note)\b/gi, className: 'text-blue-400' },
|
||||
{ regex: /--?[\w-]+/g, className: 'text-purple-400' }, // Flags
|
||||
{ regex: /'[^']*'|"[^"]*"/g, className: 'text-green-400' }, // Strings
|
||||
],
|
||||
javascript: [
|
||||
{ regex: /\b(const|let|var|function|return|if|else|for|while|import|export|from)\b/g, className: 'text-purple-400' },
|
||||
{ regex: /\b(true|false|null|undefined)\b/g, className: 'text-blue-400' },
|
||||
{ regex: /\/\/.*$/gm, className: 'text-muted-foreground italic' }, // Comments
|
||||
{ regex: /'[^']*'|"[^"]*"|`[^`]*`/g, className: 'text-green-400' }, // Strings
|
||||
{ regex: /\b(console|document|window)\b/g, className: 'text-yellow-400' },
|
||||
],
|
||||
python: [
|
||||
{ regex: /\b(def|class|if|else|elif|for|while|return|import|from|as|try|except|with)\b/g, className: 'text-purple-400' },
|
||||
{ regex: /\b(True|False|None)\b/g, className: 'text-blue-400' },
|
||||
{ regex: /#.*/g, className: 'text-muted-foreground italic' }, // Comments
|
||||
{ regex: /'[^']*'|"[^"]*"/g, className: 'text-green-400' }, // Strings
|
||||
{ regex: /\b(print|len|range|str|int|float|list|dict)\b/g, className: 'text-yellow-400' },
|
||||
],
|
||||
};
|
||||
|
||||
const defaultPatterns = patterns.bash;
|
||||
const langPatterns = patterns[language || ''] || defaultPatterns;
|
||||
|
||||
// Apply highlighting to a single line
|
||||
const highlightLine = (line: string): React.ReactNode => {
|
||||
if (!line) return '\n';
|
||||
|
||||
// Split by regex matches and wrap in spans
|
||||
let result: React.ReactNode = line;
|
||||
let key = 0;
|
||||
|
||||
for (const pattern of langPatterns) {
|
||||
if (typeof result === 'string') {
|
||||
const parts = result.split(pattern.regex);
|
||||
result = parts.map((part, i) => {
|
||||
if (pattern.regex.test(part)) {
|
||||
return (
|
||||
<span key={`${key}-${i}`} className={pattern.className}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
key++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-words">
|
||||
{highlightLine(line)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming indicator animation
|
||||
*/
|
||||
function StreamingIndicator() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 ml-2">
|
||||
<span className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||
<span className="w-2 h-2 bg-primary rounded-full animate-pulse delay-75" />
|
||||
<span className="w-2 h-2 bg-primary rounded-full animate-pulse delay-150" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI CLIOutput Component Renderer
|
||||
* Displays CLI output with optional syntax highlighting and streaming indicator
|
||||
*/
|
||||
export const A2UICLIOutput: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
|
||||
const cliOutputComp = component as CLIOutputComponent;
|
||||
const { CLIOutput: config } = cliOutputComp;
|
||||
|
||||
// Resolve output content
|
||||
const output = resolveTextContent(config.output, resolveBinding) || '';
|
||||
const language = config.language || 'bash';
|
||||
const streaming = config.streaming ?? false;
|
||||
const maxLines = config.maxLines;
|
||||
|
||||
// Local state for scroll management
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (!isPaused && isAtBottom && streaming) {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [output, isPaused, isAtBottom, streaming]);
|
||||
|
||||
// Handle scroll to detect if user is at bottom
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setIsAtBottom(atBottom);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setIsAtBottom(true);
|
||||
}, []);
|
||||
|
||||
// Truncate output if maxLines is set
|
||||
const displayOutput = maxLines
|
||||
? output.split('\n').slice(-maxLines).join('\n')
|
||||
: output;
|
||||
|
||||
return (
|
||||
<div className="a2ui-cli-output relative group">
|
||||
{/* Output container with scroll */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="bg-muted/50 rounded-md p-3 font-mono text-sm overflow-y-auto max-h-[400px] border"
|
||||
>
|
||||
{displayOutput ? (
|
||||
<div className="min-h-[50px]">
|
||||
{highlightSyntax(String(displayOutput), language)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic">No output</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
{/* Controls overlay - visible on hover or when not at bottom */}
|
||||
<div className={cn(
|
||||
"absolute top-2 right-2 flex gap-1 transition-opacity",
|
||||
isAtBottom ? "opacity-0 group-hover:opacity-100" : "opacity-100"
|
||||
)}>
|
||||
{/* Streaming indicator */}
|
||||
{streaming && !isPaused && (
|
||||
<div className="bg-background/90 backdrop-blur rounded px-2 py-1 text-xs flex items-center">
|
||||
<span className="text-muted-foreground mr-1">Streaming</span>
|
||||
<StreamingIndicator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pause/Resume button */}
|
||||
{streaming && (
|
||||
<button
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
className="bg-background/90 backdrop-blur rounded px-2 py-1 text-xs hover:bg-background transition-colors border"
|
||||
title={isPaused ? "Resume scrolling" : "Pause scrolling"}
|
||||
>
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="bg-background/90 backdrop-blur rounded px-2 py-1 text-xs hover:bg-background transition-colors border"
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
v
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language indicator */}
|
||||
{language && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Language: {language}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Utility function for className merging
|
||||
function cn(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// ========================================
|
||||
// A2UI DateTimeInput Component Renderer
|
||||
// ========================================
|
||||
// Date/time picker with ISO string format support
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
|
||||
import { resolveLiteralOrBinding, resolveTextContent } from '../A2UIRenderer';
|
||||
import type { DateTimeInputComponent } from '../../core/A2UITypes';
|
||||
|
||||
/**
|
||||
* Convert ISO string to datetime-local input format (YYYY-MM-DDTHH:mm)
|
||||
*/
|
||||
function isoToDateTimeLocal(isoString: string): string {
|
||||
if (!isoString) return '';
|
||||
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
// Format: YYYY-MM-DDTHH:mm
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert datetime-local input format to ISO string
|
||||
*/
|
||||
function dateTimeLocalToIso(dateTimeLocal: string, includeTime: boolean): string {
|
||||
if (!dateTimeLocal) return '';
|
||||
|
||||
const date = new Date(dateTimeLocal);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI DateTimeInput Component Renderer
|
||||
* Uses native input[type="datetime-local"] or input[type="date"] based on includeTime
|
||||
*/
|
||||
export const A2UIDateTimeInput: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
|
||||
const dateTimeComp = component as DateTimeInputComponent;
|
||||
const { DateTimeInput: config } = dateTimeComp;
|
||||
|
||||
// Resolve initial value
|
||||
const getInitialValue = (): string => {
|
||||
if (!config.value) return '';
|
||||
const resolved = resolveTextContent(config.value, resolveBinding);
|
||||
return resolved || '';
|
||||
};
|
||||
|
||||
const [internalValue, setInternalValue] = useState(getInitialValue);
|
||||
const includeTime = config.includeTime ?? true;
|
||||
|
||||
// Update internal value when binding changes
|
||||
useEffect(() => {
|
||||
setInternalValue(getInitialValue());
|
||||
}, [config.value]);
|
||||
|
||||
// Resolve min/max date constraints
|
||||
const minDate = config.minDate ? resolveTextContent(config.minDate, resolveBinding) : undefined;
|
||||
const maxDate = config.maxDate ? resolveTextContent(config.maxDate, resolveBinding) : undefined;
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInternalValue(newValue);
|
||||
|
||||
// Convert to ISO string and trigger action
|
||||
const isoValue = dateTimeLocalToIso(newValue, includeTime);
|
||||
onAction(config.onChange.actionId, {
|
||||
...config.onChange.parameters,
|
||||
value: isoValue,
|
||||
});
|
||||
}, [onAction, config.onChange, includeTime]);
|
||||
|
||||
const inputType = includeTime ? 'datetime-local' : 'date';
|
||||
const inputMin = minDate ? isoToDateTimeLocal(String(minDate)) : undefined;
|
||||
const inputMax = maxDate ? isoToDateTimeLocal(String(maxDate)) : undefined;
|
||||
const inputValue = internalValue ? isoToDateTimeLocal(String(internalValue)) : '';
|
||||
|
||||
return (
|
||||
<div className="a2ui-datetime-input">
|
||||
<input
|
||||
type={inputType}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
placeholder={config.placeholder || (includeTime ? 'Select date and time' : 'Select date')}
|
||||
min={inputMin}
|
||||
max={inputMax}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
|
||||
"ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2",
|
||||
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Utility function for className merging
|
||||
function cn(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// ========================================
|
||||
// A2UI Component Renderers Index
|
||||
// ========================================
|
||||
// Exports all A2UI component renderers
|
||||
|
||||
export * from './registry';
|
||||
@@ -0,0 +1,46 @@
|
||||
// ========================================
|
||||
// A2UI Component Renderers Index
|
||||
// ========================================
|
||||
// Exports all A2UI component renderers
|
||||
// Importing this file automatically registers all components
|
||||
|
||||
import { a2uiRegistry } from '../../core/A2UIComponentRegistry';
|
||||
import type { A2UIComponentType } from '../../core/A2UITypes';
|
||||
|
||||
// Import all component renderers synchronously
|
||||
import { A2UIText } from './A2UIText';
|
||||
import { A2UIButton } from './A2UIButton';
|
||||
import { A2UIDropdown } from './A2UIDropdown';
|
||||
import { A2UITextField } from './A2UITextField';
|
||||
import { A2UITextArea } from './A2UITextArea';
|
||||
import { A2UICheckbox } from './A2UICheckbox';
|
||||
import { A2UIProgress } from './A2UIProgress';
|
||||
import { A2UICard } from './A2UICard';
|
||||
import { A2UICLIOutput } from './A2UICLIOutput';
|
||||
import { A2UIDateTimeInput } from './A2UIDateTimeInput';
|
||||
|
||||
// Synchronous auto-registration of all built-in components
|
||||
// This runs immediately when the module is loaded
|
||||
a2uiRegistry.register('Text' as A2UIComponentType, A2UIText);
|
||||
a2uiRegistry.register('Button' as A2UIComponentType, A2UIButton);
|
||||
a2uiRegistry.register('Dropdown' as A2UIComponentType, A2UIDropdown);
|
||||
a2uiRegistry.register('TextField' as A2UIComponentType, A2UITextField);
|
||||
a2uiRegistry.register('TextArea' as A2UIComponentType, A2UITextArea);
|
||||
a2uiRegistry.register('Checkbox' as A2UIComponentType, A2UICheckbox);
|
||||
a2uiRegistry.register('Progress' as A2UIComponentType, A2UIProgress);
|
||||
a2uiRegistry.register('Card' as A2UIComponentType, A2UICard);
|
||||
a2uiRegistry.register('CLIOutput' as A2UIComponentType, A2UICLIOutput);
|
||||
a2uiRegistry.register('DateTimeInput' as A2UIComponentType, A2UIDateTimeInput);
|
||||
|
||||
// Export all components
|
||||
export * from './A2UIText';
|
||||
export * from './A2UIButton';
|
||||
export * from './A2UIDropdown';
|
||||
export * from './A2UITextField';
|
||||
export * from './A2UITextArea';
|
||||
export * from './A2UICheckbox';
|
||||
export * from './A2UIProgress';
|
||||
export * from './A2UICard';
|
||||
export * from './A2UICLIOutput';
|
||||
export * from './A2UIDateTimeInput';
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// ========================================
|
||||
// A2UI Component Registry Initialization
|
||||
// ========================================
|
||||
// Registers all A2UI component renderers
|
||||
|
||||
import { a2uiRegistry } from '../../core/A2UIComponentRegistry';
|
||||
import { A2UIText } from './A2UIText';
|
||||
import { A2UIButton } from './A2UIButton';
|
||||
import { A2UIDropdown } from './A2UIDropdown';
|
||||
import { A2UITextField } from './A2UITextField';
|
||||
import { A2UITextArea } from './A2UITextArea';
|
||||
import { A2UICheckbox } from './A2UICheckbox';
|
||||
import { A2UIProgress } from './A2UIProgress';
|
||||
import { A2UICard } from './A2UICard';
|
||||
|
||||
/**
|
||||
* Initialize and register all built-in A2UI component renderers
|
||||
*/
|
||||
export function registerBuiltInComponents(): void {
|
||||
// Register all component types
|
||||
a2uiRegistry.register('Text', A2UIText);
|
||||
a2uiRegistry.register('Button', A2UIButton);
|
||||
a2uiRegistry.register('Dropdown', A2UIDropdown);
|
||||
a2uiRegistry.register('TextField', A2UITextField);
|
||||
a2uiRegistry.register('TextArea', A2UITextArea);
|
||||
a2uiRegistry.register('Checkbox', A2UICheckbox);
|
||||
a2uiRegistry.register('Progress', A2UIProgress);
|
||||
a2uiRegistry.register('Card', A2UICard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-initialize on import
|
||||
* This ensures all components are registered when the renderer package is loaded
|
||||
*/
|
||||
registerBuiltInComponents();
|
||||
|
||||
export * from './A2UIText';
|
||||
export * from './A2UIButton';
|
||||
export * from './A2UIDropdown';
|
||||
export * from './A2UITextField';
|
||||
export * from './A2UITextArea';
|
||||
export * from './A2UICheckbox';
|
||||
export * from './A2UIProgress';
|
||||
export * from './A2UICard';
|
||||
@@ -3,3 +3,6 @@
|
||||
// ========================================
|
||||
|
||||
export * from './A2UIRenderer';
|
||||
|
||||
// Import component renderers to trigger auto-registration
|
||||
import './components';
|
||||
|
||||
Reference in New Issue
Block a user