diff --git a/examples/embed_demo.html b/examples/embed_demo.html
new file mode 100644
index 000000000..23192e719
--- /dev/null
+++ b/examples/embed_demo.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+ Preswald Component Embedding Demo
+
+
+
+ Preswald Component Embedding Demo
+
+ This page demonstrates how to embed Preswald components in external web pages.
+
+
+
Embedding a Specific Component
+
Below is an embedded card component with ID embeddable_card:
+
+
+
+
+
+
+
HTML code:
+
<iframe
+ src="http://localhost:8000/embed?component_id=embeddable_card"
+ width="100%"
+ height="200"
+ frameborder="0">
+</iframe>
+
+
+
+
Embedding a Plot Component
+
Below is an embedded plot component with ID sample_plot:
+
+
+
+
+
+
+
HTML code:
+
<iframe
+ src="http://localhost:8000/embed?component_id=sample_plot"
+ width="100%"
+ height="350"
+ frameborder="0">
+</iframe>
+
+
+
+
Embedding the Entire App
+
You can also embed the entire application:
+
+
+
+
+
+
+
HTML code:
+
<iframe
+ src="http://localhost:8000/embed"
+ width="100%"
+ height="500"
+ frameborder="0">
+</iframe>
+
+
+ Note: Make sure the Preswald server is running with the embed_example.py script before viewing this page.
+
+
\ No newline at end of file
diff --git a/examples/embed_example.py b/examples/embed_example.py
new file mode 100644
index 000000000..d98651227
--- /dev/null
+++ b/examples/embed_example.py
@@ -0,0 +1,37 @@
+"""
+Example script demonstrating component embedding
+"""
+import preswald as pw
+
+# Create some components with unique IDs
+pw.header("Embed Example", level=1, id="main_header")
+pw.text("This is a sample application with multiple components.", id="intro_text")
+
+# Create a component specifically for embedding
+with pw.card(id="embeddable_card", title="Embeddable Component"):
+ pw.text("This component can be embedded on its own.", id="card_text")
+ pw.button("Click Me", id="embed_button")
+
+# Create another component
+pw.plotly({
+ "data": [{"y": [1, 2, 3, 4], "type": "scatter"}],
+ "layout": {"title": "Sample Plot"}
+}, id="sample_plot")
+
+# Add instructions for embedding
+pw.markdown("""
+## How to Embed
+
+To embed the card component, use the following HTML code:
+
+```html
+
+```
+
+This will only show the card component, not the rest of the application.
+""", id="embed_instructions")
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index fae9a0950..5495de557 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -5,6 +5,7 @@ import { BrowserRouter as Router } from 'react-router-dom';
import Layout from './components/Layout';
import LoadingState from './components/LoadingState';
import Dashboard from './components/pages/Dashboard';
+import EmbedView from './components/pages/EmbedView';
import { comm } from './utils/websocket';
const App = () => {
@@ -13,8 +14,14 @@ const App = () => {
const [config, setConfig] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const [areComponentsLoading, setAreComponentsLoading] = useState(true);
+ const [isEmbedMode, setIsEmbedMode] = useState(false);
useEffect(() => {
+ // Check if in embed mode (either from URL or from window.EMBED_CONFIG)
+ const isEmbed = window.location.pathname.startsWith('/embed') ||
+ (window.EMBED_CONFIG && window.EMBED_CONFIG.embed_mode);
+ setIsEmbedMode(isEmbed);
+
comm.connect();
const unsubscribe = comm.subscribe(handleMessage);
@@ -147,8 +154,12 @@ const App = () => {
setError(message.connected ? null : 'Lost connection. Attempting to reconnect...');
};
- console.log('[App] Rendering with:', { components, isConnected, error });
- console.log(window.location.pathname);
+ console.log('[App] Rendering with:', { components, isConnected, error, isEmbedMode });
+
+ // If in embed mode, render the EmbedView directly without Layout
+ if (isEmbedMode) {
+ return ;
+ }
return (
diff --git a/frontend/src/components.css b/frontend/src/components.css
index f9ef52706..136483708 100644
--- a/frontend/src/components.css
+++ b/frontend/src/components.css
@@ -848,3 +848,57 @@
background-color: #4b5563;
}
}
+
+/* Embeddable Components Styles */
+.embed-container {
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ width: 100%;
+ height: 100%;
+ min-height: 50px;
+ margin: 0;
+ padding: 0;
+ overflow: auto;
+ background-color: transparent;
+}
+
+.embed-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100px;
+ color: #333;
+ font-size: 1rem;
+}
+
+.embed-error {
+ background-color: #fff0f0;
+ border: 1px solid #ffcccc;
+ border-radius: 6px;
+ padding: 12px;
+ margin: 12px 0;
+ color: #cc0000;
+}
+
+.embed-error h3 {
+ margin-top: 0;
+ margin-bottom: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.embed-error p {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+/* When in embed mode, apply tighter spacing */
+.embed-container .preswald-component {
+ margin-bottom: 12px;
+}
+
+/* Hide certain UI elements in embed mode */
+.embed-container .preswald-debug-info,
+.embed-container .preswald-toolbar {
+ display: none;
+}
diff --git a/frontend/src/components/pages/EmbedView.jsx b/frontend/src/components/pages/EmbedView.jsx
new file mode 100644
index 000000000..8e5a4219d
--- /dev/null
+++ b/frontend/src/components/pages/EmbedView.jsx
@@ -0,0 +1,169 @@
+import React, { useEffect, useState, useRef } from 'react';
+import DynamicComponents from '../DynamicComponents';
+
+/**
+ * EmbedView component for embedded app or single component view.
+ * This provides a minimal layout without navigation, toolbars, etc.
+ */
+const EmbedView = () => {
+ const [componentData, setComponentData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const containerRef = useRef(null);
+
+ // Auto-resize handler for iframes
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ // Create a ResizeObserver to detect size changes
+ const resizeObserver = new ResizeObserver(() => {
+ // Send message to parent window with new height
+ if (window.parent !== window) {
+ window.parent.postMessage({
+ height: containerRef.current.scrollHeight,
+ type: 'preswald-resize'
+ }, '*');
+ }
+ });
+
+ // Start observing
+ resizeObserver.observe(containerRef.current);
+
+ // Stop observing on cleanup
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [componentData]);
+
+ // Handle initial data load
+ useEffect(() => {
+ const handleComponentUpdate = (event) => {
+ if (event.data && event.data.type === 'components') {
+ setComponentData(event.data.components);
+ setLoading(false);
+ } else if (event.data && event.data.type === 'error') {
+ setError(event.data.content);
+ setLoading(false);
+ }
+ };
+
+ // Add message event listener
+ window.addEventListener('message', handleComponentUpdate);
+
+ // Connect via WebSocket for embedded view
+ const connectWebSocket = async () => {
+ try {
+ const embedConfig = window.EMBED_CONFIG || { embed_mode: true };
+ const clientId = `embed-${Date.now()}`;
+
+ // Connect to WebSocket with embed flag
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws/${clientId}`);
+
+ ws.onopen = () => {
+ // Send init message with embed mode
+ ws.send(JSON.stringify({
+ type: 'init',
+ embed_mode: true,
+ component_id: embedConfig.component_id
+ }));
+ };
+
+ ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+
+ if (data.type === 'components') {
+ if (embedConfig.component_id) {
+ // Filter to just the requested component if specified
+ const filteredComponents = filterComponentById(data.components, embedConfig.component_id);
+ setComponentData(filteredComponents);
+ } else {
+ setComponentData(data.components);
+ }
+ setLoading(false);
+ } else if (data.type === 'error') {
+ setError(data.content);
+ setLoading(false);
+ }
+ };
+
+ ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ setError({ message: 'Failed to connect to the server' });
+ setLoading(false);
+ };
+
+ ws.onclose = () => {
+ console.log('WebSocket connection closed');
+ };
+
+ // Store the WebSocket connection for cleanup
+ return ws;
+ } catch (error) {
+ console.error('Failed to connect to WebSocket:', error);
+ setError({ message: 'Failed to connect to the server' });
+ setLoading(false);
+ }
+ };
+
+ const wsConnection = connectWebSocket();
+
+ // Cleanup function
+ return () => {
+ window.removeEventListener('message', handleComponentUpdate);
+ if (wsConnection) {
+ wsConnection.then(ws => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.close();
+ }
+ });
+ }
+ };
+ }, []);
+
+ // Filter components to return only the specified component and its dependencies
+ const filterComponentById = (components, componentId) => {
+ if (!components || !componentId) return components;
+
+ // Create a new components object with the same structure
+ const filteredComponents = { ...components };
+
+ // If components use a rows structure, filter them
+ if (filteredComponents.rows) {
+ const flattenedComponents = [];
+
+ // Find the specified component in the rows
+ filteredComponents.rows.forEach(row => {
+ row.forEach(component => {
+ if (component.id === componentId) {
+ flattenedComponents.push(component);
+ }
+ });
+ });
+
+ // Create a single row with the filtered component
+ filteredComponents.rows = flattenedComponents.length > 0 ? [flattenedComponents] : [];
+ }
+
+ return filteredComponents;
+ };
+
+ return (
+
+ {loading &&
Loading...
}
+
+ {error && (
+
+
Error
+
{error.message}
+
+ )}
+
+ {componentData && !loading && !error && (
+
+ )}
+
+ );
+};
+
+export default EmbedView;
\ No newline at end of file
diff --git a/preswald/cli.py b/preswald/cli.py
index f0a9b7ab7..bdeb4a924 100644
--- a/preswald/cli.py
+++ b/preswald/cli.py
@@ -175,7 +175,13 @@ def init(name, template):
default=False,
help="Disable automatically opening a new browser tab",
)
-def run(port, log_level, disable_new_tab):
+@click.option(
+ "--embed",
+ is_flag=True,
+ default=False,
+ help="Run in embed mode (for iframe embedding)",
+)
+def run(port, log_level, disable_new_tab, embed):
"""
Run a Preswald app from the current directory.
@@ -221,6 +227,7 @@ def run(port, log_level, disable_new_tab):
"port": port,
"log_level": log_level,
"disable_new_tab": disable_new_tab,
+ "embed": embed,
},
)
@@ -233,7 +240,7 @@ def run(port, log_level, disable_new_tab):
webbrowser.open(url)
- start_server(script=script, port=port)
+ start_server(script=script, port=port, embed=embed)
except Exception as e:
click.echo(f"Error: {e}")
diff --git a/preswald/engine/base_service.py b/preswald/engine/base_service.py
index 77c251ab6..31f65d35d 100644
--- a/preswald/engine/base_service.py
+++ b/preswald/engine/base_service.py
@@ -41,6 +41,7 @@ def __init__(self):
self._script_path: str | None = None
self._is_shutting_down: bool = False
self._render_buffer = RenderBuffer()
+ self._embed_mode: bool = False # Flag for embed mode
# DAG workflow engine
self._workflow = Workflow(service=self)
@@ -106,6 +107,17 @@ def script_path(self, path: str):
def is_reactivity_enabled(self):
return self._reactivity_enabled
+ @property
+ def embed_mode(self) -> bool:
+ """Get the embed mode status."""
+ return self._embed_mode
+
+ @embed_mode.setter
+ def embed_mode(self, value: bool):
+ """Set embed mode status."""
+ self._embed_mode = value
+ logger.info(f"Embed mode set to: {value}")
+
def _ensure_dummy_atom(self, atom_name: str):
"""
Register a placeholder (dummy) atom if one does not already exist.
@@ -301,7 +313,26 @@ def get_rendered_components(self):
rows = self._layout_manager.get_layout()
return {"rows": rows}
+ def get_component(self, component_id: str) -> dict | None:
+ """
+ Get a specific component by its ID.
+
+ Args:
+ component_id: The ID of the component to retrieve
+
+ Returns:
+ The component dictionary or None if not found
+ """
+ components = self._layout_manager.get_layout()
+ for row in components:
+ for component in row:
+ if component.get("id") == component_id:
+ return component
+
+ return None
+
def get_workflow(self) -> Workflow:
+ """Get the workflow instance."""
return self._workflow
async def handle_client_message(self, client_id: str, message: Dict[str, Any]):
diff --git a/preswald/engine/server_service.py b/preswald/engine/server_service.py
index 707260e6e..c7bf2a61b 100644
--- a/preswald/engine/server_service.py
+++ b/preswald/engine/server_service.py
@@ -26,9 +26,9 @@ class ServerPreswaldService(BasePreswaldService):
def __init__(self):
super().__init__()
- # TODO: deprecated
- # Connection management
- self._connections: dict[str, Any] = {}
+ # Initialize connection tracking
+ self.connections = [] # Track connections for broadcasting
+ self._connections: dict[str, Any] = {} # TODO: deprecated
# Branding management
self.branding_manager = None # set during server creation
@@ -44,17 +44,78 @@ async def register_client(
logger.info(f"[WebSocket] New connection request from client: {client_id}")
await websocket.accept()
logger.info(f"[WebSocket] Connection accepted for client: {client_id}")
-
- return await self._register_common_client_setup(client_id, websocket)
+
+ # Store for broadcasting
+ self.connections.append({"id": client_id, "socket": websocket})
+
+ # Initialize with common setup
+ runner = await self._register_common_client_setup(client_id, websocket)
+
+ # Wait for initialization message from client
+ try:
+ init_message = await websocket.receive_json()
+ logger.info(f"Received init message: {init_message}")
+
+ # Check if this is an embed request
+ if init_message.get("type") == "init" and init_message.get("embed_mode"):
+ # Set embed mode flag
+ self.embed_mode = True
+
+ # If component_id is specified, we'll only send that component
+ component_id = init_message.get("component_id")
+ if component_id:
+ logger.info(f"Embed request for component: {component_id}")
+
+ # Get the specific component
+ component = self.get_component(component_id)
+
+ if component:
+ # Create a new components structure with just this component
+ filtered_components = {"rows": [[component]]}
+ await websocket.send_json({
+ "type": "components",
+ "components": filtered_components
+ })
+ else:
+ # Component not found
+ logger.warning(f"Component not found for embed: {component_id}")
+ await websocket.send_json({
+ "type": "error",
+ "content": {"message": f"Component '{component_id}' not found"}
+ })
+ else:
+ # No specific component requested, send all components
+ await self._broadcast_components(client_id=client_id)
+ except Exception as e:
+ logger.error(f"Error processing init message: {e}")
+
+ # Broadcast new connection to all clients
+ await self._broadcast_connections()
+
+ return runner
except WebSocketDisconnect:
logger.error(f"[WebSocket] Client disconnected: {client_id}")
+ # Clean up if registration fails
+ if client_id in self.websocket_connections:
+ self.websocket_connections.pop(client_id)
+ if client_id in self.script_runners:
+ runner = self.script_runners.pop(client_id)
+ await runner.stop()
+ # Remove from connections list
+ self.connections = [c for c in self.connections if c.get("id") != client_id]
except Exception as e:
logger.error(f"Error registering client {client_id}: {e}")
raise
async def unregister_client(self, client_id: str):
+ """Clean up resources for a disconnected client"""
await super().unregister_client(client_id)
+
+ # Remove from connections list
+ self.connections = [conn for conn in self.connections if conn.get("id") != client_id]
+
+ # Broadcast updated connections to all clients
asyncio.create_task(self._broadcast_connections()) # noqa: RUF006
async def _broadcast_connections(self):
@@ -82,3 +143,35 @@ async def _broadcast_connections(self):
except Exception as e:
logger.error(f"Error broadcasting connections: {e}")
# Don't raise the exception to prevent disrupting the main flow
+
+ async def _broadcast_components(self, client_id=None):
+ """
+ Broadcast component data to one or all clients
+
+ Args:
+ client_id: Optional client ID to send to. If None, sends to all clients.
+ """
+ try:
+ # Get all components
+ components = self.get_rendered_components()
+
+ if client_id:
+ # Send to specific client
+ websocket = self.websocket_connections.get(client_id)
+ if websocket:
+ await websocket.send_json({
+ "type": "components",
+ "components": components
+ })
+ else:
+ # Send to all clients
+ for conn in self.connections:
+ try:
+ await conn["socket"].send_json({
+ "type": "components",
+ "components": components
+ })
+ except Exception as e:
+ logger.error(f"Error broadcasting components to {conn['id']}: {e}")
+ except Exception as e:
+ logger.error(f"Error broadcasting components: {e}")
diff --git a/preswald/main.py b/preswald/main.py
index 3606e9c2f..d43a0da81 100644
--- a/preswald/main.py
+++ b/preswald/main.py
@@ -19,10 +19,13 @@
logger = logging.getLogger(__name__)
-def create_app(script_path: str | None = None) -> FastAPI:
+def create_app(script_path: str | None = None, embed: bool = False) -> FastAPI:
"""Create and configure the FastAPI application"""
app = FastAPI()
service = PreswaldService.initialize(script_path)
+
+ # Store embed mode in the service
+ service.embed_mode = embed
if reactivity_explicitly_disabled():
service.disable_reactivity()
@@ -63,6 +66,24 @@ async def serve_index():
except Exception as e:
logger.error(f"Error serving index: {e}")
raise HTTPException(status_code=500, detail="Internal server error") from e
+
+ @app.get("/embed")
+ async def serve_embed_app():
+ """Serve the full app in embed mode"""
+ try:
+ return _handle_embed_request(app.state.service)
+ except Exception as e:
+ logger.error(f"Error serving embed view: {e}")
+ raise HTTPException(status_code=500, detail="Internal server error") from e
+
+ @app.get("/embed/{component_id}")
+ async def serve_embed_component(component_id: str):
+ """Serve a specific component in embed mode"""
+ try:
+ return _handle_embed_request(app.state.service, component_id)
+ except Exception as e:
+ logger.error(f"Error serving component embed view: {e}")
+ raise HTTPException(status_code=500, detail="Internal server error") from e
@app.get("/favicon.ico")
async def serve_favicon():
@@ -142,10 +163,10 @@ async def noop_message_handler(msg):
return service.get_rendered_components()
-def start_server(script: str | None = None, port: int = 8501):
+def start_server(script: str | None = None, port: int = 8501, embed: bool = False):
"""Start the FastAPI server"""
- app = create_app(script)
-
+ app = create_app(script, embed=embed)
+
config = uvicorn.Config(app, host="0.0.0.0", port=port, loop="asyncio")
server = uvicorn.Server(config)
@@ -164,6 +185,13 @@ def sync_handle_shutdown(signum, frame):
signal.signal(signal.SIGINT, sync_handle_shutdown)
signal.signal(signal.SIGTERM, sync_handle_shutdown)
+ # Log embedding info if in embed mode
+ if embed:
+ embed_url = f"http://localhost:{port}/embed"
+ logger.info(f"🖼️ Running in embed mode - use this URL for embedding: {embed_url}")
+ logger.info("📋 Embed code example:")
+ logger.info(f"")
+
try:
import asyncio
@@ -261,3 +289,60 @@ def _handle_favicon_request(service: PreswaldService) -> HTMLResponse:
except Exception as e:
logger.error(f"Error serving index: {e}")
raise HTTPException(status_code=500, detail="Internal server error") from e
+
+
+def _handle_embed_request(service: PreswaldService, component_id: str | None = None) -> HTMLResponse:
+ """
+ Handle embed requests for full app or specific components.
+
+ Args:
+ service: The PreswaldService instance
+ component_id: Optional component ID to embed just one component
+
+ Returns:
+ HTMLResponse with the embedded content
+ """
+ try:
+ # Get the index.html content as a base
+ with open(os.path.join(service.branding_manager.static_dir, "index.html"), "r") as f:
+ html_content = f.read()
+
+ # Extract branding information
+ branding = {}
+ if hasattr(service, "branding_manager") and service.branding_manager:
+ branding = service.branding_manager.get_branding_config_with_data_urls(
+ service.script_path or ""
+ )
+
+ # Inject component_id if specified
+ embed_config = {
+ "embed_mode": True,
+ "component_id": component_id
+ }
+
+ # Inject the embed configuration into the HTML
+ html_content = html_content.replace(
+ "",
+ f""
+ )
+
+ # Inject branding if available
+ if branding:
+ html_content = html_content.replace(
+ "",
+ f""
+ )
+
+ # Add special headers for embedding
+ response = HTMLResponse(content=html_content)
+
+ # Set headers to allow embedding in iframes
+ response.headers["X-Frame-Options"] = "ALLOWALL"
+ response.headers["Content-Security-Policy"] = "frame-ancestors *"
+ response.headers["Access-Control-Allow-Origin"] = "*"
+
+ return response
+
+ except Exception as e:
+ logger.error(f"Error handling embed request: {e}")
+ raise HTTPException(status_code=500, detail="Error serving embedded content") from e
diff --git a/tests/embed_test.py b/tests/embed_test.py
new file mode 100644
index 000000000..28796a914
--- /dev/null
+++ b/tests/embed_test.py
@@ -0,0 +1,159 @@
+import pytest
+import asyncio
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from preswald.engine.server_service import ServerPreswaldService
+from preswald.engine.base_service import BasePreswaldService
+
+
+@pytest.fixture
+def service():
+ # Create a service instance for testing
+ service = ServerPreswaldService()
+
+ # Mock the _register_common_client_setup method to avoid actual script execution
+ service._register_common_client_setup = AsyncMock(return_value=MagicMock())
+
+ # Mock get_component method
+ mock_component = {"id": "test_component", "type": "text", "value": "Test Content"}
+ service.get_component = MagicMock(return_value=mock_component)
+
+ return service
+
+
+@pytest.mark.asyncio
+async def test_register_client_with_embed_mode(service):
+ """Test client registration with embed mode"""
+
+ # Create a mock WebSocket
+ mock_websocket = AsyncMock()
+ mock_websocket.receive_json = AsyncMock(return_value={
+ "type": "init",
+ "embed_mode": True,
+ "component_id": "test_component"
+ })
+
+ # Register the client
+ await service.register_client("test_client", mock_websocket)
+
+ # Verify that embed_mode was set
+ assert service.embed_mode is True
+
+ # Verify that the component was sent to the client
+ mock_websocket.send_json.assert_any_call({
+ "type": "components",
+ "components": {"rows": [[{"id": "test_component", "type": "text", "value": "Test Content"}]]}
+ })
+
+
+@pytest.mark.asyncio
+async def test_register_client_with_nonexistent_component(service):
+ """Test client registration with a component ID that doesn't exist"""
+
+ # Create a mock WebSocket
+ mock_websocket = AsyncMock()
+ mock_websocket.receive_json = AsyncMock(return_value={
+ "type": "init",
+ "embed_mode": True,
+ "component_id": "nonexistent_component"
+ })
+
+ # Mock get_component to return None for nonexistent component
+ service.get_component = MagicMock(return_value=None)
+
+ # Register the client
+ await service.register_client("test_client", mock_websocket)
+
+ # Verify that embed_mode was set
+ assert service.embed_mode is True
+
+ # Verify that an error message was sent to the client
+ mock_websocket.send_json.assert_any_call({
+ "type": "error",
+ "content": {"message": "Component 'nonexistent_component' not found"}
+ })
+
+
+@pytest.mark.asyncio
+async def test_broadcast_components(service):
+ """Test broadcasting components to clients"""
+
+ # Create mock WebSockets
+ mock_websocket1 = AsyncMock()
+ mock_websocket2 = AsyncMock()
+
+ # Add mock connections
+ service.connections = [
+ {"id": "client1", "socket": mock_websocket1},
+ {"id": "client2", "socket": mock_websocket2}
+ ]
+
+ # Mock get_rendered_components
+ mock_components = {"rows": [[{"id": "test_component", "type": "text", "value": "Test Content"}]]}
+ service.get_rendered_components = MagicMock(return_value=mock_components)
+
+ # Broadcast to all clients
+ await service._broadcast_components()
+
+ # Verify that both clients received the components
+ mock_websocket1.send_json.assert_called_with({
+ "type": "components",
+ "components": mock_components
+ })
+
+ mock_websocket2.send_json.assert_called_with({
+ "type": "components",
+ "components": mock_components
+ })
+
+
+@pytest.mark.asyncio
+async def test_broadcast_components_to_specific_client(service):
+ """Test broadcasting components to a specific client"""
+
+ # Create mock WebSocket
+ mock_websocket = AsyncMock()
+
+ # Add the client ID to websocket_connections
+ service.websocket_connections = {"client1": mock_websocket}
+
+ # Mock get_rendered_components
+ mock_components = {"rows": [[{"id": "test_component", "type": "text", "value": "Test Content"}]]}
+ service.get_rendered_components = MagicMock(return_value=mock_components)
+
+ # Broadcast to specific client
+ await service._broadcast_components(client_id="client1")
+
+ # Verify that the client received the components
+ mock_websocket.send_json.assert_called_with({
+ "type": "components",
+ "components": mock_components
+ })
+
+
+@pytest.mark.asyncio
+async def test_unregister_client(service):
+ """Test unregistering a client"""
+
+ # Create mock websocket connections and connections list
+ mock_websocket = AsyncMock()
+ service.websocket_connections = {"client1": mock_websocket}
+ service.connections = [{"id": "client1", "socket": mock_websocket}]
+
+ # Mock the super().unregister_client method
+ with patch.object(BasePreswaldService, 'unregister_client', AsyncMock()) as mock_super:
+ # Mock the _broadcast_connections method
+ service._broadcast_connections = AsyncMock()
+
+ # Unregister the client
+ await service.unregister_client("client1")
+
+ # Verify that the super method was called
+ mock_super.assert_called_once_with("client1")
+
+ # Verify that the client was removed from the connections list
+ assert len(service.connections) == 0
+
+ # Verify that _broadcast_connections was called
+ assert service._broadcast_connections.called
\ No newline at end of file