Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2bb100cd6 |
@@ -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
|
||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-embed
|
||||
dist-embed-harness
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
@@ -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>
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user