// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { MemoryRouter } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const clientState = vi.hoisted(() => { const legacyMutateAsync = vi.fn().mockImplementation(async () => ({ startUrl: "about:blank", })); const workspaceSsoMutateAsync = vi.fn().mockImplementation(async () => ({ startUrl: "about:blank", })); const legacyErrorMutateAsync = vi.fn(); const actionNames: string[] = []; const actionQueryParams: Array<{ name: string; params?: unknown; }> = []; const actionQueryOptions: Array<{ name: string; options?: Record; }> = []; return { actionNames, actionQueryParams, actionQueryOptions, workspaceApps: null as Array> | null, grantedApps: [ { id: "analytics.agent-native.com", name: "Analytics", url: "https://analytics.agent-native.com", }, ], legacyMutateAsync, legacyMutateError: null as Error | null, legacyErrorMutateAsync, inBuilderFrame: false, frameLoadHandler: null as (() => void) | null, suppressFrameLoad: false, theme: "dark" as "dark" | "light", workspaceSsoEnabled: false, workspaceSsoMutateAsync, }; }); vi.mock("@agent-native/core/client/chat-first", () => ({ CHAT_FIRST_DEFAULT_APP_IDS: [ "content", "design", "mail", "calendar", "clips", ], ChatFirstAppPane: ({ app, embedUrl, errorMessage, renderEmbed, status, }: { app: { name: string } | null; embedUrl?: string | null; errorMessage?: string; renderEmbed: (target: { url: string; title?: string }) => React.ReactNode; status: string; }) => (
{status === "ready" && embedUrl ? (() => { const frame = renderEmbed({ url: embedUrl, title: app?.name }); if (clientState.suppressFrameLoad && React.isValidElement(frame)) { clientState.frameLoadHandler = (frame.props as { onLoad?: () => void }).onLoad ?? null; return React.cloneElement(frame, { onLoad: undefined, src: undefined, }); } return frame; })() : null}
), defaultChatFirstCopy: (key: string) => key, })); vi.mock("@agent-native/core/client/feature-flags", () => ({ useFeatureFlag: () => clientState.workspaceSsoEnabled, })); vi.mock("@agent-native/core/client/host", () => ({ isInBuilderFrame: () => clientState.inBuilderFrame, })); vi.mock("@agent-native/core/client/hooks", () => ({ useActionMutation: (name: string) => { clientState.actionNames.push(name); return { mutateAsync: name === "create-workspace-app-embed-session" ? clientState.workspaceSsoMutateAsync : clientState.legacyMutateError ? clientState.legacyErrorMutateAsync : clientState.legacyMutateAsync, }; }, useActionQuery: (name: string, params?: unknown, options?: unknown) => { clientState.actionQueryParams.push({ name, params }); clientState.actionQueryOptions.push({ name, options: options && typeof options === "object" ? (options as Record) : undefined, }); return { data: name === "list_apps" ? { apps: clientState.grantedApps } : (clientState.workspaceApps ?? [ { id: "mail", name: "Mail", path: "/mail", url: null, status: "ready", }, { id: "calendar", name: "Calendar", path: "/calendar", url: null, status: "ready", }, { id: "documents", name: "Documents", path: "/documents", url: null, status: "ready", }, { id: "settings", name: "Settings", path: "/settings", url: null, status: "ready", }, ]), isError: false, isLoading: false, refetch: vi.fn(), }; }, })); vi.mock("@agent-native/core/client/i18n", () => ({ useT: () => (key: string) => key, })); vi.mock("next-themes", () => ({ useTheme: () => ({ resolvedTheme: clientState.theme }), })); import { WorkspaceAppFrame, WorkspaceAppKeepAlive } from "./workspace-app-host"; describe("WorkspaceAppKeepAlive", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); clientState.actionNames.length = 0; clientState.actionQueryParams.length = 0; clientState.actionQueryOptions.length = 0; clientState.workspaceApps = null; clientState.legacyMutateAsync.mockImplementation(async () => ({ startUrl: "about:blank", })); clientState.legacyMutateAsync.mockClear(); clientState.legacyMutateError = null; clientState.legacyErrorMutateAsync.mockReset(); clientState.inBuilderFrame = false; clientState.frameLoadHandler = null; clientState.suppressFrameLoad = false; clientState.workspaceSsoMutateAsync.mockClear(); clientState.theme = "dark"; clientState.workspaceSsoEnabled = false; }); afterEach(() => { act(() => root.unmount()); container.remove(); Object.defineProperty(window, "parent", { configurable: true, value: window, }); Object.defineProperty(window, "top", { configurable: true, value: window, }); vi.unstubAllGlobals(); }); it("keeps visited app frames in the DOM while hiding inactive apps", async () => { await act(async () => { root.render(); await Promise.resolve(); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); await act(async () => { root.render(); await Promise.resolve(); }); const mailEntry = container.querySelector( '[data-dispatch-workspace-app-cache-entry="mail"]', ); const calendarEntry = container.querySelector( '[data-dispatch-workspace-app-cache-entry="calendar"]', ); expect(mailEntry).not.toBeNull(); expect(mailEntry?.classList.contains("hidden")).toBe(true); expect(mailEntry?.querySelector("iframe")).not.toBeNull(); expect(calendarEntry?.classList.contains("hidden")).toBe(false); expect(calendarEntry?.querySelector("iframe")).not.toBeNull(); expect(container.querySelectorAll("iframe")).toHaveLength(2); expect( clientState.actionQueryParams.find( (query) => query.name === "list-workspace-apps", )?.params, ).toEqual({ includeAgentCards: false, includeArchived: true }); expect( clientState.actionQueryOptions.find((query) => query.name === "list_apps") ?.options?.enabled, ).toBe(false); }); it("resolves a granted external app instead of showing app not found", async () => { await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect( container.querySelector( '[data-dispatch-workspace-app-cache-entry="analytics.agent-native.com"]', ), ).not.toBeNull(); expect( container.querySelector('[data-chat-first-app-status="ready"]'), ).not.toBeNull(); expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({ app: "analytics.agent-native.com", url: "https://analytics.agent-native.com", chrome: "minimal", }); expect( clientState.actionQueryOptions.find((query) => query.name === "list_apps") ?.options?.enabled, ).toBe(true); }); it("does not resolve archived workspace apps from granted discovery", async () => { clientState.workspaceApps = [ { id: "analytics.agent-native.com", name: "Hidden app", path: "/hidden-app", url: null, status: "ready", archived: true, }, ]; await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect( container.querySelector('[data-chat-first-app-status="ready"]'), ).toBeNull(); expect(clientState.legacyMutateAsync).not.toHaveBeenCalled(); }); it("uses the app-scoped workspace session action when the rollout is enabled", async () => { clientState.workspaceSsoEnabled = true; await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect(clientState.workspaceSsoMutateAsync).toHaveBeenCalledWith({ app: "mail", url: "https://mail.agent-native.com", chrome: "minimal", }); expect(clientState.legacyMutateAsync).not.toHaveBeenCalled(); }); it("uses the granted-app session when a mounted app reuses a canonical id", async () => { clientState.workspaceSsoEnabled = true; await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({ app: "mail", url: "https://agent-workspace.builder.io/mail", chrome: "minimal", }); expect(clientState.workspaceSsoMutateAsync).not.toHaveBeenCalled(); }); it("uses the granted-app session action for mounted apps outside the SSO registry", async () => { clientState.workspaceSsoEnabled = true; await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({ app: "feedback-leaderboard", url: "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard", chrome: "minimal", }); expect(clientState.workspaceSsoMutateAsync).not.toHaveBeenCalled(); }); it("mints custom SSO before navigating a Builder-hosted surface", async () => { clientState.workspaceSsoEnabled = true; clientState.inBuilderFrame = true; const navigateToTopWindow = vi.fn(() => true); Object.defineProperty(window, "parent", { configurable: true, value: {}, }); await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect(clientState.workspaceSsoMutateAsync).toHaveBeenCalledWith({ app: "custom-sso", url: "https://custom.example/custom-sso", chrome: "minimal", }); expect(navigateToTopWindow).toHaveBeenCalledWith("about:blank"); expect(clientState.legacyMutateAsync).not.toHaveBeenCalled(); expect(container.querySelector("iframe")).toBeNull(); }); it("sends the parent theme on iframe load and when the parent changes", async () => { await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); const iframe = container.querySelector("iframe"); expect(iframe).not.toBeNull(); if (!iframe) throw new Error("Workspace app iframe was not rendered"); const postMessage = vi.fn(); Object.defineProperty(iframe, "contentWindow", { configurable: true, value: { postMessage }, }); await act(async () => { iframe?.dispatchEvent(new Event("load")); await Promise.resolve(); }); expect(postMessage).toHaveBeenCalledWith( { type: "agent-native-theme-update", theme: "dark", isDark: true, }, "*", ); clientState.theme = "light"; await act(async () => { root.render( , ); await Promise.resolve(); }); expect(postMessage).toHaveBeenLastCalledWith( { type: "agent-native-theme-update", theme: "light", isDark: false, }, "*", ); }); it("reports trusted child routes to the standalone Dispatch host", async () => { clientState.legacyMutateAsync.mockResolvedValue({ startUrl: "https://design.example.test/_agent-native/embed", }); clientState.suppressFrameLoad = true; const onChildRouteChange = vi.fn(); await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); const iframe = container.querySelector("iframe"); expect(iframe).not.toBeNull(); if (!iframe) throw new Error("Workspace app iframe was not rendered"); const frameWindow = iframe.contentWindow; expect(frameWindow).not.toBeNull(); if (!frameWindow) throw new Error("Workspace app frame window is missing"); await act(async () => { window.dispatchEvent( new MessageEvent("message", { data: { type: "agent-native:workspace-app-route", path: "/foobar?mode=edit#canvas", }, origin: "https://design.example.test", source: frameWindow, }), ); await Promise.resolve(); }); expect(onChildRouteChange).toHaveBeenCalledWith( "/apps/design/foobar?mode=edit#canvas", ); await act(async () => { window.dispatchEvent( new MessageEvent("message", { data: { type: "agent-native:workspace-app-route", path: "/ignored", }, origin: "https://evil.example.test", source: frameWindow, }), ); window.dispatchEvent( new MessageEvent("message", { data: { type: "agent-native:workspace-app-route", path: "/ignored", }, origin: "https://design.example.test", source: {} as Window, }), ); await Promise.resolve(); }); expect(onChildRouteChange).toHaveBeenCalledTimes(1); }); it("seeds a standalone iframe from its initial Dispatch route", async () => { await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({ app: "design", path: "/foobar?mode=edit#canvas", chrome: "minimal", }); }); it("clears the embed error after a direct fallback iframe loads", async () => { clientState.legacyMutateError = new Error("Restore request failed"); clientState.legacyErrorMutateAsync.mockRejectedValue( clientState.legacyMutateError, ); clientState.suppressFrameLoad = true; await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); const iframe = container.querySelector("iframe"); expect(iframe).not.toBeNull(); expect( container .querySelector("[data-chat-first-app-error]") ?.getAttribute("data-chat-first-app-error"), ).toBe("Restore request failed"); if (!iframe) throw new Error("Workspace app iframe was not rendered"); expect(clientState.frameLoadHandler).not.toBeNull(); await act(async () => { clientState.frameLoadHandler?.(); await Promise.resolve(); }); expect( container .querySelector("[data-chat-first-app-error]") ?.getAttribute("data-chat-first-app-error"), ).toBe(""); }); it("does not expose a child login page when the workspace SSO exchange fails", async () => { clientState.workspaceSsoEnabled = true; clientState.workspaceSsoMutateAsync.mockRejectedValue( new Error("Workspace SSO is temporarily unavailable"), ); await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); expect(container.querySelector("iframe")).toBeNull(); expect( container .querySelector("[data-chat-first-app-error]") ?.getAttribute("data-chat-first-app-error"), ).toBe("Workspace SSO is temporarily unavailable"); }); it("keeps a normal iframe app inline", async () => { const topWindow = { location: { href: "" } } as unknown as Window; Object.defineProperty(window, "parent", { configurable: true, value: {}, }); Object.defineProperty(window, "top", { configurable: true, value: topWindow, }); await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(topWindow.location.href).toBe(""); expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({ app: "mail", path: "/mail", chrome: "minimal", }); expect(container.querySelector("iframe")).not.toBeNull(); }); it("falls back to the embedded app when top-window navigation is blocked", async () => { const navigateToTopWindow = vi.fn(() => false); clientState.inBuilderFrame = true; Object.defineProperty(window, "parent", { configurable: true, value: {}, }); await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(navigateToTopWindow).toHaveBeenCalledWith("/mail"); expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({ app: "mail", path: "/mail", chrome: "minimal", }); expect(container.querySelector("iframe")).not.toBeNull(); }); it("preserves a chat-first deep route in the top window when embedded", async () => { const topWindow = { location: { href: "" } } as unknown as Window; const embedPath = "/emails?status=failed#latest"; const expectedUrl = new URL( "/mail/emails?status=failed#latest", window.location.href, ).href; clientState.inBuilderFrame = true; Object.defineProperty(window, "parent", { configurable: true, value: {}, }); Object.defineProperty(window, "top", { configurable: true, value: topWindow, }); await act(async () => { root.render( , ); await Promise.resolve(); }); expect(topWindow.location.href).toBe(expectedUrl); expect(clientState.legacyMutateAsync).not.toHaveBeenCalled(); expect(container.querySelector("iframe")).toBeNull(); }); it("opens the app in the top window for a Builder webview", async () => { const topWindow = { location: { href: "" } } as unknown as Window; const expectedUrl = new URL("/mail", window.location.href).href; clientState.inBuilderFrame = true; Object.defineProperty(window, "top", { configurable: true, value: topWindow, }); await act(async () => { root.render( , ); await Promise.resolve(); }); expect(topWindow.location.href).toBe(expectedUrl); expect(clientState.legacyMutateAsync).not.toHaveBeenCalled(); expect(container.querySelector("iframe")).toBeNull(); }); it("evicts the oldest inactive app after reaching the keep-alive limit", async () => { await act(async () => { root.render(); await Promise.resolve(); }); for (const activeAppId of ["calendar", "documents", "settings"]) { await act(async () => { root.render(); await Promise.resolve(); }); } expect( container.querySelector( '[data-dispatch-workspace-app-cache-entry="mail"]', ), ).toBeNull(); expect( container.querySelector( '[data-dispatch-workspace-app-cache-entry="calendar"]', ), ).not.toBeNull(); expect( container.querySelector( '[data-dispatch-workspace-app-cache-entry="documents"]', ), ).not.toBeNull(); expect( container.querySelector( '[data-dispatch-workspace-app-cache-entry="settings"]', ), ).not.toBeNull(); expect( container.querySelectorAll("[data-dispatch-workspace-app-cache-entry]"), ).toHaveLength(3); expect(container.querySelectorAll("iframe")).toHaveLength(3); }); });