import { describe, expect, it } from "vitest";
import {
_internals,
transformHtmxOnEvents,
} from "./htmx-on-events";
const { STANDARD_EVENT_MAP, TODO_MARKER } = _internals;
describe("transformHtmxOnEvents — basic renames", () => {
it("renames hx-on:click → onClick (colon variant)", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content).toBe(
``,
);
expect(r.notes[0]).toContain("Renamed 1 hx-on:* attribute(s)");
expect(r.notes[0]).toContain("onClick=1");
});
it("renames hx-on-click → onClick (dash variant)", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content).toBe(``);
});
it("preserves whitespace around `=`", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.content).toBe(``);
});
it("renames every standard event in the map", () => {
for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.content, `event=${htmxEvent}`).toBe(``);
}
});
it("renames every standard event in the map (dash variant)", () => {
for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.content, `event=${htmxEvent}`).toBe(``);
}
});
it("handles multiple events on the same element", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.content).toBe(
``,
);
expect(r.notes[0]).toContain("Renamed 3 hx-on:* attribute(s)");
});
it("handles multi-line attribute values", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.content).toContain("onClick={() => {");
expect(r.content).toContain("setLoading(true);");
expect(r.content).not.toContain("hx-on");
});
it("handles string-valued events (rare but legal htmx)", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.content).toBe(``);
});
});
describe("transformHtmxOnEvents — what stays untouched", () => {
it("leaves htmx lifecycle events alone (htmx-config-request, htmx-before-request, ...)", () => {
const src = `
`;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(false);
expect(r.content).toBe(src);
});
it("renames standard events but preserves htmx lifecycle on the same element", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content).toBe(
``,
);
});
it("leaves unknown/custom events alone (no React synthetic equivalent)", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(false);
expect(r.content).toBe(src);
});
it("leaves non-event hx-* attributes alone (hx-post, hx-target, hx-swap, ...)", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(false);
expect(r.content).toBe(src);
});
it("leaves already-React onClick alone", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(false);
expect(r.content).toBe(src);
});
it("does not match attribute names that merely contain 'hx-on' as a substring", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(false);
});
it("returns unchanged on files with no hx-on at all (fast path)", () => {
const src = `\n`;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(false);
expect(r.content).toBe(src);
expect(r.notes).toEqual([]);
});
});
describe("transformHtmxOnEvents — Fresh-isms TODO injection", () => {
it("injects a top-of-file MIGRATION TODO when handler references useScript()", () => {
const src = `import { useScript } from "site/sdk/useScript.ts";
export default function X() {
return ;
}
`;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
expect(r.content).toContain("onClick={useScript(handler)}");
expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(true);
});
it("injects a top-of-file MIGRATION TODO when handler references globalThis.window.STOREFRONT", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
});
it("injects a top-of-file MIGRATION TODO when handler references STOREFRONT.* (shorthand)", () => {
const src = `import { STOREFRONT } from "site/sdk";\n`;
const r = transformHtmxOnEvents(src);
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
});
it("does NOT inject a TODO when no Fresh-isms are detected", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content.startsWith(TODO_MARKER)).toBe(false);
expect(r.content).toBe(``);
expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(false);
});
it("preserves a leading shebang and inserts the TODO after it", () => {
const src = `#!/usr/bin/env node\n\n`;
const r = transformHtmxOnEvents(src);
expect(r.content.startsWith("#!/usr/bin/env node\n")).toBe(true);
expect(r.content.split("\n")[1]).toBe(TODO_MARKER);
});
it("does not inject the TODO twice when the codemod is rerun (idempotent)", () => {
const src = ``;
const first = transformHtmxOnEvents(src);
const second = transformHtmxOnEvents(first.content);
expect(second.changed).toBe(false);
const occurrences = first.content.split(TODO_MARKER).length - 1;
expect(occurrences).toBe(1);
});
});
describe("transformHtmxOnEvents — idempotency + edge cases", () => {
it("is idempotent on a clean rewritten file (rerunning is a no-op)", () => {
const src = ``;
const first = transformHtmxOnEvents(src);
const second = transformHtmxOnEvents(first.content);
expect(first.changed).toBe(false);
expect(second.changed).toBe(false);
expect(second.content).toBe(src);
});
it("is idempotent on a file that just got rewritten (rerun produces same output)", () => {
const src = ``;
const first = transformHtmxOnEvents(src);
const second = transformHtmxOnEvents(first.content);
expect(second.changed).toBe(false);
expect(second.content).toBe(first.content);
});
it("handles JSX components (capitalized tag names) just like lowercase intrinsics", () => {
const src = `open`;
const r = transformHtmxOnEvents(src);
expect(r.content).toBe(
`open`,
);
});
it("renames within a long file containing many tags", () => {
const src = Array.from({ length: 20 })
.map((_, i) => ``)
.join("\n");
const r = transformHtmxOnEvents(src);
expect(r.notes[0]).toContain("Renamed 20 hx-on:* attribute(s)");
expect(r.content.split("onClick=").length - 1).toBe(20);
expect(r.content.includes("hx-on")).toBe(false);
});
});
describe("transformHtmxOnEvents — htmx-shaped fixtures", () => {
it("AddToBagButton: hx-on-click + Fresh useScript → onClick + TODO", () => {
const src = `import { useScript } from "site/sdk/useScript.ts";
interface Props { productId: string; }
export default function AddToBagButton({ productId }: Props) {
const handler = (id: string) => {
globalThis.window.STOREFRONT.CART.addToCart({ id });
};
return (
);
}
`;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content).toContain("onClick={useScript(handler, productId)}");
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
const codeBody = r.content.split("§ Pattern 1 (event-handler).\n")[1];
expect(codeBody).not.toMatch(/\bhx-on\b/);
});
it("SearchInput: keeps hx-post/hx-target/etc, renames hx-on:change", () => {
const src = ` { e.preventDefault(); }}
/>`;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content).toContain(`onChange={(e) => { e.preventDefault(); }}`);
expect(r.content).toContain("hx-post={searchUrl}");
expect(r.content).toContain('hx-target="#suggestions"');
expect(r.content).toContain('hx-swap="innerHTML"');
expect(r.content).toContain('hx-sync="closest form:abort"');
});
it("RecoveryPassword: form with mixed standard + htmx lifecycle hooks", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content).toContain(`onSubmit={(e) => { /* validate */ }}`);
expect(r.content).toContain("hx-on-htmx-before-request={(e) => addCsrf(e)}");
expect(r.content).toContain("hx-post={url}");
expect(r.content).toContain('hx-trigger="submit"');
});
it("Footer.tsx-style: simple onClick with no Fresh-ism — no TODO", () => {
const src = ``;
const r = transformHtmxOnEvents(src);
expect(r.changed).toBe(true);
expect(r.content.startsWith(TODO_MARKER)).toBe(false);
expect(r.content).toBe(
``,
);
});
});