85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
/**
|
|
* 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 = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head></head>
|
|
<body>
|
|
<div id="stage" data-hf-root data-width="1920" data-height="1080" data-duration="5">
|
|
<h1 id="title" data-hf-id="hf-title" data-start="0" data-end="3"
|
|
style="color: #fff; font-size: 64px">Hello World</h1>
|
|
<img id="logo" data-hf-id="hf-logo" src="/logo.png" alt="Logo"
|
|
data-x="100" data-y="200" data-start="0" data-end="5" />
|
|
<p id="body-copy" data-hf-id="hf-body" data-start="1" data-end="4"
|
|
style="font-size: 24px">Body copy</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`.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 <html>/root), not the Document.
|
|
const declEl = (document as unknown as Document).documentElement;
|
|
expect(readVariableDefault(declEl, "never-declared")).toBeUndefined();
|
|
});
|
|
});
|