Compare commits

...

1 Commits

Author SHA1 Message Date
harry-yao_data f2bb100cd6 fix(web): show the Otto favicon in the browser tab while embedded
Embedded in the Databricks workspace, the tab kept the workspace's
Databricks favicon because the embed island never set one. Swap it to
the Otto starfish (inlined data URI) while mounted and restore the
host's icon on unmount; an operator branding favicon still wins.

The e2e mounts the real embed entry in a minimal host-shell page
(embed-harness.html) and drives mount / branding-override / unmount in
Chromium — the suite's first browser coverage of the embed island.

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: Isaac
2026-08-20 00:33:18 +00:00
10 changed files with 375 additions and 0 deletions
View File
+172
View File
@@ -0,0 +1,172 @@
"""E2E: the embed island points the host page's tab favicon at Otto.
The standalone suite drives ``main.tsx``; the embed entry (``embed.tsx``)
only ever runs inside a host application, so embed-only behavior has no
coverage in the rest of the suite. These tests mount the real
``OmnigentApp`` in a minimal host shell (``web/embed-harness.html`` +
``web/vite.embed-harness.config.ts`` — the same component the Databricks
monolith renders, bundled with its own React + react-router), serve that
build over a local HTTP server, and assert the host page's
``link[rel="icon"]``:
- becomes the Otto starfish (an inlined SVG data URI) while the embed is
mounted, replacing the host page's own icon,
- prefers the operator branding favicon when ``/v1/info`` advertises one,
- is restored (href AND type) when the host navigates away and the island
unmounts — the host must get its own tab icon back.
Part of the gated e2e suite (needs ``npm`` + a vite build); see this
package's ``conftest`` module docstring for how the suite is run and
excluded from the default ``pytest`` run.
"""
from __future__ import annotations
import base64
import functools
import http.server
import os
import re
import subprocess
import threading
from collections.abc import Iterator
from pathlib import Path
import pytest
from playwright.sync_api import Page, expect
_REPO_ROOT = Path(__file__).resolve().parents[3]
_WEB_DIR = _REPO_ROOT / "web"
# The host shell's own icon, declared in embed-harness.html — what the
# workspace tab would show without the embed.
_HOST_FAVICON_HREF = "/host-favicon.ico"
_HOST_FAVICON_TYPE = "image/x-icon"
_BRANDING_FAVICON_HREF = "/v1/branding/logo/favicon"
# Otto is inlined as a data: URI by the `?inline` import in
# web/src/lib/documentFavicon.ts; the URL-encoded magenta fill is the
# starfish's signature color (#F43BA6).
_OTTO_DATA_URI = re.compile(r"^data:image/svg\+xml")
_OTTO_MAGENTA = "%23F43BA6"
_LOGO_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
@pytest.fixture(scope="module")
def embed_harness_build(built_spa: None, tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Build the embed host-shell page into an isolated dir.
:param built_spa: Depended on only to guarantee the toolchain is
installed (``pnpm install``); the harness build does not use the
standalone output.
:param tmp_path_factory: Isolated ``--outDir`` so this never clobbers a
real build.
:returns: Directory containing ``embed-harness.html`` + hashed assets.
"""
out = tmp_path_factory.mktemp("embed-harness")
subprocess.run(
["pnpm", "run", "build:embed-harness", "--outDir", str(out)],
cwd=_WEB_DIR,
check=True,
stdin=subprocess.DEVNULL,
env={**os.environ, "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"},
)
# Guard against a vacuous pass: if the `--outDir` override is ever
# dropped, the build lands elsewhere and the server below would 404
# every request — fail here with the real cause instead.
if not (out / "embed-harness.html").is_file():
pytest.fail(
f"embed harness build produced no embed-harness.html in {out} — the "
"--outDir override was not honored"
)
return out
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
"""SimpleHTTPRequestHandler without the per-request stderr log."""
def log_message(self, format: str, *args: object) -> None:
pass
@pytest.fixture(scope="module")
def embed_harness_url(embed_harness_build: Path) -> Iterator[str]:
"""Serve the harness build over an ephemeral loopback HTTP server.
A real server (rather than ``page.route`` file interception) keeps
module-script MIME handling and lazy-chunk loading identical to how a
host page serves the island.
"""
handler = functools.partial(_QuietHandler, directory=str(embed_harness_build))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}"
finally:
server.shutdown()
server.server_close()
def _stub_info(page: Page, branding: object) -> None:
"""Serve ``/v1/info`` carrying only ``branding``; the SPA defaults the rest."""
page.route("**/v1/info", lambda route: route.fulfill(json={"branding": branding}))
page.route(
"**/v1/branding/logo/**",
lambda route: route.fulfill(status=200, content_type="image/png", body=_LOGO_PNG),
)
def test_embed_tab_favicon_is_otto_while_mounted(page: Page, embed_harness_url: str) -> None:
"""The embed swaps the host's tab icon to Otto and restores it on unmount."""
_stub_info(page, None)
page.goto(f"{embed_harness_url}/embed-harness.html")
icon = page.locator('head link[rel="icon"]')
# Once the island mounts, the host icon is replaced by the inlined Otto
# SVG and the host's type hint is cleared (it described the .ico).
expect(icon).to_have_attribute("href", _OTTO_DATA_URI, timeout=30_000)
href = icon.get_attribute("href") or ""
assert _OTTO_MAGENTA in href.upper(), (
f"favicon data URI is not the Otto starfish: {href[:120]}"
)
assert icon.get_attribute("type") is None
# Host navigates away → island unmounts → the host's own icon (href and
# type) is restored.
page.get_by_test_id("host-nav-toggle").click()
expect(icon).to_have_attribute("href", re.compile(re.escape(_HOST_FAVICON_HREF) + r"$"))
assert icon.get_attribute("type") == _HOST_FAVICON_TYPE
# Navigating back re-mounts the island → Otto again.
page.get_by_test_id("host-nav-toggle").click()
expect(icon).to_have_attribute("href", _OTTO_DATA_URI)
def test_embed_tab_favicon_prefers_operator_branding(page: Page, embed_harness_url: str) -> None:
"""An operator-configured branding favicon wins over the Otto default."""
_stub_info(
page,
{
"app_name": "Acme Agent",
"heading": None,
"logos": {"main": None, "loading": None, "favicon": _BRANDING_FAVICON_HREF},
"powered_by": True,
},
)
page.goto(f"{embed_harness_url}/embed-harness.html")
icon = page.locator('head link[rel="icon"]')
# The Otto default applies at mount; once the stubbed /v1/info resolves,
# the branding favicon replaces it.
expect(icon).to_have_attribute(
"href", re.compile(re.escape(_BRANDING_FAVICON_HREF) + r"$"), timeout=30_000
)
# Unmount still restores the host icon, not the branding one.
page.get_by_test_id("host-nav-toggle").click()
expect(icon).to_have_attribute("href", re.compile(re.escape(_HOST_FAVICON_HREF) + r"$"))
assert icon.get_attribute("type") == _HOST_FAVICON_TYPE
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-embed
dist-embed-harness
dist-ssr
coverage
*.local
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--
Simulates the host workspace's own tab icon (e.g. the Databricks
favicon): the embed island must replace it while mounted and restore it
when the host navigates away. The 404 on the .ico itself is fine — the
tests only read the link element's attributes.
-->
<link rel="icon" type="image/x-icon" href="/host-favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Host workspace shell</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/embed-harness/main.tsx"></script>
</body>
</html>
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"build:embed": "vite build --config vite.embed.config.ts",
"build:embed-harness": "vite build --config vite.embed-harness.config.ts",
"type-check": "tsc -b",
"preview": "vite preview",
"lint": "oxlint --deny-warnings --report-unused-disable-directives .",
+63
View File
@@ -0,0 +1,63 @@
// Test-only host shell for the embed island.
//
// Mounts `OmnigentApp` — the real embed entry (src/embed.tsx), the same
// component the Databricks monolith renders — inside a minimal host page so
// e2e tests can drive embed-only behavior (the document favicon swap, the
// host navigating away and unmounting the island) without the monolith.
// Built by vite.embed-harness.config.ts and served statically by
// tests/e2e_ui/embed/. Unlike the intermediate embed build
// (vite.embed.config.ts) this is an app build: React + react-router are
// bundled, since there is no host rspack to supply them.
import { useState } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { OmnigentApp } from "../embed";
/**
* Minimal stand-in for the host workspace shell: a chrome bar the island does
* not own, plus a toggle that unmounts/remounts the island the way host-side
* navigation does.
*/
function HostShell() {
const [embedMounted, setEmbedMounted] = useState(true);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<header
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 16px",
borderBottom: "1px solid #ccc",
fontFamily: "sans-serif",
}}
>
<strong data-testid="host-chrome">Host workspace shell</strong>
<button
type="button"
data-testid="host-nav-toggle"
onClick={() => setEmbedMounted((v) => !v)}
>
{embedMounted ? "Navigate away" : "Navigate back"}
</button>
</header>
<div style={{ flex: 1, minHeight: 0 }}>
{embedMounted ? (
<OmnigentApp />
) : (
<p data-testid="host-other-page" style={{ fontFamily: "sans-serif" }}>
Host page without the embed
</p>
)}
</div>
</div>
);
}
createRoot(document.getElementById("root")!).render(
<MemoryRouter>
<HostShell />
</MemoryRouter>,
);
+10
View File
@@ -32,7 +32,9 @@ import { ImageLightboxProvider } from "./components/ImageLightbox";
import { RunnerHealthProvider } from "./hooks/RunnerHealthProvider";
import { CapabilitiesContext } from "./lib/CapabilitiesContext";
import { createBootServerInfo } from "./lib/bootCapabilities";
import { useBranding } from "./lib/branding";
import { resolveServerInfo, type ServerInfo } from "./lib/capabilities";
import { useDocumentFavicon } from "./lib/documentFavicon";
import { EmbeddedProvider } from "./lib/embedded";
import { type OmnigentHostConfig, setEmbedRoot, setOmnigentHostConfig } from "./lib/host";
import { resolveIdentity } from "./lib/identity";
@@ -131,6 +133,13 @@ function EmbedCapabilitiesProvider({ children }: { children: ReactNode }) {
return <CapabilitiesContext.Provider value={info}>{children}</CapabilitiesContext.Provider>;
}
/** Tab favicon while embedded: the operator's branding favicon when set, else Otto. */
function EmbedDocumentFavicon() {
const { logos } = useBranding();
useDocumentFavicon(logos.favicon);
return null;
}
function OmnigentProviders({
routing,
basename,
@@ -191,6 +200,7 @@ function OmnigentProviders({
<ImageLightboxProvider>
<RoutingProvider value={routing}>
<EmbedCapabilitiesProvider>
<EmbedDocumentFavicon />
<SessionUpdatesProvider>
<RunnerHealthProvider>
<QueueFlushProvider>
+43
View File
@@ -0,0 +1,43 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useDocumentFavicon } from "./documentFavicon";
function TestFavicon({ brandingFavicon }: { brandingFavicon: string | null }) {
useDocumentFavicon(brandingFavicon);
return null;
}
describe("useDocumentFavicon", () => {
let link: HTMLLinkElement;
beforeEach(() => {
link = document.createElement("link");
link.rel = "icon";
link.setAttribute("type", "image/x-icon");
link.setAttribute("href", "https://workspace.example/favicon.ico");
document.head.appendChild(link);
});
afterEach(() => {
cleanup();
link.remove();
});
it("points the tab icon at the Otto starfish", () => {
render(<TestFavicon brandingFavicon={null} />);
expect(link.href).toMatch(/^data:image\/svg\+xml/);
});
it("prefers an operator branding favicon when configured", () => {
render(<TestFavicon brandingFavicon="/v1/branding/logo/favicon" />);
expect(link.href).toMatch(/\/v1\/branding\/logo\/favicon$/);
});
it("restores the host page's icon on unmount", () => {
const { unmount } = render(<TestFavicon brandingFavicon={null} />);
unmount();
expect(link.getAttribute("href")).toBe("https://workspace.example/favicon.ico");
expect(link.getAttribute("type")).toBe("image/x-icon");
});
});
+25
View File
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import ottoFaviconUrl from "@/assets/otto-no-padding.svg?inline";
/**
* Point the tab favicon at the Otto starfish while mounted; restore the host
* page's icon on unmount. The embed renders inside a host page (e.g. the
* Databricks workspace) whose own favicon would otherwise keep showing.
*/
export function useDocumentFavicon(brandingFavicon: string | null): void {
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>('head link[rel~="icon"]');
if (!link) return;
const prevHref = link.getAttribute("href");
const prevType = link.getAttribute("type");
link.removeAttribute("type");
link.href = brandingFavicon ?? ottoFaviconUrl;
return () => {
if (prevType === null) link.removeAttribute("type");
else link.setAttribute("type", prevType);
if (prevHref === null) link.removeAttribute("href");
else link.setAttribute("href", prevHref);
};
}, [brandingFavicon]);
}
+41
View File
@@ -0,0 +1,41 @@
// Build for the embed test-harness page.
//
// Produces a tiny standalone host page (embed-harness.html + hashed JS/CSS)
// that mounts the REAL embed entry (src/embed.tsx → OmnigentApp) inside a
// minimal host shell — the same component the Databricks monolith renders,
// but bundled with its own React + react-router so it runs without the
// monolith. tests/e2e_ui/embed/ serves this output statically and drives
// embed-only behavior (e.g. the document favicon swap) in a real browser.
// Run via `pnpm run build:embed-harness`.
//
// Unlike vite.embed.config.ts (the intermediate library build that leaves
// React / react-router as bare externals for the monolith's rspack), this is
// an app build: nothing is external and the page is self-contained. Kept out
// of the main app build so it emits no service worker and never ships.
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
// Served from an ephemeral local HTTP server in tests; relative asset URLs
// keep the page loadable from any mount path.
base: "./",
// The harness has no use for the web app's public/ assets (PWA icons,
// favicon.svg) — the host page supplies its own <link rel="icon">.
publicDir: false,
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: path.resolve(__dirname, "./dist-embed-harness"),
emptyOutDir: true,
rollupOptions: {
input: path.resolve(__dirname, "./embed-harness.html"),
},
},
});