// @vitest-environment jsdom import { act } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime"; import { createCustomJsxComponents } from "../jsx"; import { MAX_REFRESH_INTERVAL_MS, MAX_REFRESH_INTERVAL_SECONDS, normalizeRefreshInterval } from "../runtime/sub-fetch"; import type { CustomJsxRequestCapability, CustomWidgetPublishedQueryState, CustomWidgetRequestResult, CustomWidgetRuntimeMessages, CustomWidgetRuntimePort, } from "../runtime"; const messages: CustomWidgetRuntimeMessages = { requestIdRequired: "Request ID required", unsavedPreview: "Unsaved", invalidParams: "Invalid params", loadRequest: "Load", requestFailed: "Failed", loading: "Loading", retry: "Retry", widgetItemUnavailable: "Unavailable", actionsDisabledEditMode: "Disabled", actionSimulated: "Simulated", actionCompleted: "Completed", confirmDelete: "Confirm delete", toggle: "Toggle", refresh: "Refresh", }; let root: Root; let host: HTMLDivElement; beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; window.matchMedia = vi.fn().mockImplementation(() => ({ matches: false, media: "", onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })); host = document.createElement("div"); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(() => root.unmount()); host.remove(); vi.restoreAllMocks(); }); function createPort(overrides: Partial = {}): CustomWidgetRuntimePort { return { query: vi.fn(async () => ({ ok: true, status: 200, data: { name: "Bulbasaur" } })), executeAction: vi.fn(async () => ({ ok: true, status: 200, data: null })), invalidate: vi.fn(async () => undefined), confirm: vi.fn(async () => true), notify: vi.fn(), ...overrides, }; } async function render( node: ReactNode, port: CustomWidgetRuntimePort, capabilities: readonly CustomJsxRequestCapability[] = [], setQueryState?: (requestId: string, value: CustomWidgetPublishedQueryState | null) => void, ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { root.render( {node} , ); }); } async function settle() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } describe("Custom Widget runtime ports", () => { it("clamps refresh intervals below the browser timer overflow boundary", () => { expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS)).toBe(MAX_REFRESH_INTERVAL_MS); expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS + 1)).toBe(MAX_REFRESH_INTERVAL_MS); expect(normalizeRefreshInterval(Number.MAX_VALUE)).toBe(MAX_REFRESH_INTERVAL_MS); expect(MAX_REFRESH_INTERVAL_MS).toBeLessThan(2 ** 31 - 1); }); it("routes binding-construction failures through the runtime error display", async () => { await render( null) as never }} createBindings={() => { throw new RangeError("Response data exceeded the depth limit"); }} messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }} />, createPort(), ); expect(host.textContent).toContain("RUNTIME_RENDER_ERROR"); expect(host.textContent).toContain("Response data exceeded the depth limit"); }); it("renders common safe collection and number formatting operations", async () => { const components = createCustomJsxComponents({ TablerIcon: (() => null) as never, copyLabels: { copy: "Copy", copied: "Copied" }, }); await render( {[1, 2].concat([3]).flatMap(value => [value]).map(value => value.toLocaleString()).join(" · ")}' } data={{}} components={components} createBindings={() => ({})} messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }} />, createPort(), ); expect(host.textContent).toContain("1 · 2 · 3"); }); it("removes unsafe dynamic link targets at runtime", async () => { const components = createCustomJsxComponents({ TablerIcon: (() => null) as never, copyLabels: { copy: "Copy", copied: "Copied" }, }); const rendererMessages = { noTemplate: "No template", templateWarnings: (count: number) => `${count} warnings`, }; await render( Unsafe"} data={{ url: "//example.com/escape" }} components={components} createBindings={(data) => ({ data })} messages={rendererMessages} />, createPort(), ); expect(host.querySelector("a")?.hasAttribute("href")).toBe(false); }); it("scopes authored radio names to the widget and preserves uncontrolled defaults", async () => { const components = createCustomJsxComponents({ TablerIcon: (() => null) as never, copyLabels: { copy: "Copy", copied: "Copied" }, }); const rendererMessages = { noTemplate: "No template", templateWarnings: (count: number) => `${count} warnings`, }; await render( <> ' } data={{}} components={components} createBindings={() => ({})} messages={rendererMessages} /> , createPort(), ); await settle(); const outside = host.querySelector('input[type="radio"]'); const [inside, other] = [...host.querySelectorAll('button[role="radio"]')]; expect(outside?.name).toBe("shared"); expect(inside?.name).toMatch(/^custom-widget-/u); expect(inside?.name).not.toBe(outside?.name); expect(other?.name).toBe(inside?.name); expect(inside?.getAttribute("aria-checked")).toBe("true"); expect(other?.getAttribute("aria-checked")).toBe("false"); }); it("exposes temporary bindings through inputs without browser storage", async () => { const setItem = vi.spyOn(Storage.prototype, "setItem"); const rendererMessages = { noTemplate: "No template", templateWarnings: (count: number) => `${count} warnings`, }; const components = createCustomJsxComponents({ TablerIcon: (() => null) as never, copyLabels: { copy: "Copy", copied: "Copied" }, }); const widget = ( {inputs.search}'} data={{}} components={components} createBindings={() => ({})} messages={rendererMessages} /> ); await render(widget, createPort()); await settle(); expect(host.textContent).toContain("containers"); expect(setItem).not.toHaveBeenCalled(); }); it("preserves input focus across temporary binding updates", async () => { const components = createCustomJsxComponents({ TablerIcon: (() => null) as never, copyLabels: { copy: "Copy", copied: "Copied" }, }); await render( {inputs.search}'} data={{}} components={components} createBindings={() => ({})} messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }} />, createPort(), ); await settle(); const input = host.querySelector("input") as HTMLInputElement; input.focus(); await act(async () => { input.value = "p"; input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "p", inputType: "insertText" })); }); await settle(); expect(input.isConnected).toBe(true); expect(document.activeElement).toBe(input); expect(input.value).toBe("p"); expect(host.textContent).toContain("p"); }); it("resets temporary bindings when the template changes", async () => { const rendererMessages = { noTemplate: "No template", templateWarnings: (count: number) => `${count} warnings`, }; const components = createCustomJsxComponents({ TablerIcon: (() => null) as never, copyLabels: { copy: "Copy", copied: "Copied" }, }); await render( {inputs.shared}'} data={{}} components={components} createBindings={() => ({})} messages={rendererMessages} />, createPort(), ); await settle(); expect(host.textContent).toContain("text"); await render( {inputs.shared ? "enabled" : "disabled"}' } data={{}} components={components} createBindings={() => ({})} messages={rendererMessages} />, createPort(), ); await settle(); expect(host.textContent).toContain("enabled"); expect(host.textContent).not.toContain("BINDING_TYPE_CONFLICT"); }); it("preserves temporary bindings when data changes without changing the template", async () => { const rendererMessages = { noTemplate: "No template", templateWarnings: (count: number) => `${count} warnings`, }; const components = createCustomJsxComponents({ TablerIcon: (() => null) as never, copyLabels: { copy: "Copy", copied: "Copied" }, }); const template = '{inputs.shared ? "enabled" : "disabled"}'; await render( ({ data })} messages={rendererMessages} />, createPort(), ); await settle(); await act(async () => (host.querySelector("input") as HTMLInputElement).click()); await settle(); expect(host.textContent).toContain("enabled"); await render( ({ data })} messages={rendererMessages} />, createPort(), ); await settle(); expect((host.querySelector("input") as HTMLInputElement).checked).toBe(true); expect(host.textContent).toContain("enabled"); }); it("runs an automatic query once and publishes data to the declarative data root", async () => { const port = createPort(); const publish = vi.fn(); await render(, port, [], publish); await settle(); expect(port.query).toHaveBeenCalledTimes(1); expect(publish).toHaveBeenLastCalledWith("details", { data: { name: "Bulbasaur" }, status: expect.objectContaining({ loading: false, ok: true, status: 200 }), }); expect(host.querySelector("button, p, code, [role='alert']")).toBeNull(); }); it("uses a built-in manual trigger and passes successful request metadata to children", async () => { const port = createPort(); await render( {(data, metadata) => ( {`${(data as { name: string }).name}:${metadata.status}:${String(metadata.loading)}`} )} , port, ); expect(port.query).not.toHaveBeenCalled(); expect(host.textContent).toContain("Load"); await act(async () => (host.querySelector("button") as HTMLButtonElement).click()); await settle(); expect(port.query).toHaveBeenCalledOnce(); expect(host.textContent).toContain("Bulbasaur:200:false"); }); it("keeps manual query results local instead of publishing a shared request slot", async () => { const port = createPort(); const publish = vi.fn(); await render( Bulbasaur card}> {(data) => {(data as { name: string }).name}} , port, [], publish, ); await act(async () => (host.querySelector("button") as HTMLButtonElement).click()); await settle(); expect(port.query).toHaveBeenCalledOnce(); expect(publish).not.toHaveBeenCalled(); expect(host.textContent).toContain("Bulbasaur"); }); it("runs a manual query from a custom accessible trigger", async () => { const port = createPort(); await render( Bulbasaur card} > {(data) => {(data as { name: string }).name}} , port, ); const trigger = host.querySelector("button") as HTMLButtonElement; expect(trigger.getAttribute("aria-label")).toBe("Open Bulbasaur"); expect(host.textContent).toContain("Bulbasaur card"); await act(async () => trigger.click()); await settle(); expect(port.query).toHaveBeenCalledOnce(); expect(host.textContent).toContain("Bulbasaur"); }); it("passes cancellation to the port when the component unmounts", async () => { let signal: AbortSignal | undefined; const port = createPort({ query: vi.fn((_input, nextSignal) => { signal = nextSignal; return new Promise(() => undefined); }), }); await render(, port); await act(() => root.unmount()); expect(signal?.aborted).toBe(true); root = createRoot(host); }); it("confirms DELETE actions, reports success, and invalidates declared targets", async () => { const port = createPort(); await render(Remove, port, [ { id: "remove", kind: "action", method: "DELETE", trigger: "manual", minimumBoardPermission: "full", invalidates: ["parent"], }, ]); await act(async () => (host.querySelector("button") as HTMLButtonElement).click()); await settle(); expect(port.confirm).toHaveBeenCalledOnce(); expect(port.executeAction).toHaveBeenCalledWith(expect.objectContaining({ confirmed: true })); expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "success" })); expect(port.invalidate).toHaveBeenCalledWith(expect.objectContaining({ targets: ["parent"] })); }); it("rolls a toggle back when its action fails", async () => { const port = createPort({ executeAction: vi.fn(async () => ({ ok: false, status: 500, data: null, error: "Nope" })), }); await render( , port, ); const input = host.querySelector("input") as HTMLInputElement; await act(async () => { input.click(); }); await settle(); expect(input.checked).toBe(false); expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "error", message: "Nope" })); expect(port.invalidate).not.toHaveBeenCalled(); }); it("confirms a DELETE toggle before executing it", async () => { const port = createPort(); await render( , port, [ { id: "delete-toggle", kind: "action", method: "DELETE", trigger: "manual", minimumBoardPermission: "full", }, ], ); await act(async () => (host.querySelector("input") as HTMLInputElement).click()); await settle(); expect(port.confirm).toHaveBeenCalledWith(expect.objectContaining({ destructive: true })); expect(port.executeAction).toHaveBeenCalledWith(expect.objectContaining({ confirmed: true })); }); });