/**
* SDK smoke test — end-to-end pipeline.
*
* Exercises the full public surface: openComposition → mutate → applyPatches →
* serialize round-trip, plus batch transactionality, history undo, persist
* adapter, and patch subscription.
*
* This file is the "golden example" pinned as a regression smoke test.
* If it breaks, the SDK's public contract has changed.
*/
import { describe, it, expect, vi } from "vitest";
import {
openComposition,
ORIGIN_APPLY_PATCHES,
resolveScoped,
findById,
escapeHfId,
readVariableDefault,
} from "./index.js";
import { createMemoryAdapter } from "./adapters/memory.js";
// ─── Fixture ─────────────────────────────────────────────────────────────────
const BASE_HTML = `
Hello World
Body copy
`.trim();
// ─── init → mutate → serialize ────────────────────────────────────────────────
describe("openComposition + basic mutations", () => {
it("opens without error and exposes element snapshots", async () => {
const comp = await openComposition(BASE_HTML);
const els = comp.getElements();
expect(els.length).toBeGreaterThanOrEqual(3);
const title = comp.getElement("hf-title");
expect(title).not.toBeNull();
expect(title?.inlineStyles.color).toBe("#fff");
});
it("setStyle mutates inline styles and serializes back to HTML", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#f00", fontSize: "96px" });
const html = comp.serialize();
expect(html).toContain("color: #f00");
expect(html).toContain("font-size: 96px");
});
it("element handle sugar — same result as direct setStyle", async () => {
const comp = await openComposition(BASE_HTML);
comp.element("hf-title").setStyle({ color: "#0f0" });
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
});
it("setText updates text content", async () => {
const comp = await openComposition(BASE_HTML);
comp.setText("hf-title", "Goodbye World");
expect(comp.getElement("hf-title")?.text).toContain("Goodbye World");
expect(comp.serialize()).toContain("Goodbye World");
});
it("dispatch moveElement writes data-x/data-y attributes", async () => {
const comp = await openComposition(BASE_HTML);
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 300, y: 400 });
const el = comp.getElement("hf-logo");
expect(el?.attributes["data-x"]).toBe("300");
expect(el?.attributes["data-y"]).toBe("400");
});
it("serialize round-trip: mutate → serialize → reopen → same state", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#0f0" });
comp.setText("hf-body", "Round-tripped");
const html = comp.serialize();
const comp2 = await openComposition(html);
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
expect(comp2.getElement("hf-body")?.text).toContain("Round-tripped");
});
});
// ─── patch subscription ───────────────────────────────────────────────────────
describe("patch events", () => {
it("emits a patch event per dispatch with correct path and value", async () => {
const comp = await openComposition(BASE_HTML);
const events: unknown[] = [];
comp.on("patch", (e) => events.push(e));
comp.setStyle("hf-title", { fontSize: "48px" });
expect(events).toHaveLength(1);
const event = events[0] as { patches: { path: string; value: unknown }[] };
const patch = event.patches.find((p) => p.path.endsWith("/fontSize"));
expect(patch?.value).toBe("48px");
});
it("applyPatches origin is tagged ORIGIN_APPLY_PATCHES", async () => {
const comp = await openComposition(BASE_HTML);
const origins: unknown[] = [];
comp.on("patch", (e) => origins.push((e as { origin: unknown }).origin));
comp.applyPatches([
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
]);
expect(origins[0]).toBe(ORIGIN_APPLY_PATCHES);
});
});
// ─── applyPatches ─────────────────────────────────────────────────────────────
describe("applyPatches", () => {
it("applies external RFC 6902 patches to the live document", async () => {
const comp = await openComposition(BASE_HTML);
comp.applyPatches([
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
{ op: "replace", path: "/elements/hf-title/text", value: "Patched" },
]);
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
expect(comp.getElement("hf-title")?.text).toContain("Patched");
});
it("applyPatches does NOT enter undo history — undo() is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
comp.applyPatches([
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
]);
comp.undo(); // no-op: applyPatches bypasses history
// color must still be the patched value (undo had nothing to revert)
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
});
});
// ─── batch transactionality ───────────────────────────────────────────────────
describe("batch()", () => {
it("coalesces multiple dispatches into one patch event", async () => {
const comp = await openComposition(BASE_HTML);
const events: unknown[] = [];
comp.on("patch", (e) => events.push(e));
comp.batch(() => {
comp.setStyle("hf-title", { color: "#f00" });
comp.setText("hf-body", "Batched");
});
expect(events).toHaveLength(1);
});
it("rolls back DOM on throw — model unchanged after throwing batch", async () => {
const comp = await openComposition(BASE_HTML);
const beforeColor = comp.getElement("hf-title")?.inlineStyles.color;
try {
comp.batch(() => {
comp.setStyle("hf-title", { color: "#f00" });
throw new Error("user cancelled");
});
} catch {
// expected
}
// DOM must be exactly as before
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(beforeColor);
});
it("throwing batch does NOT add a history entry — undo is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
try {
comp.batch(() => {
comp.setStyle("hf-title", { color: "#f00" });
throw new Error("rollback");
});
} catch {
// expected
}
// undo should be a no-op since no history entry was added
comp.undo();
// color should still be the original (batch was rolled back + undo had nothing to do)
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
});
});
// ─── history ─────────────────────────────────────────────────────────────────
describe("undo / redo", () => {
it("undo reverts last mutation, redo re-applies it", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#f00" });
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
comp.undo();
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
comp.redo();
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
});
it("undo with no history is a no-op", async () => {
const comp = await openComposition(BASE_HTML);
const before = comp.getElement("hf-title")?.inlineStyles.color;
comp.undo(); // no-op
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(before);
});
});
// ─── persist adapter ─────────────────────────────────────────────────────────
describe("persist adapter", () => {
it("writes serialized HTML to the adapter on mutation", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, { persist: adapter });
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
expect(writeSpy).toHaveBeenCalled();
const [, content] = writeSpy.mock.calls[0] as [string, string];
expect(content).toContain("color: #f00");
});
it("still persists when history:false (undo opt-out must not disable auto-save)", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, { persist: adapter, history: false });
expect(comp.canUndo()).toBe(false); // undo is off…
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
expect(writeSpy).toHaveBeenCalled(); // …but the write still happened
const [, content] = writeSpy.mock.calls[0] as [string, string];
expect(content).toContain("color: #f00");
});
it("surfaces persist errors via on('persist:error')", async () => {
const adapter = createMemoryAdapter();
const errors: unknown[] = [];
const comp = await openComposition(BASE_HTML, { persist: adapter });
comp.on("persist:error", (e) => errors.push(e));
adapter.injectFault("disk full");
comp.setStyle("hf-title", { color: "#f00" });
await new Promise((r) => setTimeout(r, 20));
expect(errors).toHaveLength(1);
});
it("defaults the write path to composition.html when persistPath is omitted", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, { persist: adapter });
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
const [path] = writeSpy.mock.calls[0] as [string, string];
expect(path).toBe("composition.html");
});
it("writes to persistPath when supplied", async () => {
const adapter = createMemoryAdapter();
const writeSpy = vi.spyOn(adapter, "write");
const comp = await openComposition(BASE_HTML, {
persist: adapter,
persistPath: "scenes/intro.html",
});
comp.setStyle("hf-title", { color: "#f00" });
await comp.flush();
const [path] = writeSpy.mock.calls[0] as [string, string];
expect(path).toBe("scenes/intro.html");
});
});
// ─── T3 embedded mode (override-set) ─────────────────────────────────────────
describe("T3 embedded mode", () => {
it("applies override-set on open, mutations layer on top", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: { "hf-title.style.color": "#0f0" },
});
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
});
it("getOverrides() returns accumulated override-set", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#f00" });
const overrides = comp.getOverrides();
expect(overrides["hf-title.style.color"]).toBe("#f00");
});
it("serialize → reopen with overrides → same state as direct mutation", async () => {
// Simulate host storing overrides + base template separately (T3 pattern)
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#0f0" });
comp.setText("hf-body", "Override text");
const overrides = comp.getOverrides();
// Host reopens the original template with the stored overrides
const comp2 = await openComposition(BASE_HTML, { overrides });
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
expect(comp2.getElement("hf-body")?.text).toContain("Override text");
});
});
describe("engine helper exports (resolveScoped, findById, escapeHfId, readVariableDefault)", () => {
it("are importable from the public index and work against a live document", async () => {
// These operate on a Document directly, not the Composition — exercise them
// against a document parsed the same way the SDK parses internally.
const { document } = await import("linkedom").then((m) => m.parseHTML(BASE_HTML));
expect(findById(document as unknown as Document, "hf-title")).not.toBeNull();
expect(resolveScoped(document as unknown as Document, "hf-title")).not.toBeNull();
expect(escapeHfId('hf-"quoted"')).toBe('hf-\\"quoted\\"');
// readVariableDefault takes the declaration element (the /root), not the Document.
const declEl = (document as unknown as Document).documentElement;
expect(readVariableDefault(declEl, "never-declared")).toBeUndefined();
});
});