diff --git a/frontend/src/components/A2UIRenderer.tsx b/frontend/src/components/A2UIRenderer.tsx index dfdcd7d..c93a076 100644 --- a/frontend/src/components/A2UIRenderer.tsx +++ b/frontend/src/components/A2UIRenderer.tsx @@ -230,23 +230,4 @@ export function A2UIRenderer({ block, onAction }: A2UIRendererProps) { } } -// ─── parseA2UIBlocks ───────────────────────────────────────────────────────── -export function parseA2UIBlocks(content: string): A2UIBlock[] { - const blocks: A2UIBlock[] = [] - const regex = /```a2ui\s*\n([\s\S]*?)```/g - let match: RegExpExecArray | null - - while ((match = regex.exec(content)) !== null) { - try { - const parsed = JSON.parse(match[1]) - if (parsed && parsed.type) { - blocks.push(parsed as A2UIBlock) - } - } catch { - // Silently skip invalid JSON - } - } - - return blocks -} diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx index 4063d6d..e0be0f1 100644 --- a/frontend/src/components/ChatPanel.tsx +++ b/frontend/src/components/ChatPanel.tsx @@ -4,7 +4,8 @@ import ReactMarkdown from 'react-markdown' import { IconSend } from '@tabler/icons-react' import { ToolCallCard } from './ToolCallCard' import { MCPAppRenderer } from './MCPAppRenderer' -import { A2UIRenderer, parseA2UIBlocks } from './A2UIRenderer' +import { A2UIRenderer } from './A2UIRenderer' +import { parseA2UIBlocks } from '../lib/a2ui' import type { ChatMessage } from '../hooks/useChat' interface ChatPanelProps { diff --git a/frontend/src/lib/a2ui.ts b/frontend/src/lib/a2ui.ts new file mode 100644 index 0000000..dfbb2fa --- /dev/null +++ b/frontend/src/lib/a2ui.ts @@ -0,0 +1,25 @@ +import type { A2UIBlock } from './types' + +/** + * Parses `a2ui` fenced code blocks out of a markdown-like content string. + * Each block must contain a valid JSON object with a `type` field. + * Invalid blocks are silently skipped. + */ +export function parseA2UIBlocks(content: string): A2UIBlock[] { + const blocks: A2UIBlock[] = [] + const regex = /```a2ui\s*\n([\s\S]*?)```/g + let match: RegExpExecArray | null + + while ((match = regex.exec(content)) !== null) { + try { + const parsed = JSON.parse(match[1]) + if (parsed && parsed.type) { + blocks.push(parsed as A2UIBlock) + } + } catch { + // Silently skip invalid JSON + } + } + + return blocks +} diff --git a/frontend/src/test/A2UIRenderer.test.tsx b/frontend/src/test/A2UIRenderer.test.tsx index 146103f..25418d5 100644 --- a/frontend/src/test/A2UIRenderer.test.tsx +++ b/frontend/src/test/A2UIRenderer.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { A2UIRenderer, parseA2UIBlocks } from '../components/A2UIRenderer' +import { A2UIRenderer } from '../components/A2UIRenderer' +import { parseA2UIBlocks } from '../lib/a2ui' import type { A2UIBlock } from '../lib/types' // Mock @tabler/icons-react so icon SVGs don't cause jsdom issues diff --git a/frontend/src/test/ChatPanel.test.tsx b/frontend/src/test/ChatPanel.test.tsx index b6509b7..69a525d 100644 --- a/frontend/src/test/ChatPanel.test.tsx +++ b/frontend/src/test/ChatPanel.test.tsx @@ -32,8 +32,8 @@ vi.mock('../components/MCPAppRenderer', () => ({ ), })) -// Mock A2UIRenderer module (component + parseA2UIBlocks) -vi.mock('../components/A2UIRenderer', () => ({ +// Mock lib/a2ui parseA2UIBlocks parser +vi.mock('../lib/a2ui', () => ({ parseA2UIBlocks: vi.fn((content: string) => { const blocks: Array> = [] const regex = /```a2ui\s*\n([\s\S]*?)```/g @@ -48,6 +48,10 @@ vi.mock('../components/A2UIRenderer', () => ({ } return blocks }), +})) + +// Mock A2UIRenderer component +vi.mock('../components/A2UIRenderer', () => ({ A2UIRenderer: ({ block, onAction, diff --git a/tests/test_runtime_html.py b/tests/test_runtime_html.py index 4074145..7f5f1d2 100644 --- a/tests/test_runtime_html.py +++ b/tests/test_runtime_html.py @@ -7,7 +7,7 @@ as specified in task-5. """ import os -import re + import pytest HTML_FILE = os.path.join( @@ -31,7 +31,10 @@ class TestFileExists: assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}" def test_is_complete_html_document(self, html_content): - assert "" in html_content or "" in html_content.lower() + assert ( + "" in html_content + or "" in html_content.lower() + ) assert "" in html_content @@ -40,21 +43,32 @@ class TestCDNLibraries: """Verify all required CDN libraries are present in the head section.""" def test_tabler_css_cdn(self, html_content): - assert "https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" in html_content + assert ( + "https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" + in html_content + ) def test_leaflet_css_cdn(self, html_content): - assert "https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" in html_content + assert ( + "https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" in html_content + ) def test_chartjs_cdn(self, html_content): assert "https://cdn.jsdelivr.net/npm/chart.js@4" in html_content def test_leaflet_js_cdn(self, html_content): - assert "https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js" in html_content + assert ( + "https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js" in html_content + ) def test_react_19_umd(self, html_content): # React 19 production UMD assert "react@19" in html_content - assert "umd" in html_content.lower() or "unpkg.com" in html_content or "cdn.jsdelivr.net" in html_content + assert ( + "umd" in html_content.lower() + or "unpkg.com" in html_content + or "cdn.jsdelivr.net" in html_content + ) def test_react_dom_19_umd(self, html_content): # ReactDOM 19 production UMD @@ -73,7 +87,9 @@ class TestStyles: assert "transparent" in html_content def test_dark_color_scheme(self, html_content): - assert "color-scheme: dark" in html_content or "color-scheme:dark" in html_content + assert ( + "color-scheme: dark" in html_content or "color-scheme:dark" in html_content + ) def test_body_padding_16px(self, html_content): assert "16px" in html_content @@ -83,7 +99,11 @@ class TestStyles: def test_visual_error_red_color(self, html_content): # red text color for error class - assert "#ff" in html_content.lower() or "red" in html_content or "color: #" in html_content + assert ( + "#ff" in html_content.lower() + or "red" in html_content + or "color: #" in html_content + ) def test_visual_error_monospace(self, html_content): assert "monospace" in html_content @@ -118,7 +138,11 @@ class TestMcpBridge: def test_call_tool_jsonrpc_20(self, html_content): # jsonrpc key may appear as unquoted JS property (jsonrpc:) or quoted ("jsonrpc" / 'jsonrpc') - assert '"jsonrpc"' in html_content or "'jsonrpc'" in html_content or "jsonrpc:" in html_content + assert ( + '"jsonrpc"' in html_content + or "'jsonrpc'" in html_content + or "jsonrpc:" in html_content + ) assert '"2.0"' in html_content or "'2.0'" in html_content def test_call_tool_method_name(self, html_content): @@ -129,7 +153,11 @@ class TestMcpBridge: def test_call_tool_uses_uuid(self, html_content): # Random UUID for call id - assert "crypto" in html_content or "Math.random" in html_content or "uuid" in html_content.lower() + assert ( + "crypto" in html_content + or "Math.random" in html_content + or "uuid" in html_content.lower() + ) def test_send_message_method(self, html_content): assert "sendMessage" in html_content @@ -169,7 +197,7 @@ class TestExecuteVisual: def test_jsx_detection_type_text_babel(self, html_content): # The regex pattern in JS source: /type="text\/babel"/ contains text\/babel - assert 'text/babel' in html_content or r'text\/babel' in html_content + assert "text/babel" in html_content or r"text\/babel" in html_content def test_jsx_path_uses_babel_transform(self, html_content): assert "Babel.transform" in html_content @@ -200,7 +228,11 @@ class TestExecuteVisual: assert "new Function" in html_content def test_plain_js_passes_chart_arg(self, html_content): - assert '"Chart"' in html_content or "'Chart'" in html_content or "Chart" in html_content + assert ( + '"Chart"' in html_content + or "'Chart'" in html_content + or "Chart" in html_content + ) def test_plain_js_passes_leaflet_arg(self, html_content): assert '"L"' in html_content or "'L'" in html_content @@ -270,4 +302,6 @@ class TestInitialHandshake: assert "1.0.0" in html_content def test_handshake_uses_send_to_host(self, html_content): - assert "sendToHost" in html_content or "window.parent.postMessage" in html_content + assert ( + "sendToHost" in html_content or "window.parent.postMessage" in html_content + )