A space for your next idea
Try the controls, open the menu, and submit the form to explore its states.
+{t('generativeUI.paused')}
+ +{error}
{t('generativeUI.resetDescription')}
+ +Gather your ingredients, then cook one step at a time.
Try the controls, open the menu, and submit the form to explore its states.
+Choose a city to load its forecast.
New output
') + await page.evaluate(() => window.dispatchEvent(new CustomEvent('fixture-history', { detail: [ + { id: 'custom', title: 'Custom interface', revision: 0, html: 'Previous output
', connect_origins: [] }, + ] }))) + await page.getByLabel('Select output revision').selectOption('custom:0') + await expect(preview(page).getByText('Previous output')).toBeVisible() + await expect(page.locator('iframe')).toHaveCount(1) + await page.getByRole('button', { name: 'Back to conversation' }).click() + await page.getByRole('button', { name: 'Revise interface' }).click() + await expect(preview(page).getByText('New output')).toBeVisible() + await expect(page.getByLabel('Select output revision')).toHaveValue('custom:2') + await expect(page.locator('iframe')).toHaveCount(1) +}) + +test('output visibility belongs to its session and older history does not reopen it', async ({ page }) => { + await open(page) + await page.getByRole('button', { name: 'Back to conversation' }).click() + await page.getByRole('button', { name: 'Switch session' }).click() + await expect(preview(page).getByRole('heading', { name: 'Weeknight tomato pasta' })).toBeVisible() + await page.getByRole('button', { name: 'Switch session' }).click() + await expect(page.locator('iframe')).toHaveCount(0) + await page.evaluate(() => window.dispatchEvent(new CustomEvent('fixture-history', { detail: [ + { id: 'older', title: 'Older output', revision: 1, html: 'Older history
', connect_origins: [] }, + ] }))) + await expect(page.locator('iframe')).toHaveCount(0) + await page.getByRole('button', { name: 'Show outputs', exact: true }).click() + await expect(preview(page).getByRole('heading', { name: 'Weeknight tomato pasta' })).toBeVisible() +}) + +test('phone viewport and fullscreen change canvas space without reloading the document', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await open(page) + await preview(page).getByRole('button', { name: 'Next step' }).click() + const document = await page.locator('iframe').getAttribute('srcdoc') + await page.getByRole('button', { name: 'Phone', exact: true }).click() + await expect.poll(async () => (await page.locator('iframe').boundingBox())!.width).toBeLessThanOrEqual(390) + await page.getByRole('button', { name: 'Fullscreen output', exact: true }).click() + await page.getByRole('button', { name: 'Desktop', exact: true }).click() + await expect.poll(async () => (await page.locator('iframe').boundingBox())!.width).toBeGreaterThan(1000) + expect(await page.locator('iframe').getAttribute('srcdoc')).toBe(document) + await expect(preview(page).getByRole('heading', { name: 'Step 2 of 4' })).toBeVisible() + await page.setViewportSize({ width: 390, height: 844 }) + await page.getByRole('button', { name: 'Exit fullscreen', exact: true }).click() + await expect(page.getByRole('region', { name: 'Output panel' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await page.getByRole('button', { name: 'Back to conversation' }).click() + await expect(page.getByRole('button', { name: 'Show outputs', exact: true })).toBeVisible() +}) + +test('ready previews stay ready when the app synchronizes its language', async ({ page }) => { + await page.clock.install() + await open(page) + await expect(page.getByRole('status')).toHaveCount(0) + await page.evaluate(() => window.dispatchEvent(new CustomEvent('fixture-language', { detail: 'es' }))) + await expect(page.locator('html')).toHaveAttribute('lang', 'es') + await page.clock.fastForward(11_000) + await expect(page.getByRole('status')).toHaveCount(0) + await expect(page.getByRole('alert')).toHaveCount(0) + await preview(page).getByRole('button', { name: 'Next step' }).click() + await expect(preview(page).getByRole('heading', { name: 'Step 2 of 4' })).toBeVisible() +}) + +test('reopening during an animated close keeps the running document and progress', async ({ page }) => { + await open(page) + await preview(page).getByRole('button', { name: 'Next step' }).click() + const document = await page.locator('iframe').getAttribute('srcdoc') + // Pause the native exit at its start to exercise cancellation deterministically. + await page.getByRole('button', { name: 'Back to conversation' }).evaluate(element => { + element.addEventListener('click', () => requestAnimationFrame(() => { + document.getAnimations().forEach(animation => animation.pause()) + }), { once: true }) + }) + await page.getByRole('button', { name: 'Back to conversation' }).click() + await page.getByRole('button', { name: 'Show outputs', exact: true }).click() + await expect(preview(page).getByRole('heading', { name: 'Step 2 of 4' })).toBeVisible() + expect(await page.locator('iframe').getAttribute('srcdoc')).toBe(document) + await page.getByRole('button', { name: 'Back to conversation' }).click() + await expect(page.locator('iframe')).toHaveCount(0) +}) + +test('reduced motion disables panel and preview transitions without changing controls', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await open(page) + await preview(page).getByRole('button', { name: 'Next step' }).click() + await page.getByRole('button', { name: 'Phone', exact: true }).click() + expect((await page.locator('iframe').boundingBox())!.width).toBeLessThanOrEqual(390) + const panel = page.getByRole('region', { name: 'Output panel' }) + await expect.poll(() => panel.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0) + await expect(preview(page).getByRole('button', { name: 'Next step' })).toHaveCSS('transition-duration', '0s') + await page.getByRole('button', { name: 'Fullscreen output', exact: true }).click() + expect(await panel.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0) + await expect(preview(page).getByRole('heading', { name: 'Step 2 of 4' })).toBeVisible() + await page.getByRole('button', { name: 'Back to conversation' }).click() + await expect(page.locator('iframe')).toHaveCount(0) +}) + +test('chat and toolbar move through intermediate layouts when closing, opening and expanding', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await open(page) + await preview(page).getByRole('button', { name: 'Next step' }).click() + const document = await page.locator('iframe').getAttribute('srcdoc') + const workspace = page.getByRole('region', { name: 'Output panel', includeHidden: true }).locator('..') + const chat = workspace.locator(':scope > div').first() + const splitWidth = (await chat.boundingBox())!.width + + const pauseAtStart = async (name: string) => { + await page.getByRole('button', { name, exact: true }).evaluate(element => { + element.addEventListener('click', () => requestAnimationFrame(() => { + for (const animation of document.getAnimations()) { + if (animation.effect instanceof KeyframeEffect && animation.effect.getKeyframes().some(frame => 'gridTemplateColumns' in frame)) { + animation.pause() + animation.currentTime = 0 + } + } + }), { once: true }) + }) + await page.getByRole('button', { name, exact: true }).click() + } + const seek = async (time: number | 'finish') => { + await page.evaluate(time => { + for (const animation of document.getAnimations()) { + if (animation.effect instanceof KeyframeEffect && animation.effect.getKeyframes().some(frame => 'gridTemplateColumns' in frame)) { + if (time === 'finish') animation.finish() + else animation.currentTime = time + } + } + }, time) + } + + await pauseAtStart('Back to conversation') + expect((await chat.boundingBox())!.width).toBeCloseTo(splitWidth, 0) + await seek(150) + const closingWidth = (await chat.boundingBox())!.width + expect(closingWidth).toBeGreaterThan(splitWidth + 1) + expect(closingWidth).toBeLessThan(1439) + await seek('finish') + await expect(page.locator('iframe')).toHaveCount(0) + + await pauseAtStart('Show outputs') + expect((await chat.boundingBox())!.width).toBeCloseTo(1440, 0) + await seek(150) + const openingWidth = (await chat.boundingBox())!.width + expect(openingWidth).toBeGreaterThan(splitWidth + 1) + expect(openingWidth).toBeLessThan(1439) + await seek('finish') + await expect(preview(page).getByRole('heading', { name: 'Step 2 of 4' })).toBeVisible() + + const reopenedDocument = await page.locator('iframe').getAttribute('srcdoc') + const phoneY = (await page.getByRole('button', { name: 'Phone', exact: true }).boundingBox())!.y + await pauseAtStart('Fullscreen output') + expect((await chat.boundingBox())!.width).toBeCloseTo(splitWidth, 0) + await seek(150) + const fullscreenWidth = (await chat.boundingBox())!.width + expect(fullscreenWidth).toBeGreaterThan(1) + expect(fullscreenWidth).toBeLessThan(splitWidth - 1) + expect((await chat.locator(':scope > div').last().boundingBox())!.width).toBeGreaterThanOrEqual(splitWidth - 1) + expect((await page.getByRole('button', { name: 'Phone', exact: true }).boundingBox())!.y).toBeCloseTo(phoneY, 0) + await seek('finish') + expect((await page.getByRole('button', { name: 'Phone', exact: true }).boundingBox())!.y).toBeCloseTo(phoneY, 0) + expect(await page.locator('iframe').getAttribute('srcdoc')).toBe(reopenedDocument) + expect(reopenedDocument).not.toBe(document) // Closing unloads; expanding preserves. +}) + +test('resize supports dragging and keyboard, persists, and preserves the running document', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await open(page) + await preview(page).getByRole('button', { name: 'Next step' }).click() + const document = await page.locator('iframe').getAttribute('srcdoc') + const handle = page.getByRole('separator', { name: 'Resize conversation and output' }) + const initial = Number(await handle.getAttribute('aria-valuenow')) + await handle.press('ArrowRight') + await expect.poll(async () => Number(await handle.getAttribute('aria-valuenow'))).toBeGreaterThan(initial) + const rect = (await handle.boundingBox())! + await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2) + await page.mouse.down() + await page.mouse.move(rect.x - 140, rect.y + rect.height / 2, { steps: 8 }) + await page.mouse.up() + const resized = await handle.getAttribute('aria-valuenow') + expect(Number(resized)).toBeLessThan(initial) + await expect(preview(page).getByRole('heading', { name: 'Step 2 of 4' })).toBeVisible() + expect(await page.locator('iframe').getAttribute('srcdoc')).toBe(document) + await page.reload() + await expect(handle).toHaveAttribute('aria-valuenow', resized!) + await handle.press('Home') + await expect.poll(async () => await handle.getAttribute('aria-valuenow')).toBe(await handle.getAttribute('aria-valuemin')) + await handle.press('End') + await expect.poll(async () => await handle.getAttribute('aria-valuenow')).toBe(await handle.getAttribute('aria-valuemax')) + // A wide browser can still contain a narrow workspace next to the app sidebar. + await handle.locator('..').evaluate(element => (element as HTMLElement).style.width = '800px') + await expect(handle).toBeHidden() + await expect(page.getByRole('region', { name: 'Output panel' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) +}) + +test('reset requires confirmation, can be cancelled, and preview options dismiss outside', async ({ page }) => { + await open(page) + await preview(page).getByLabel('Servings').fill('4') + await preview(page).getByLabel('Servings').press('Tab') + await page.getByLabel('Preview options', { exact: true }).click() + await page.getByRole('button', { name: 'Reset saved progress', exact: true }).click() + await expect(page.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused() + await expect(preview(page).getByLabel('Servings')).toHaveValue('4') + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await expect(preview(page).getByLabel('Servings')).toHaveValue('4') + await page.getByRole('heading', { name: 'Cooking guide', exact: true }).click() + await expect(page.getByRole('button', { name: 'Reset saved progress' })).toBeHidden() + await page.getByLabel('Preview options', { exact: true }).click() + await preview(page).getByLabel('Servings').click() + await expect(page.getByRole('button', { name: 'Reset saved progress' })).toBeHidden() + await previewAction(page, 'Reset saved progress') + await expect(preview(page).getByLabel('Servings')).toHaveValue('2') +}) + +test('quick history hydration creates a ready frame with the latest token', async ({ page }) => { + await page.clock.install() + await open(page) + const html = 'Hydrated interface
' + await custom(page, html) + // History hydration can replace an artifact with an equal object while its + // previous srcdoc is still loading. Each document must have a fresh Window. + for (let i = 0; i < 5; i++) await custom(page, html) + await expect(preview(page).locator('#hydrated')).toHaveText('Hydrated interface') + await expect(page.getByRole('status')).toHaveCount(0) + await page.clock.fastForward(11_000) + await expect(page.getByRole('alert')).toHaveCount(0) +}) + +test('preview follows host theme without reloading or losing interactions', async ({ page }) => { + await open(page) + await custom(page, 'Loading
`, ['https://DATA.example.org:443/', 'https://api.example.com']) + await expect(preview(page).locator('#data')).toHaveText('Count 42') + await expect(page.getByRole('alert')).toContainText('Blocked resource:') + expect(requests).toEqual(['https://data.example.org/count']) + expect(denied).toEqual([]) +}) + +for (const origins of [ + ['https://data.example.org; connect-src *'], + ['https://data.example.org\n'], + ['https://data.example.org', 'https://127.0.0.1'], + ['https://app.localhost'], + ['https://data.example.org/path'], +]) test(`invalid persisted API policy fails closed: ${JSON.stringify(origins)}`, async ({ page }) => { + const requests: string[] = [] + await page.route('https://data.example.org/**', route => { requests.push(route.request().url()); return route.abort() }) + await open(page) + await custom(page, `Invalid policy
`, origins) + await expect(page.getByRole('alert')).toContainText('Blocked resource:') + expect(requests).toEqual([]) +}) + +test('design controls, menu and form work on a narrow viewport and with keyboard input', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await open(page) + await page.getByRole('button', { name: 'Design', exact: true }).click() + await preview(page).getByLabel('Card spacing').focus() + await preview(page).getByLabel('Card spacing').press('ArrowRight') + await expect(preview(page).locator('#card')).toHaveCSS('padding', '25px') + await preview(page).getByLabel('Accent color').fill('#123456') + await expect(preview(page).getByRole('button', { name: 'Join the preview' })).toHaveCSS('background-color', 'rgb(18, 52, 86)') + await preview(page).getByRole('button', { name: 'Menu', exact: true }).click() + await expect(preview(page).getByRole('navigation')).toBeVisible() + await preview(page).getByLabel('Email address').fill('test@example.com') + await preview(page).getByRole('button', { name: 'Join the preview' }).click() + await expect(preview(page).getByRole('status')).toContainText('submitted locally') + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) +}) + +test('sandbox denies parent/storage access, external scripts and undeclared fetch, even with a copied nonce', async ({ page }) => { + await open(page) + const outgoing: string[] = [] + await page.route('https://blocked.invalid/**', route => { outgoing.push(route.request().url()); return route.abort() }) + await custom(page, `Broken example
`) + await expect(page.getByRole('alert')).toContainText('Example runtime failure') + await previewAction(page, 'Reload preview') + await expect(page.getByRole('alert')).toContainText('Example runtime failure') +}) + +test('document attributes and state strings survive rendering without executing injected markup', async ({ page }) => { + await open(page) + await custom(page, '') + await expect(preview(page).locator('html')).toHaveAttribute('lang', 'fr') + await expect(preview(page).locator('body')).toHaveClass('test-body') + await expect(preview(page).locator('body')).toHaveCSS('background-color', 'rgb(1, 2, 3)') + await expect(preview(page).getByRole('button', { name: 'Save text' })).toHaveCSS('color', 'rgb(23, 21, 18)') + await preview(page).getByRole('button', { name: 'Save text' }).click() + await previewAction(page, 'Reload preview') + await expect(preview(page).locator('#value')).toHaveText('') + await expect(page.getByRole('alert')).toHaveCount(0) +}) diff --git a/app/ui_layer/browser/frontend/tests/harness.tsx b/app/ui_layer/browser/frontend/tests/harness.tsx new file mode 100644 index 00000000..2af021fd --- /dev/null +++ b/app/ui_layer/browser/frontend/tests/harness.tsx @@ -0,0 +1,61 @@ +// Development-only fixture: exercises the real chat message and preview, without +// a model/provider or a live CraftBot database. Vite does not bundle it for release. +import React, { useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import ui from '../src/store/slices/uiSlice' +import agent from '../src/store/slices/agentSlice' +import { createUiPersistenceMiddleware, loadPersistedUiState } from '../src/store/uiState' +import { OutputWorkspace } from '../src/components/GenerativeUI/OutputWorkspace' +import { ChatMessageItem } from '../src/pages/Chat/ChatMessage' +import type { GenerativeUIArtifact, ChatMessage } from '../src/types' +import i18n from '../src/i18n/config' +import '../src/styles/global.css' +import cooking from './fixtures/cooking.html?raw' +import weather from './fixtures/weather.html?raw' +import design from './fixtures/design.html?raw' + +const store = configureStore({ reducer: { ui, agent }, preloadedState: { ui: loadPersistedUiState(), agent: { ...agent(undefined, { type: '@@init' }), profilePictureUrl: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"%3E%3Ccircle cx="16" cy="16" r="14" fill="%23FF4F18"/%3E%3C/svg%3E' } }, middleware: getDefault => getDefault().concat(createUiPersistenceMiddleware()) }) +const examples: RecordCraftBot · development preview
+Explore three frontend-only answers. Every control runs in the preview.
+ +Hello
", **change}) + + +@pytest.mark.parametrize( + "origin", + [ + "http://api.example.com", + "https://api.example.com/path", + "https://api.example.com?secret=value", + "https://user:pass@api.example.com", + "https://*.example.com", + "https://api.example.com; connect-src *", + "https://api.example.com\n", + "https://127.0.0.1", + "https://[::1]", + "https://localhost", + "https://app.localhost", + "https://app.local", + "https://app.internal", + "https://api.example.com:7946", + "https://-bad.example.com", + "https://api..example.com", + ], +) +def test_rejects_unsafe_connect_origins(origin): + with pytest.raises(ValueError): + make_artifact( + {"title": "API output", "html": "Data
", "connect_origins": [origin]} + ) + + +def test_connect_origins_are_explicit_normalized_and_persisted(tmp_path): + data = make_artifact( + { + "title": "API output", + "html": "Data
", + "connect_origins": [ + "https://API.Example.com:443/", + "https://api.example.com", + "https://data.example.org", + ], + } + ) + assert data["connect_origins"] == [ + "https://api.example.com", + "https://data.example.org", + ] + storage = ChatStorage(str(tmp_path / "api-output.db")) + storage.insert_message( + StoredChatMessage( + message_id="api-output", + sender="Agent", + content="API output", + style="agent", + timestamp=1, + session_id="api-test", + ui_artifact=data, + ) + ) + assert ( + storage.get_messages(session_id="api-test")[0].ui_artifact["connect_origins"] + == data["connect_origins"] + ) + + +def test_artifact_delivery_and_history_round_trip(tmp_path, monkeypatch): + """Exercise the production state → event → adapter → SQLite → wire path.""" + streams = EventStreamManager(llm=SimpleNamespace()) + stream = streams.create_stream("cooking") + manager = StateManager(streams) + monkeypatch.setattr(manager, "bump_event_stream", lambda: None) + monkeypatch.setattr(InternalActionInterface, "state_manager", manager) + result = asyncio.run( + ActionExecutor().execute_atomic_action( + SimpleNamespace(**ActionRegistry().find_action_by_name("render_ui")), + { + "title": "Steps", + "html": "", + "_session_id": "cooking", + }, + ) + ) + event = Event.from_dict(stream.as_list()[-1].to_dict()) + assert event.ui_artifact["id"] == result["artifact_id"] + ui_event = EventTransformer.transform(event, session_id="cooking") + component = SimpleNamespace(append_message=AsyncMock()) + # InterfaceAdapter is abstract: exercise its method on a minimal receiver. + receiver = SimpleNamespace(chat_component=component) + asyncio.run( + InterfaceAdapter._display_chat_message( + receiver, + "CraftBot", + "Steps", + "agent", + session_id="cooking", + ui_artifact=ui_event.data["ui_artifact"], + ) + ) + message = component.append_message.call_args.args[0] + storage = ChatStorage(str(tmp_path / "chat.db")) + storage.insert_message( + StoredChatMessage( + message_id=message.message_id, + sender=message.sender, + content=message.content, + style=message.style, + timestamp=message.timestamp, + session_id=message.session_id, + ui_artifact=message.ui_artifact, + ) + ) + restored = BrowserChatComponent._stored_to_chat_message( + storage.get_recent_messages(session_id="cooking")[0] + ) + assert restored.to_dict()["uiArtifact"] == message.to_dict()["uiArtifact"] + assert restored.session_id == "cooking" + assert storage.get_recent_messages(session_id="other") == [] + assert ( + storage.get_latest_ui_artifact("cooking", message.ui_artifact["id"]) + == message.ui_artifact + ) + assert storage.get_latest_ui_artifact("other", message.ui_artifact["id"]) is None + # The migration is additive/idempotent; ordinary messages retain their shape. + ChatStorage(str(tmp_path / "chat.db")) + assert "uiArtifact" not in ChatMessage("user", "Hello", "user").to_dict() + assert ( + "uiArtifact" + not in StoredChatMessage("old", "user", "Hello", "user", 0).to_dict() + ) + + +def test_render_action_revisions_are_session_scoped(monkeypatch): + original = artifact() + event = Event( + "Steps", + "agent message", + "INFO", + event_type=EventType.AGENT_MESSAGE, + ui_artifact=original, + ) + streams = { + "mine": SimpleNamespace(as_list=lambda: [event]), + "other": SimpleNamespace(as_list=lambda: []), + } + manager = SimpleNamespace( + event_stream_manager=SimpleNamespace( + get_stream_by_id=streams.get, has_stream=lambda sid: sid in streams + ) + ) + monkeypatch.setattr(InternalActionInterface, "state_manager", manager) + delivery = AsyncMock() + monkeypatch.setattr(InternalActionInterface, "do_chat", delivery) + monkeypatch.setattr( + "app.usage.chat_storage.get_chat_storage", + lambda: SimpleNamespace(get_latest_ui_artifact=lambda sid, aid: None), + ) + data = {"artifact_id": original["id"], "title": "Revised", "html": "Revised
"} + result = asyncio.run(render_ui({**data, "_session_id": "mine"})) + assert result["revision"] == 2 + assert delivery.call_args.kwargs["ui_artifact"]["html"] == "Revised
" + assert delivery.call_args.kwargs["session_id"] == "mine" + assert asyncio.run(render_ui({**data, "_session_id": "other"}))["status"] == "error" + assert delivery.call_count == 1 + + +def test_simulated_action_does_not_deliver(monkeypatch): + delivery = AsyncMock() + monkeypatch.setattr(InternalActionInterface, "do_chat", delivery) + assert ( + asyncio.run( + render_ui({"title": "Demo", "html": "Hello
", "simulated_mode": True}) + )["status"] + == "success" + ) + delivery.assert_not_called() + + +def test_revision_lookup_survives_context_folding(tmp_path, monkeypatch): + original = {**artifact(), "revision": 7} + storage = ChatStorage(str(tmp_path / "chat.db")) + storage.insert_message( + StoredChatMessage( + "old", + "CraftBot", + "Steps", + "agent", + 1, + session_id="mine", + ui_artifact=original, + ) + ) + streams = SimpleNamespace( + has_stream=lambda sid: True, + get_stream_by_id=lambda sid: SimpleNamespace(as_list=lambda: []), + ) + monkeypatch.setattr( + InternalActionInterface, + "state_manager", + SimpleNamespace(event_stream_manager=streams), + ) + monkeypatch.setattr("app.usage.chat_storage.get_chat_storage", lambda: storage) + monkeypatch.setattr(InternalActionInterface, "do_chat", AsyncMock()) + data = {"artifact_id": original["id"], "title": "Revised", "html": "Updated
"} + assert asyncio.run(render_ui({**data, "_session_id": "mine"}))["revision"] == 8 + assert asyncio.run(render_ui({**data, "_session_id": "other"}))["status"] == "error"