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 = ` { /* validate */ }} hx-on-htmx-before-request={(e) => addCsrf(e)} > … `; 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( ``, ); }); });