dca3ee3484
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { WebAppFrame } from "./web-app-frame";
|
|
import { WEB_APP_STARTUP_RESULT_TYPE, WEB_APP_STARTUP_VERSION } from "./web-app-startup";
|
|
|
|
const responsive = { isMobile: false };
|
|
const theme = { resolvedTheme: "light" as "light" | "dark" };
|
|
|
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
|
useResponsiveBreakpoint: () => responsive,
|
|
}));
|
|
|
|
vi.mock("@/components/theme/app-theme", () => ({
|
|
useTheme: () => theme,
|
|
}));
|
|
|
|
function acknowledge(
|
|
frame: HTMLElement,
|
|
result: "ready" | "failed" = "ready",
|
|
code: "document_error" | "context_unavailable" = "document_error",
|
|
) {
|
|
const probe = (frame as HTMLIFrameElement).contentWindow;
|
|
const message = (
|
|
probe as unknown as { postMessage: ReturnType<typeof vi.fn> }
|
|
).postMessage.mock.calls.find(([value]) => value?.type === "kandev.web_app.startup_probe")?.[0];
|
|
if (!message) throw new Error("startup probe was not sent");
|
|
window.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
data: {
|
|
type: WEB_APP_STARTUP_RESULT_TYPE,
|
|
version: WEB_APP_STARTUP_VERSION,
|
|
nonce: message.nonce,
|
|
result,
|
|
...(result === "failed" ? { code } : {}),
|
|
},
|
|
source: probe as unknown as Window,
|
|
}),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
responsive.isMobile = false;
|
|
theme.resolvedTheme = "light";
|
|
document.documentElement.className = "";
|
|
document.documentElement.style.cssText = "";
|
|
});
|
|
|
|
describe("WebAppFrame startup", () => {
|
|
it("uses the trusted same-origin sandbox without adding host permissions", () => {
|
|
render(
|
|
<WebAppFrame runtimeUrl="/api/v1/plugins/web-apps/runtime/capability/" title="Task board" />,
|
|
);
|
|
|
|
const frame = screen.getByTitle("Task board");
|
|
expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms allow-same-origin");
|
|
expect(frame.getAttribute("allow")).toBeNull();
|
|
expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer");
|
|
expect(frame.getAttribute("src")).toContain("/api/v1/plugins/web-apps/runtime/");
|
|
});
|
|
|
|
it("waits for the current frame startup acknowledgement before revealing it", async () => {
|
|
const onLoad = vi.fn();
|
|
render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onLoad={onLoad} />);
|
|
|
|
expect(screen.getByRole("status")).not.toBeNull();
|
|
const frame = screen.getByTitle("Canvas");
|
|
const postMessage = vi.fn();
|
|
Object.defineProperty(frame, "contentWindow", {
|
|
configurable: true,
|
|
value: { postMessage },
|
|
});
|
|
fireEvent.load(frame);
|
|
expect(onLoad).not.toHaveBeenCalled();
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: "kandev.web_app.appearance",
|
|
version: 1,
|
|
mode: "light",
|
|
}),
|
|
"*",
|
|
);
|
|
expect(screen.queryByRole("status")).not.toBeNull();
|
|
acknowledge(frame);
|
|
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
|
expect(onLoad).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("WebAppFrame updates and failure handling", () => {
|
|
it("sends live resolved-theme changes without replacing the iframe", async () => {
|
|
const { rerender } = render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" />);
|
|
const frame = screen.getByTitle("Canvas");
|
|
const postMessage = vi.fn();
|
|
Object.defineProperty(frame, "contentWindow", {
|
|
configurable: true,
|
|
value: { postMessage },
|
|
});
|
|
fireEvent.load(frame);
|
|
acknowledge(frame);
|
|
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
|
const initialCallCount = postMessage.mock.calls.length;
|
|
|
|
theme.resolvedTheme = "dark";
|
|
document.documentElement.classList.add("dark");
|
|
rerender(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" />);
|
|
|
|
await waitFor(() => expect(postMessage.mock.calls.length).toBeGreaterThan(initialCallCount));
|
|
expect(postMessage.mock.calls.at(-1)?.[0]).toMatchObject({ mode: "dark" });
|
|
expect(screen.getByTitle("Canvas")).toBe(frame);
|
|
});
|
|
|
|
it("ignores wrong-frame results and reports a timeout reason at the deadline", async () => {
|
|
vi.useFakeTimers();
|
|
const onError = vi.fn();
|
|
render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
|
|
const frame = screen.getByTitle("Canvas");
|
|
const postMessage = vi.fn();
|
|
const siblingWindow = { postMessage: vi.fn() };
|
|
Object.defineProperty(frame, "contentWindow", {
|
|
configurable: true,
|
|
value: { postMessage },
|
|
});
|
|
fireEvent.load(frame);
|
|
const probe = postMessage.mock.calls.find(
|
|
([value]) => value?.type === "kandev.web_app.startup_probe",
|
|
)?.[0];
|
|
expect(probe).toBeDefined();
|
|
window.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
data: {
|
|
type: WEB_APP_STARTUP_RESULT_TYPE,
|
|
version: WEB_APP_STARTUP_VERSION,
|
|
nonce: probe.nonce,
|
|
result: "ready",
|
|
},
|
|
source: siblingWindow as unknown as Window,
|
|
}),
|
|
);
|
|
expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe("loading");
|
|
act(() => vi.advanceTimersByTime(15_000));
|
|
expect(onError).toHaveBeenCalledOnce();
|
|
expect(onError).toHaveBeenCalledWith("timeout");
|
|
expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe(
|
|
"unavailable",
|
|
);
|
|
expect(screen.queryByTitle("Canvas")).toBeNull();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it.each([["document_error"], ["context_unavailable"]] as const)(
|
|
"passes the guest-reported %s code to onError",
|
|
async (code) => {
|
|
const onError = vi.fn();
|
|
render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
|
|
const frame = screen.getByTitle("Canvas");
|
|
const postMessage = vi.fn();
|
|
Object.defineProperty(frame, "contentWindow", {
|
|
configurable: true,
|
|
value: { postMessage },
|
|
});
|
|
fireEvent.load(frame);
|
|
acknowledge(frame, "failed", code);
|
|
await waitFor(() => expect(onError).toHaveBeenCalledOnce());
|
|
expect(onError).toHaveBeenCalledWith(code);
|
|
},
|
|
);
|
|
|
|
it("uses the phone safe-area inset and renders no iframe without a capability", () => {
|
|
responsive.isMobile = true;
|
|
const { rerender } = render(<WebAppFrame title="Canvas" />);
|
|
expect(screen.queryByTitle("Canvas")).toBeNull();
|
|
expect(screen.getByTestId("web-app-frame").dataset.mobile).toBe("true");
|
|
expect(screen.getByRole("alert")).not.toBeNull();
|
|
|
|
rerender(<WebAppFrame runtimeUrl="/runtime/two/" title="Canvas" />);
|
|
expect(screen.getByTitle("Canvas")).not.toBeNull();
|
|
responsive.isMobile = false;
|
|
});
|
|
});
|