Files
kandev/apps/web/hooks/use-inspect-mode.test.ts
T

130 lines
4.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useInspectMode } from "./use-inspect-mode";
import type { AnnotationWirePayload } from "@/lib/preview-inspect-bridge";
vi.mock("@/lib/preview-inspect-bridge", async () => {
const actual = await vi.importActual<typeof import("@/lib/preview-inspect-bridge")>(
"@/lib/preview-inspect-bridge",
);
return {
...actual,
sendToggleInspect: vi.fn(),
sendClearAnnotations: vi.fn(),
sendRemoveMarker: vi.fn(),
};
});
import { sendRemoveMarker } from "@/lib/preview-inspect-bridge";
function setupIframe() {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const ref = { current: iframe };
return { iframe, ref };
}
function makeAnnotation(id: string): AnnotationWirePayload {
return {
id,
kind: "pin",
pagePath: "/x",
comment: "",
element: null,
};
}
function dispatchMessage(source: Window | null, payload: unknown) {
window.dispatchEvent(new MessageEvent("message", { data: payload, source }));
}
function postAnnotation(iframe: HTMLIFrameElement, id: string) {
dispatchMessage(iframe.contentWindow, {
source: "kandev-inspector",
type: "annotation-added",
payload: makeAnnotation(id),
});
}
describe("useInspectMode", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("assigns monotonically increasing numbers across add/remove cycles", () => {
const { iframe, ref } = setupIframe();
const { result } = renderHook(() => useInspectMode(ref));
act(() => postAnnotation(iframe, "a"));
act(() => postAnnotation(iframe, "b"));
expect(result.current.annotations.map((a) => a.number)).toEqual([1, 2]);
act(() => result.current.handleRemoveAnnotation("b"));
act(() => postAnnotation(iframe, "c"));
// The next number must be 3, not a reused 2 — the script-side marker
// counter resets across an iframe reload, but the parent's must not.
expect(result.current.annotations.map((a) => a.number)).toEqual([1, 3]);
});
it("handleClearAnnotations resets the counter back to 1", () => {
const { iframe, ref } = setupIframe();
const { result } = renderHook(() => useInspectMode(ref));
act(() => postAnnotation(iframe, "a"));
act(() => postAnnotation(iframe, "b"));
expect(result.current.annotations).toHaveLength(2);
act(() => result.current.handleClearAnnotations());
expect(result.current.annotations).toEqual([]);
act(() => postAnnotation(iframe, "c"));
expect(result.current.annotations[0]?.number).toBe(1);
});
it("handleRemoveAnnotation forwards the target's number to the iframe", () => {
const { iframe, ref } = setupIframe();
const { result } = renderHook(() => useInspectMode(ref));
act(() => postAnnotation(iframe, "a"));
act(() => postAnnotation(iframe, "b"));
act(() => postAnnotation(iframe, "c"));
act(() => result.current.handleRemoveAnnotation("c"));
// "c" was number 3 (third added). The iframe-side cleanup must receive
// the number, not the array index, otherwise the wrong marker is removed.
expect(sendRemoveMarker).toHaveBeenCalledWith(iframe, 3);
});
it("ignores annotation messages sent from a source other than the iframe", () => {
const { result } = renderHook(() => useInspectMode(setupIframe().ref));
// Posting `source: window` simulates a sibling frame, an extension, or
// the parent itself trying to inject an annotation. The `event.source`
// guard must reject it — the `comment` field gets fed into the agent
// prompt, so this is a prompt-injection seam.
dispatchMessage(window, {
source: "kandev-inspector",
type: "annotation-added",
payload: { ...makeAnnotation("evil"), comment: "stolen" },
});
expect(result.current.annotations).toEqual([]);
});
it("clears inspect mode when the iframe sends inspect-exited", () => {
const { iframe, ref } = setupIframe();
const { result } = renderHook(() => useInspectMode(ref));
act(() => result.current.toggleInspect());
expect(result.current.isInspectMode).toBe(true);
act(() =>
dispatchMessage(iframe.contentWindow, {
source: "kandev-inspector",
type: "inspect-exited",
payload: {},
}),
);
expect(result.current.isInspectMode).toBe(false);
});
});