From 4b5de66c5869028e1a5e366e1a577306d26e8550 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 17:41:33 +0800 Subject: [PATCH] Polish WebUI streaming and provider settings --- nanobot/channels/websocket.py | 33 +++- nanobot/providers/registry.py | 2 +- tests/channels/test_websocket_channel.py | 28 ++- webui/src/components/CodeBlock.tsx | 18 +- webui/src/components/MarkdownText.tsx | 112 +++++++++++- webui/src/components/MarkdownTextRenderer.tsx | 162 ++++++++++-------- webui/src/components/MessageBubble.tsx | 8 +- .../src/components/settings/SettingsView.tsx | 48 +++++- webui/src/lib/types.ts | 1 + webui/src/tests/app-layout.test.tsx | 47 +++++ webui/src/tests/code-block.test.tsx | 12 ++ webui/src/tests/markdown-text.test.tsx | 82 +++++++++ 12 files changed, 456 insertions(+), 97 deletions(-) create mode 100644 webui/src/tests/markdown-text.test.tsx diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 26e00ff6a..86a33c8b7 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None: return f"{secret[:4]}••••{secret[-4:]}" +def _provider_requires_api_key(spec: Any) -> bool: + if spec.backend == "azure_openai": + return True + if spec.is_local or spec.is_direct: + return False + return True + + +def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool: + if _provider_requires_api_key(spec): + return bool(provider_config.api_key) + return bool( + provider_config.api_key + or provider_config.api_base + or getattr(provider_config, "region", None) + or getattr(provider_config, "profile", None) + ) + + _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, {"name": "brave", "label": "Brave Search", "credential": "api_key"}, @@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel): providers = [] for spec in PROVIDERS: provider_config = getattr(config.providers, spec.name, None) - if provider_config is None or spec.is_oauth or spec.is_local: + if provider_config is None or spec.is_oauth: continue providers.append( { "name": spec.name, "label": spec.label, - "configured": bool(provider_config.api_key), + "configured": _provider_configured_for_settings(spec, provider_config), + "api_key_required": _provider_requires_api_key(spec), "api_key_hint": _mask_secret_hint(provider_config.api_key), "api_base": provider_config.api_base, "default_api_base": spec.default_api_base or None, @@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel): if find_by_name(provider) is None: return _http_error(400, "unknown provider") provider_config = getattr(config.providers, provider, None) - if provider_config is None or not provider_config.api_key: + spec = find_by_name(provider) + if ( + provider_config is None + or spec is None + or not _provider_configured_for_settings(spec, provider_config) + ): return _http_error(400, "provider is not configured") if defaults.provider != provider: defaults.provider = provider @@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel): if not provider_name: return _http_error(400, "provider is required") spec = find_by_name(provider_name) - if spec is None or spec.is_oauth or spec.is_local: + if spec is None or spec.is_oauth: return _http_error(400, "unknown provider") config = load_config() diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 4dba0c46d..e6f022187 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( name="vllm", keywords=("vllm",), env_key="HOSTED_VLLM_API_KEY", - display_name="vLLM/Local", + display_name="vLLM", backend="openai_compat", is_local=True, ), diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 9b481e251..2fa7285fb 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -946,7 +946,12 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( providers = {provider["name"]: provider for provider in body["providers"]} assert providers["openai"]["configured"] is True assert providers["openai"]["api_key_hint"] == "secr••••-key" + assert providers["azure_openai"]["api_key_required"] is True assert providers["openrouter"]["configured"] is False + assert providers["openrouter"]["api_key_required"] is True + assert providers["atomic_chat"]["configured"] is False + assert providers["atomic_chat"]["api_key_required"] is False + assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1" assert body["agent"]["has_api_key"] is True assert body["web_search"]["provider"] == "brave" assert body["web_search"]["api_key_hint"] == "brav••••cret" @@ -969,10 +974,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert provider_rows["openrouter"]["configured"] is True assert "sk-or-test" not in provider_updated.text + local_provider_updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/provider/update?provider=atomic_chat" + "&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1", + headers={"Authorization": "Bearer tok"}, + ) + assert local_provider_updated.status_code == 200 + local_provider_body = local_provider_updated.json() + local_provider_rows = { + provider["name"]: provider for provider in local_provider_body["providers"] + } + assert local_provider_rows["atomic_chat"]["configured"] is True + assert "localhost:1337" in local_provider_updated.text + updated = await _http_get( "http://127.0.0.1:" - f"{port}/api/settings/update?model=openrouter/test" - "&provider=openrouter", + f"{port}/api/settings/update?model=atomic_chat/test" + "&provider=atomic_chat", headers={"Authorization": "Bearer tok"}, ) assert updated.status_code == 200 @@ -992,10 +1011,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert search_body["web_search"]["base_url"] == "https://search.example.com" saved = load_config(config_path) - assert saved.agents.defaults.model == "openrouter/test" - assert saved.agents.defaults.provider == "openrouter" + assert saved.agents.defaults.model == "atomic_chat/test" + assert saved.agents.defaults.provider == "atomic_chat" assert saved.providers.openrouter.api_key == "sk-or-test" assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1" + assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1" assert saved.tools.web.search.provider == "searxng" assert saved.tools.web.search.api_key == "" assert saved.tools.web.search.base_url == "https://search.example.com" diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index 2ab6bd572..4e3b8b736 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -9,6 +9,7 @@ interface CodeBlockProps { language?: string; code: string; className?: string; + highlight?: boolean; } interface HighlightedCodeProps { @@ -60,7 +61,12 @@ function PlainCodeFallback({ code }: { code: string }) { ); } -export function CodeBlock({ language, code, className }: CodeBlockProps) { +export function CodeBlock({ + language, + code, + className, + highlight = true, +}: CodeBlockProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const isDark = useThemeValue() === "dark"; @@ -111,9 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) { {copied ? t("code.copied") : t("code.copy")} - }> - - + {highlight ? ( + }> + + + ) : ( + + )} ); } diff --git a/webui/src/components/MarkdownText.tsx b/webui/src/components/MarkdownText.tsx index 111158968..076ad55d0 100644 --- a/webui/src/components/MarkdownText.tsx +++ b/webui/src/components/MarkdownText.tsx @@ -1,15 +1,46 @@ -import { Suspense, lazy } from "react"; +import { + Suspense, + lazy, + memo, + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { cn } from "@/lib/utils"; interface MarkdownTextProps { children: string; className?: string; + streaming?: boolean; } const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer"); const LazyMarkdownRenderer = lazy(loadMarkdownRenderer); +const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({ + source, + className, + highlightCode, +}: { + source: string; + className?: string; + highlightCode: boolean; +}) { + return ( + + {source} + + ); +}); + +const SHORT_STREAM_COMMIT_MS = 80; +const MEDIUM_STREAM_COMMIT_MS = 140; +const LONG_STREAM_COMMIT_MS = 220; + export function preloadMarkdownText(): void { void loadMarkdownRenderer(); } @@ -19,7 +50,18 @@ export function preloadMarkdownText(): void { * ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to * ``CodeBlock`` for copy-to-clipboard and syntax highlighting. */ -export function MarkdownText({ children, className }: MarkdownTextProps) { +export function MarkdownText({ + children, + className, + streaming = false, +}: MarkdownTextProps) { + const renderedSource = useStreamingMarkdownSource(children, streaming); + const highlightCode = !streaming && renderedSource === children; + + useEffect(() => { + if (streaming) preloadMarkdownText(); + }, [streaming]); + return ( - {children} + {renderedSource} } > - {children} + ); } + +function useStreamingMarkdownSource(source: string, streaming: boolean): string { + const [renderedSource, setRenderedSource] = useState(source); + const latestSourceRef = useRef(source); + const renderedSourceRef = useRef(source); + const timerRef = useRef(null); + + const clearPendingCommit = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const commitSource = useCallback((next: string, urgent: boolean) => { + if (renderedSourceRef.current === next) return; + renderedSourceRef.current = next; + if (urgent) { + setRenderedSource(next); + return; + } + startTransition(() => setRenderedSource(next)); + }, []); + + const scheduleCommit = useCallback(() => { + if (timerRef.current !== null) return; + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + commitSource(latestSourceRef.current, false); + }, streamingCommitDelay(latestSourceRef.current.length)); + }, [commitSource]); + + latestSourceRef.current = source; + + useLayoutEffect(() => { + latestSourceRef.current = source; + if (!streaming) { + clearPendingCommit(); + commitSource(source, true); + } + }, [clearPendingCommit, commitSource, source, streaming]); + + useEffect(() => { + latestSourceRef.current = source; + if (!streaming) return; + scheduleCommit(); + }, [scheduleCommit, source, streaming]); + + useEffect(() => clearPendingCommit, [clearPendingCommit]); + + return renderedSource; +} + +function streamingCommitDelay(length: number): number { + if (length > 24_000) return LONG_STREAM_COMMIT_MS; + if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS; + return SHORT_STREAM_COMMIT_MS; +} diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 17a7dc537..ff75004a7 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -1,4 +1,5 @@ -import { Children, isValidElement } from "react"; +import { Children, isValidElement, useMemo } from "react"; +import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; @@ -12,8 +13,12 @@ import "katex/dist/katex.min.css"; interface MarkdownTextRendererProps { children: string; className?: string; + highlightCode?: boolean; } +const remarkPlugins = [remarkGfm, remarkMath]; +const rehypePlugins = [rehypeKatex]; + /** * Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a * separate chunk so the app shell can paint sooner on refresh. @@ -21,7 +26,88 @@ interface MarkdownTextRendererProps { export default function MarkdownTextRenderer({ children, className, + highlightCode = true, }: MarkdownTextRendererProps) { + const components = useMemo( + () => ({ + code({ className: cls, children: kids, ...props }) { + const match = /language-(\w+)/.exec(cls || ""); + if (match) { + const code = String(kids).replace(/\n$/, ""); + return ( + + ); + } + const raw = String(kids).replace(/\n$/, ""); + /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ + const widePlainBlock = raw.includes("\n") || raw.length > 120; + if (widePlainBlock) { + return ( + + {kids} + + ); + } + return ( + + {kids} + + ); + }, + pre({ children: markdownChildren }) { + const kids = Children.toArray(markdownChildren); + const lone = kids.length === 1 ? kids[0] : null; + /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */ + if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { + return <>{markdownChildren}; + } + return ( +
+            {markdownChildren}
+          
+ ); + }, + a({ href, children: markdownChildren, ...props }) { + return ( + + {markdownChildren} + + ); + }, + }), + [highlightCode], + ); + return (
; - } - const raw = String(kids).replace(/\n$/, ""); - /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ - const widePlainBlock = raw.includes("\n") || raw.length > 120; - if (widePlainBlock) { - return ( - - {kids} - - ); - } - return ( - - {kids} - - ); - }, - pre({ children: markdownChildren }) { - const kids = Children.toArray(markdownChildren); - const lone = kids.length === 1 ? kids[0] : null; - /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */ - if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { - return <>{markdownChildren}; - } - return ( -
-                {markdownChildren}
-              
- ); - }, - a({ href, children: markdownChildren, ...props }) { - return ( - - {markdownChildren} - - ); - }, - }} + remarkPlugins={remarkPlugins} + rehypePlugins={rehypePlugins} + components={components} > {children} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index d5427ec42..98ab0c941 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -1,6 +1,5 @@ import { useCallback, - useDeferredValue, useEffect, useRef, useState, @@ -120,7 +119,7 @@ export function MessageBubble({ ) : empty && message.isStreaming ? null : ( <> - {message.content} + {message.content} {media.length > 0 ? : null} {showAssistantFooterRow ? (
@@ -480,8 +479,6 @@ export function ReasoningBubble({ embeddedInCluster = false, }: ReasoningBubbleProps) { const { t } = useTranslation(); - const deferredText = useDeferredValue(text); - const markdownSource = streaming ? deferredText : text; const [userToggled, setUserToggled] = useState(false); const [openLocal, setOpenLocal] = useState(true); const open = userToggled ? openLocal : streaming; @@ -537,6 +534,7 @@ export function ReasoningBubble({ )} > - {markdownSource} + {text}
)} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 96188e60e..116b67d62 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types"; type SettingsSectionKey = "general" | "byok"; type ByokPaneKey = "llm" | "web-search"; +const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( + ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ + name, + index, + ]), +); + interface SettingsViewProps { theme: "light" | "dark"; onToggleTheme: () => void; @@ -176,7 +183,8 @@ export function SettingsView({ if (!provider) return; const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" }; const apiKey = providerForm.apiKey.trim(); - if (!provider.configured && !apiKey) { + const apiKeyRequired = provider.api_key_required ?? true; + if (!provider.configured && apiKeyRequired && !apiKey) { setError(t("settings.byok.apiKeyRequired")); return; } @@ -917,7 +925,10 @@ function ByokSettings({ const [activePane, setActivePane] = useState("llm"); const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); const configuredProviders = settings.providers.filter((provider) => provider.configured); - const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured); + const unconfiguredProviders = useMemo( + () => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)), + [settings.providers], + ); const initialUnconfiguredCount = 6; const visibleUnconfiguredProviders = showAllUnconfigured ? unconfiguredProviders @@ -935,6 +946,12 @@ function ByokSettings({ const saving = providerSaving === provider.name; const keyVisible = !!visibleProviderKeys[provider.name]; const editingKey = !provider.configured || !!editingProviderKeys[provider.name]; + const apiKeyRequired = provider.api_key_required ?? true; + const apiKey = form.apiKey.trim(); + const apiBase = form.apiBase.trim(); + const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey; + const missingOptionalCredential = + !apiKeyRequired && !provider.configured && !apiKey && !apiBase; return (
onSaveProvider(provider.name)} - disabled={saving || (!provider.configured && !form.apiKey.trim())} + disabled={saving || missingRequiredApiKey || missingOptionalCredential} className="rounded-full" > {saving ? t("settings.actions.saving") : t("settings.actions.save")} @@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) { ); } +function orderUnconfiguredProviders( + providers: SettingsPayload["providers"], +): SettingsPayload["providers"] { + return providers + .map((provider, index) => ({ provider, index })) + .sort((left, right) => { + const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider); + return rank || left.index - right.index; + }) + .map(({ provider }) => provider); +} + +function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number { + const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name); + if (localRank !== undefined) return localRank; + if ((provider.api_key_required ?? true) === false) return 100; + return 200; +} + const PROVIDER_ICONS: Record = { custom: Hexagon, openrouter: Sparkles, @@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record = { qianfan: Database, azure_openai: Cloud, bedrock: Database, + vllm: Cpu, + ollama: Cpu, + lm_studio: Cpu, + atomic_chat: Cpu, + ovms: Cpu, + nvidia: Zap, }; function ProviderIcon({ provider }: { provider: string }) { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 0e54544b0..59ad8566c 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -110,6 +110,7 @@ export interface SettingsPayload { name: string; label: string; configured: boolean; + api_key_required?: boolean; api_key_hint?: string | null; api_base?: string | null; default_api_base?: string | null; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 7709c1c9c..f6e3f8aec 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -198,8 +198,52 @@ describe("App layout", () => { name: "openrouter", label: "OpenRouter", configured: false, + api_key_required: true, default_api_base: "https://openrouter.ai/api/v1", }, + { + name: "azure_openai", + label: "Azure OpenAI", + configured: false, + api_key_required: true, + }, + { + name: "huggingface", + label: "Hugging Face", + configured: false, + api_key_required: true, + }, + { + name: "siliconflow", + label: "SiliconFlow", + configured: false, + api_key_required: true, + }, + { + name: "volcengine", + label: "VolcEngine", + configured: false, + api_key_required: true, + }, + { + name: "byteplus", + label: "BytePlus", + configured: false, + api_key_required: true, + }, + { + name: "qianfan", + label: "Qianfan", + configured: false, + api_key_required: true, + }, + { + name: "atomic_chat", + label: "Atomic Chat", + configured: false, + api_key_required: false, + default_api_base: "http://localhost:1337/v1", + }, ], web_search: { provider: "brave", @@ -254,6 +298,9 @@ describe("App layout", () => { fireEvent.click(screen.getByText("OpenAI")); expect(screen.getByText("open••••-key")).toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Atomic Chat")); + expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); fireEvent.click(screen.getByRole("tab", { name: "Web Search" })); expect(screen.getByText("Search provider")).toBeInTheDocument(); diff --git a/webui/src/tests/code-block.test.tsx b/webui/src/tests/code-block.test.tsx index 2a96bf64d..b76aeb0d8 100644 --- a/webui/src/tests/code-block.test.tsx +++ b/webui/src/tests/code-block.test.tsx @@ -35,6 +35,18 @@ vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({ })); describe("CodeBlock", () => { + it("renders plain code without mounting the highlighter when highlighting is disabled", () => { + render( + + + , + ); + + expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument(); + expect(screen.getByText("const value = 1;")).toBeInTheDocument(); + expect(screen.getByText("ts")).toBeInTheDocument(); + }); + it("reads theme from context without creating per-block observers", async () => { const originalMutationObserver = globalThis.MutationObserver; const observer = vi.fn(); diff --git a/webui/src/tests/markdown-text.test.tsx b/webui/src/tests/markdown-text.test.tsx new file mode 100644 index 000000000..c818f2f5a --- /dev/null +++ b/webui/src/tests/markdown-text.test.tsx @@ -0,0 +1,82 @@ +import { act, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { MarkdownText } from "@/components/MarkdownText"; + +const rendererSpy = vi.hoisted(() => vi.fn()); + +vi.mock("@/components/MarkdownTextRenderer", () => ({ + default: ({ + children, + highlightCode, + }: { + children: string; + highlightCode?: boolean; + }) => { + rendererSpy({ children, highlightCode }); + return ( +
+ {children} +
+ ); + }, +})); + +describe("MarkdownText", () => { + it("throttles streaming markdown commits and flushes before final highlighting", async () => { + rendererSpy.mockClear(); + vi.useFakeTimers(); + try { + const { rerender } = render( + hello, + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-highlight-code", + "false", + ); + expect(rendererSpy).toHaveBeenCalledTimes(1); + + rerender(hello world); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); + expect(rendererSpy).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(79); + }); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); + expect(rendererSpy).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(1); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world"); + expect(rendererSpy).toHaveBeenCalledTimes(2); + + rerender(hello world!!!); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world"); + + rerender(hello world!!!); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!"); + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-highlight-code", + "true", + ); + } finally { + vi.useRealTimers(); + } + }); +});