refactor: extract parseA2UIBlocks to lib/a2ui.ts; remove unused import re
- Extract parseA2UIBlocks() from A2UIRenderer.tsx into its own frontend/src/lib/a2ui.ts module to satisfy react-refresh/only-export- components ESLint rule (A2UIRenderer should only export React components) - Update ChatPanel.tsx and test files to import from lib/a2ui - Split ChatPanel.test.tsx mock: separate vi.mock for lib/a2ui (parseA2UIBlocks) and A2UIRenderer (component only) - Remove unused 'import re' from tests/test_runtime_html.py (ruff F401) All 216 frontend tests and 242 bash tests still pass. TypeScript and Vite builds are clean. Remaining ESLint warnings (react-hooks/set-state- in-effect in MCPAppRenderer and useSessions) are pre-existing patterns not introduced by Phase 3.
This commit is contained in:
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import { IconSend } from '@tabler/icons-react'
|
import { IconSend } from '@tabler/icons-react'
|
||||||
import { ToolCallCard } from './ToolCallCard'
|
import { ToolCallCard } from './ToolCallCard'
|
||||||
import { MCPAppRenderer } from './MCPAppRenderer'
|
import { MCPAppRenderer } from './MCPAppRenderer'
|
||||||
import { A2UIRenderer, parseA2UIBlocks } from './A2UIRenderer'
|
import { A2UIRenderer } from './A2UIRenderer'
|
||||||
|
import { parseA2UIBlocks } from '../lib/a2ui'
|
||||||
import type { ChatMessage } from '../hooks/useChat'
|
import type { ChatMessage } from '../hooks/useChat'
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
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'
|
import type { A2UIBlock } from '../lib/types'
|
||||||
|
|
||||||
// Mock @tabler/icons-react so icon SVGs don't cause jsdom issues
|
// Mock @tabler/icons-react so icon SVGs don't cause jsdom issues
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ vi.mock('../components/MCPAppRenderer', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock A2UIRenderer module (component + parseA2UIBlocks)
|
// Mock lib/a2ui parseA2UIBlocks parser
|
||||||
vi.mock('../components/A2UIRenderer', () => ({
|
vi.mock('../lib/a2ui', () => ({
|
||||||
parseA2UIBlocks: vi.fn((content: string) => {
|
parseA2UIBlocks: vi.fn((content: string) => {
|
||||||
const blocks: Array<Record<string, unknown>> = []
|
const blocks: Array<Record<string, unknown>> = []
|
||||||
const regex = /```a2ui\s*\n([\s\S]*?)```/g
|
const regex = /```a2ui\s*\n([\s\S]*?)```/g
|
||||||
@@ -48,6 +48,10 @@ vi.mock('../components/A2UIRenderer', () => ({
|
|||||||
}
|
}
|
||||||
return blocks
|
return blocks
|
||||||
}),
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock A2UIRenderer component
|
||||||
|
vi.mock('../components/A2UIRenderer', () => ({
|
||||||
A2UIRenderer: ({
|
A2UIRenderer: ({
|
||||||
block,
|
block,
|
||||||
onAction,
|
onAction,
|
||||||
|
|||||||
+47
-13
@@ -7,7 +7,7 @@ as specified in task-5.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
HTML_FILE = os.path.join(
|
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}"
|
assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}"
|
||||||
|
|
||||||
def test_is_complete_html_document(self, html_content):
|
def test_is_complete_html_document(self, html_content):
|
||||||
assert "<!DOCTYPE html>" in html_content or "<!doctype html>" in html_content.lower()
|
assert (
|
||||||
|
"<!DOCTYPE html>" in html_content
|
||||||
|
or "<!doctype html>" in html_content.lower()
|
||||||
|
)
|
||||||
assert "<html" in html_content
|
assert "<html" in html_content
|
||||||
assert "</html>" in html_content
|
assert "</html>" in html_content
|
||||||
|
|
||||||
@@ -40,21 +43,32 @@ class TestCDNLibraries:
|
|||||||
"""Verify all required CDN libraries are present in the head section."""
|
"""Verify all required CDN libraries are present in the head section."""
|
||||||
|
|
||||||
def test_tabler_css_cdn(self, html_content):
|
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):
|
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):
|
def test_chartjs_cdn(self, html_content):
|
||||||
assert "https://cdn.jsdelivr.net/npm/chart.js@4" in html_content
|
assert "https://cdn.jsdelivr.net/npm/chart.js@4" in html_content
|
||||||
|
|
||||||
def test_leaflet_js_cdn(self, 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):
|
def test_react_19_umd(self, html_content):
|
||||||
# React 19 production UMD
|
# React 19 production UMD
|
||||||
assert "react@19" in html_content
|
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):
|
def test_react_dom_19_umd(self, html_content):
|
||||||
# ReactDOM 19 production UMD
|
# ReactDOM 19 production UMD
|
||||||
@@ -73,7 +87,9 @@ class TestStyles:
|
|||||||
assert "transparent" in html_content
|
assert "transparent" in html_content
|
||||||
|
|
||||||
def test_dark_color_scheme(self, 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):
|
def test_body_padding_16px(self, html_content):
|
||||||
assert "16px" in html_content
|
assert "16px" in html_content
|
||||||
@@ -83,7 +99,11 @@ class TestStyles:
|
|||||||
|
|
||||||
def test_visual_error_red_color(self, html_content):
|
def test_visual_error_red_color(self, html_content):
|
||||||
# red text color for error class
|
# 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):
|
def test_visual_error_monospace(self, html_content):
|
||||||
assert "monospace" in html_content
|
assert "monospace" in html_content
|
||||||
@@ -118,7 +138,11 @@ class TestMcpBridge:
|
|||||||
|
|
||||||
def test_call_tool_jsonrpc_20(self, html_content):
|
def test_call_tool_jsonrpc_20(self, html_content):
|
||||||
# jsonrpc key may appear as unquoted JS property (jsonrpc:) or quoted ("jsonrpc" / 'jsonrpc')
|
# 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
|
assert '"2.0"' in html_content or "'2.0'" in html_content
|
||||||
|
|
||||||
def test_call_tool_method_name(self, 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):
|
def test_call_tool_uses_uuid(self, html_content):
|
||||||
# Random UUID for call id
|
# 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):
|
def test_send_message_method(self, html_content):
|
||||||
assert "sendMessage" in html_content
|
assert "sendMessage" in html_content
|
||||||
@@ -169,7 +197,7 @@ class TestExecuteVisual:
|
|||||||
|
|
||||||
def test_jsx_detection_type_text_babel(self, html_content):
|
def test_jsx_detection_type_text_babel(self, html_content):
|
||||||
# The regex pattern in JS source: /type="text\/babel"/ contains text\/babel
|
# 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):
|
def test_jsx_path_uses_babel_transform(self, html_content):
|
||||||
assert "Babel.transform" in html_content
|
assert "Babel.transform" in html_content
|
||||||
@@ -200,7 +228,11 @@ class TestExecuteVisual:
|
|||||||
assert "new Function" in html_content
|
assert "new Function" in html_content
|
||||||
|
|
||||||
def test_plain_js_passes_chart_arg(self, 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):
|
def test_plain_js_passes_leaflet_arg(self, html_content):
|
||||||
assert '"L"' in html_content or "'L'" in 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
|
assert "1.0.0" in html_content
|
||||||
|
|
||||||
def test_handshake_uses_send_to_host(self, 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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user