Files
homarr/packages/custom-widgets/src/test/runtime.spec.tsx
T

504 lines
17 KiB
TypeScript

// @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> = {}): 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(
<MantineProvider>
<QueryClientProvider client={queryClient}>
<CustomWidgetRuntimeProvider
itemId="item-1"
isEditMode={false}
requestCapabilities={capabilities}
port={port}
messages={messages}
setQueryState={setQueryState}
>
{node}
</CustomWidgetRuntimeProvider>
</QueryClientProvider>
</MantineProvider>,
);
});
}
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(
<CustomJsxRenderer
template="<Text>Safe</Text>"
data={{}}
components={{ Text: (() => 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(
<CustomJsxRenderer
template={
'<Text>{[1, 2].concat([3]).flatMap(value => [value]).map(value => value.toLocaleString()).join(" · ")}</Text>'
}
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(
<CustomJsxRenderer
template={"<Anchor href={data.url}>Unsafe</Anchor>"}
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(
<>
<input aria-label="Outside" name="shared" type="radio" />
<CustomJsxRenderer
template={
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
</>,
createPort(),
);
await settle();
const outside = host.querySelector<HTMLInputElement>('input[type="radio"]');
const [inside, other] = [...host.querySelectorAll<HTMLButtonElement>('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 = (
<CustomJsxRenderer
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
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(
<CustomJsxRenderer
template={'<Stack><TextInput bind="search"/><Text>{inputs.search}</Text></Stack>'}
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(
<CustomJsxRenderer
template={'<Stack><TextInput bind="shared" defaultValue="text"/><Text>{inputs.shared}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect(host.textContent).toContain("text");
await render(
<CustomJsxRenderer
template={
'<Stack><Switch bind="shared" defaultChecked/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>'
}
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 = '<Stack><Switch bind="shared"/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>';
await render(
<CustomJsxRenderer
template={template}
data={{ version: 1 }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
await settle();
expect(host.textContent).toContain("enabled");
await render(
<CustomJsxRenderer
template={template}
data={{ version: 2 }}
components={components}
createBindings={(data) => ({ 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(<SubFetch requestId="details" />, 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(
<SubFetch requestId="details" trigger="manual">
{(data, metadata) => (
<span>{`${(data as { name: string }).name}:${metadata.status}:${String(metadata.loading)}`}</span>
)}
</SubFetch>,
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(
<SubFetch requestId="details" trigger="manual" triggerContent={<span>Bulbasaur card</span>}>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
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(
<SubFetch
requestId="details"
trigger="manual"
triggerAriaLabel="Open Bulbasaur"
triggerContent={<span>Bulbasaur card</span>}
>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
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<CustomWidgetRequestResult>(() => undefined);
}),
});
await render(<SubFetch requestId="slow" />, 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(<ActionButton requestId="remove">Remove</ActionButton>, 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(
<ToggleSwitch requestId="toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
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(
<ToggleSwitch requestId="delete-toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
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 }));
});
});