import { mount } from "$core/binder";
import {
clearStates,
parseHttpConfig,
request,
serializeForm,
serializeFormToJSON,
setErrorState,
setLoadingState,
swap,
} from "$core/http";
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("http", () => {
describe("swap", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
container.innerHTML = "
Original
";
document.body.append(container);
});
it("swaps innerHTML by default", () => {
const target = container.querySelector("#target")!;
swap(target, "New");
expect(target.innerHTML).toBe("New");
});
it("swaps innerHTML explicitly", () => {
const target = container.querySelector("#target")!;
swap(target, "Bold", "innerHTML");
expect(target.innerHTML).toBe("Bold");
});
it("swaps outerHTML", () => {
const target = container.querySelector("#target")!;
swap(target, "", "outerHTML");
expect(container.querySelector("#target")).toBeNull();
expect(container.querySelector("#new")?.textContent).toBe("Replaced");
});
it("inserts beforebegin", () => {
const target = container.querySelector("#target")!;
swap(target, "Before", "beforebegin");
expect(container.querySelector("#before")?.nextElementSibling?.id).toBe("target");
});
it("inserts afterbegin", () => {
const target = container.querySelector("#target")!;
swap(target, "First", "afterbegin");
expect(target.firstElementChild?.id).toBe("first");
});
it("inserts beforeend", () => {
const target = container.querySelector("#target")!;
swap(target, "Last", "beforeend");
expect(target.lastElementChild?.id).toBe("last");
});
it("inserts afterend", () => {
const target = container.querySelector("#target")!;
swap(target, "After", "afterend");
expect(container.querySelector("#target")?.nextElementSibling?.id).toBe("after");
});
it("deletes the target element", () => {
const target = container.querySelector("#target")!;
swap(target, "", "delete");
expect(container.querySelector("#target")).toBeNull();
});
it("does nothing with none strategy", () => {
const target = container.querySelector("#target")!;
const originalHTML = target.innerHTML;
swap(target, "Should not appear", "none");
expect(target.innerHTML).toBe(originalHTML);
});
describe("state preservation", () => {
it("preserves focus when swapping innerHTML", () => {
container.innerHTML = `
`;
const target = container.querySelector("#target")!;
const input2 = container.querySelector("#input2") as HTMLInputElement;
input2.focus();
expect(document.activeElement).toBe(input2);
swap(
target,
`
`,
"innerHTML",
);
const newInput2 = container.querySelector("#input2") as HTMLInputElement;
expect(document.activeElement).toBe(newInput2);
});
it("preserves input values when swapping innerHTML", () => {
container.innerHTML = `
`;
const target = container.querySelector("#target")!;
const nameInput = container.querySelector("#name") as HTMLInputElement;
const bioInput = container.querySelector("#bio") as HTMLTextAreaElement;
nameInput.value = "John Doe";
bioInput.value = "Software developer";
swap(
target,
`
`,
"innerHTML",
);
const newNameInput = container.querySelector("#name") as HTMLInputElement;
const newBioInput = container.querySelector("#bio") as HTMLTextAreaElement;
const newAgreeInput = container.querySelector("#agree") as HTMLInputElement;
expect(newNameInput.value).toBe("John Doe");
expect(newBioInput.value).toBe("Software developer");
expect(newAgreeInput.checked).toBe(true);
});
it("preserves scroll position when swapping innerHTML", () => {
container.innerHTML = `
`;
const target = container.querySelector("#target")!;
target.scrollTop = 50;
swap(
target,
`
`,
"innerHTML",
);
expect(target.scrollTop).toBe(50);
});
it("preserves nested element scroll positions", () => {
container.innerHTML = `
`;
const target = container.querySelector("#target")!;
const nested = container.querySelector("#nested")!;
nested.scrollTop = 75;
swap(
target,
`
`,
"innerHTML",
);
const newNested = container.querySelector("#nested")!;
expect(newNested.scrollTop).toBe(75);
});
it("preserves state when swapping outerHTML", () => {
container.innerHTML = `
`;
const target = container.querySelector("#target")!;
const field = container.querySelector("#field") as HTMLInputElement;
field.value = "updated";
field.focus();
swap(
target,
`
`,
"outerHTML",
);
const newField = container.querySelector("#field") as HTMLInputElement;
expect(newField.value).toBe("updated");
expect(document.activeElement).toBe(newField);
});
it("does not attempt state preservation for insert strategies", () => {
container.innerHTML = `
`;
const target = container.querySelector("#target")!;
const existing = container.querySelector("#existing") as HTMLInputElement;
existing.value = "test";
existing.focus();
swap(target, "", "beforeend");
expect(existing.value).toBe("test");
expect(document.activeElement).toBe(existing);
});
});
});
describe("serializeForm", () => {
it("serializes form to FormData", () => {
const form = document.createElement("form");
form.innerHTML = `
`;
const formData = serializeForm(form);
expect(formData.get("username")).toBe("john");
expect(formData.get("email")).toBe("john@example.com");
expect(formData.get("subscribe")).toBe("on");
});
it("handles multiple values with same name", () => {
const form = document.createElement("form");
form.innerHTML = `
`;
const formData = serializeForm(form);
expect(formData.getAll("tags")).toEqual(["tag1", "tag2"]);
});
});
describe("serializeFormToJSON", () => {
it("serializes form to JSON object", () => {
const form = document.createElement("form");
form.innerHTML = `
`;
const json = serializeFormToJSON(form);
expect(json).toEqual({ username: "jane", age: "25" });
});
it("handles multiple values as array", () => {
const form = document.createElement("form");
form.innerHTML = `
`;
const json = serializeFormToJSON(form);
expect(json.color).toEqual(["red", "blue"]);
});
});
describe("parseHttpConfig", () => {
it("parses default configuration", () => {
const element = document.createElement("button");
const config = parseHttpConfig(element, {});
expect(config.trigger).toBe("click");
expect(config.target).toBe(element);
expect(config.swap).toBe("innerHTML");
expect(config.headers).toEqual({});
});
it("parses trigger from dataset", () => {
const element = document.createElement("div");
element.dataset.voltTrigger = "mouseover";
const config = parseHttpConfig(element, {});
expect(config.trigger).toBe("mouseover");
});
it("parses target selector from dataset", () => {
const element = document.createElement("div");
element.dataset.voltTarget = "'#result'";
const config = parseHttpConfig(element, {});
expect(config.target).toBe("#result");
});
it("parses swap strategy from dataset", () => {
const element = document.createElement("div");
element.dataset.voltSwap = "outerHTML";
const config = parseHttpConfig(element, {});
expect(config.swap).toBe("outerHTML");
});
it("parses headers from dataset", () => {
const element = document.createElement("div");
element.dataset.voltHeaders = "headers";
const config = parseHttpConfig(element, { headers: { Authorization: "Bearer token" } });
expect(config.headers).toEqual({ Authorization: "Bearer token" });
});
it("uses submit trigger for forms", () => {
const element = document.createElement("form");
const config = parseHttpConfig(element, {});
expect(config.trigger).toBe("submit");
});
});
describe("state management", () => {
let element: HTMLDivElement;
beforeEach(() => {
element = document.createElement("div");
});
it("sets loading state", () => {
setLoadingState(element);
expect(element.dataset.voltLoading).toBe("true");
});
it("sets error state", () => {
setErrorState(element, "Network error");
expect(element.dataset.voltError).toBe("Network error");
});
it("clears states", () => {
element.dataset.voltLoading = "true";
element.dataset.voltError = "Some error";
clearStates(element);
expect(Object.hasOwn(element.dataset, "voltLoading")).toBe(false);
expect(Object.hasOwn(element.dataset, "voltError")).toBe(false);
});
it("dispatches volt:loading event", () => {
const handler = vi.fn();
element.addEventListener("volt:loading", handler);
setLoadingState(element);
expect(handler).toHaveBeenCalledOnce();
expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
expect(handler.mock.calls[0][0].detail).toEqual({ element });
});
it("dispatches volt:error event", () => {
const handler = vi.fn();
element.addEventListener("volt:error", handler);
setErrorState(element, "Test error");
expect(handler).toHaveBeenCalledOnce();
expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
expect(handler.mock.calls[0][0].detail).toEqual({ element, message: "Test error" });
});
it("dispatches volt:success event", () => {
const handler = vi.fn();
element.addEventListener("volt:success", handler);
clearStates(element);
expect(handler).toHaveBeenCalledOnce();
expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
expect(handler.mock.calls[0][0].detail).toEqual({ element });
});
it("events bubble up the DOM", () => {
const parent = document.createElement("div");
parent.append(element);
const loadingHandler = vi.fn();
const errorHandler = vi.fn();
const successHandler = vi.fn();
parent.addEventListener("volt:loading", loadingHandler);
parent.addEventListener("volt:error", errorHandler);
parent.addEventListener("volt:success", successHandler);
setLoadingState(element);
setErrorState(element, "Bubbled error");
clearStates(element);
expect(loadingHandler).toHaveBeenCalledOnce();
expect(errorHandler).toHaveBeenCalledOnce();
expect(successHandler).toHaveBeenCalledOnce();
});
});
describe("request", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("makes a GET request", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Response
"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const response = await request({ method: "GET", url: "/api/data" });
expect(mockFetch).toHaveBeenCalledWith("/api/data", { method: "GET", headers: {}, body: undefined });
expect(response.ok).toBe(true);
expect(response.html).toBe("Response
");
});
it("makes a POST request with body", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 201,
statusText: "Created",
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve({ id: 123 }),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const formData = new FormData();
formData.append("name", "Test");
const response = await request({ method: "POST", url: "/api/create", body: formData });
expect(mockFetch).toHaveBeenCalledWith("/api/create", { method: "POST", headers: {}, body: formData });
expect(response.ok).toBe(true);
expect(response.json).toEqual({ id: 123 });
});
it("parses HTML response", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
text: () => Promise.resolve("HTML content
"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const response = await request({ method: "GET", url: "/page" });
expect(response.html).toBe("HTML content
");
expect(response.json).toBeUndefined();
});
it("parses JSON response", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve({ success: true }),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const response = await request({ method: "GET", url: "/api/status" });
expect(response.json).toEqual({ success: true });
expect(response.html).toBeUndefined();
});
it("throws error for network failure", async () => {
const mockFetch = vi.fn(() => Promise.reject(new Error("Network error")));
vi.stubGlobal("fetch", mockFetch);
await expect(request({ method: "GET", url: "/api/fail" })).rejects.toThrow("HTTP request failed: Network error");
});
});
describe("HTTP method bindings", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("binds data-volt-get and makes GET request on click", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Loaded
"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
document.body.append(button);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/data", expect.objectContaining({ method: "GET" }));
});
});
it("binds data-volt-post and serializes form on submit", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 201,
statusText: "Created",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Created
"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const form = document.createElement("form");
form.dataset.voltPost = "'/api/submit'";
form.innerHTML = "";
document.body.append(form);
mount(form, {});
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"/api/submit",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
);
});
});
it("updates target element with response", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("New content"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const container = document.createElement("div");
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
container.append(button);
document.body.append(container);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(button.innerHTML).toBe("New content");
});
});
it("sets loading state during request", async () => {
let resolveRequest: ((value: Response) => void) | undefined;
const mockFetch = vi.fn(() =>
new Promise((resolve) => {
resolveRequest = resolve;
})
);
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/slow'";
document.body.append(button);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(button.dataset.voltLoading).toBe("true");
});
resolveRequest?.(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Done
"),
} as Response,
);
await vi.waitFor(() => {
expect(Object.hasOwn(button.dataset, "voltLoading")).toBe(false);
});
});
it("sets error state on request failure", async () => {
const mockFetch = vi.fn(() => Promise.reject(new Error("Server error")));
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/fail'";
document.body.append(button);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(button.dataset.voltError).toContain("Server error");
});
});
});
describe("retry logic", () => {
it("retries network errors immediately", async () => {
let callCount = 0;
const mockFetch = vi.fn(() => {
callCount++;
if (callCount < 3) {
return Promise.reject(new Error("HTTP request failed: fetch failed"));
}
return Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Success
"),
} as Response,
);
});
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
button.dataset.voltRetry = "3";
button.dataset.voltTarget = "'#result'";
const result = document.createElement("div");
result.id = "result";
document.body.append(button, result);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(result.innerHTML).toBe("Success
");
}, { timeout: 2000 });
expect(callCount).toBe(3);
});
it("retries 5xx errors with exponential backoff", async () => {
let callCount = 0;
const mockFetch = vi.fn(() => {
callCount++;
if (callCount < 3) {
return Promise.resolve(
{
ok: false,
status: 500,
statusText: "Internal Server Error",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve(""),
} as Response,
);
}
return Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Success
"),
} as Response,
);
});
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
button.dataset.voltRetry = "3";
button.dataset.voltRetryDelay = "100";
button.dataset.voltTarget = "'#result'";
const result = document.createElement("div");
result.id = "result";
document.body.append(button, result);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(result.innerHTML).toBe("Success
");
}, { timeout: 5000 });
expect(callCount).toBe(3);
});
it("does not retry 4xx errors", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: false,
status: 404,
statusText: "Not Found",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve(""),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/missing'";
button.dataset.voltRetry = "3";
button.dataset.voltTarget = "'#result'";
const result = document.createElement("div");
result.id = "result";
document.body.append(button, result);
mount(button, {});
button.click();
await vi.waitFor(() => {
const errorAttr = result.dataset.voltError;
expect(errorAttr).toBeTruthy();
expect(errorAttr).toContain("404");
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("respects max retry attempts", async () => {
const mockFetch = vi.fn(() => Promise.reject(new Error("HTTP request failed: network error")));
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
button.dataset.voltRetry = "2";
button.dataset.voltTarget = "'#result'";
const result = document.createElement("div");
result.id = "result";
document.body.append(button, result);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(result.dataset.voltError).toBeTruthy();
});
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it("sets retry attempt attribute and dispatches retry event", async () => {
let callCount = 0;
const mockFetch = vi.fn(() => {
callCount++;
if (callCount < 2) {
return Promise.reject(new Error("HTTP request failed: network error"));
}
return Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Success
"),
} as Response,
);
});
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
button.dataset.voltRetry = "3";
button.dataset.voltTarget = "'#result'";
const result = document.createElement("div");
result.id = "result";
let retryEventFired = false;
let retryAttempt = 0;
result.addEventListener(
"volt:retry",
((event: CustomEvent) => {
retryEventFired = true;
retryAttempt = event.detail.attempt;
}) as EventListener,
);
document.body.append(button, result);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(result.innerHTML).toBe("Success
");
}, { timeout: 2000 });
expect(retryEventFired).toBe(true);
expect(retryAttempt).toBe(1);
});
});
describe("loading indicators", () => {
it("shows and hides indicator with display style", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Success
"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
button.dataset.voltIndicator = "#spinner";
button.dataset.voltTarget = "'#result'";
const spinner = document.createElement("div");
spinner.id = "spinner";
spinner.style.display = "none";
const result = document.createElement("div");
result.id = "result";
document.body.append(button, spinner, result);
expect(spinner.style.display).toBe("none");
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(spinner.style.display).toBe("");
});
await vi.waitFor(() => {
expect(result.innerHTML).toBe("Success
");
expect(spinner.style.display).toBe("none");
}, { timeout: 1000 });
});
it("shows and hides indicator with CSS class", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Success
"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
button.dataset.voltIndicator = "#spinner";
button.dataset.voltTarget = "'#result'";
const spinner = document.createElement("div");
spinner.id = "spinner";
spinner.classList.add("hidden");
const result = document.createElement("div");
result.id = "result";
document.body.append(button, spinner, result);
expect(spinner.classList.contains("hidden")).toBe(true);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(spinner.classList.contains("hidden")).toBe(false);
});
await vi.waitFor(() => {
expect(result.innerHTML).toBe("Success
");
expect(spinner.classList.contains("hidden")).toBe(true);
}, { timeout: 1000 });
});
it("hides indicator on error", async () => {
const mockFetch = vi.fn(() => Promise.reject(new Error("Server error")));
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/fail'";
button.dataset.voltIndicator = "#spinner";
const spinner = document.createElement("div");
spinner.id = "spinner";
spinner.style.display = "none";
document.body.append(button, spinner);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(spinner.style.display).toBe("");
});
await vi.waitFor(() => {
expect(button.dataset.voltError).toBeTruthy();
expect(spinner.style.display).toBe("none");
}, { timeout: 1000 });
});
it("handles multiple indicators", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(
{
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "text/html" }),
text: () => Promise.resolve("Success
"),
} as Response,
)
);
vi.stubGlobal("fetch", mockFetch);
const button = document.createElement("button");
button.dataset.voltGet = "'/api/data'";
button.dataset.voltIndicator = ".spinner";
button.dataset.voltTarget = "'#result'";
const spinner1 = document.createElement("div");
spinner1.classList.add("spinner", "hidden");
const spinner2 = document.createElement("div");
spinner2.classList.add("spinner", "hidden");
const result = document.createElement("div");
result.id = "result";
document.body.append(button, spinner1, spinner2, result);
mount(button, {});
button.click();
await vi.waitFor(() => {
expect(spinner1.classList.contains("hidden")).toBe(false);
expect(spinner2.classList.contains("hidden")).toBe(false);
});
await vi.waitFor(() => {
expect(result.innerHTML).toBe("Success
");
expect(spinner1.classList.contains("hidden")).toBe(true);
expect(spinner2.classList.contains("hidden")).toBe(true);
}, { timeout: 1000 });
});
});
});