mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete (#1325)
* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete * fix(sdk): address review — live-DOM query cache, single parse, style parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): restore full public exports now session/document modules exist index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): coalesce history by patch paths; replay override-set on open Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22bb6737c5
commit
7010edac85
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Session-level behavior: history coalescing invariants and T3 override replay.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
|
||||
const BASE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3" style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
|
||||
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// ─── History coalescing ───────────────────────────────────────────────────────
|
||||
|
||||
describe("history coalescing", () => {
|
||||
it("rapid edits to the SAME property coalesce into one undo entry", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-title", { color: "#222" });
|
||||
comp.setStyle("hf-title", { color: "#333" });
|
||||
|
||||
comp.undo();
|
||||
const el = comp.getElement("hf-title");
|
||||
expect(el?.inlineStyles["color"]).toBe("#fff"); // back to original in ONE step
|
||||
});
|
||||
|
||||
it("rapid edits to DIFFERENT elements do NOT coalesce — undo reverts only the last edit", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-sub", { opacity: "1" });
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-sub")?.inlineStyles["opacity"]).toBe("0.5"); // last edit reverted
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111"); // first edit intact
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
|
||||
it("rapid edits to different properties of the same element do not coalesce", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-title", { fontSize: "96px" });
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["fontSize"]).toBe("64px");
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T3 override replay ───────────────────────────────────────────────────────
|
||||
|
||||
describe("override-set replay on open", () => {
|
||||
it("applies style, text, and attribute overrides to the base document", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: {
|
||||
"hf-title.style.color": "#e63946",
|
||||
"hf-title.text": "Edited headline",
|
||||
"hf-logo.attr.src": "/new-logo.png",
|
||||
},
|
||||
});
|
||||
|
||||
const title = comp.getElement("hf-title");
|
||||
expect(title?.inlineStyles["color"]).toBe("#e63946");
|
||||
expect(title?.text).toBe("Edited headline");
|
||||
expect(comp.getElement("hf-logo")?.attributes["src"]).toBe("/new-logo.png");
|
||||
|
||||
const html = comp.serialize();
|
||||
expect(html).toContain("Edited headline");
|
||||
expect(html).toContain("/new-logo.png");
|
||||
expect(html).toContain("#e63946");
|
||||
});
|
||||
|
||||
it("applies timing overrides (computed absolute end)", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.timing.end": 4.5 },
|
||||
});
|
||||
expect(comp.serialize()).toContain('data-end="4.5"');
|
||||
});
|
||||
|
||||
it("removes elements marked with the null removal marker", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-sub": null },
|
||||
});
|
||||
expect(comp.getElement("hf-sub")).toBeNull();
|
||||
expect(comp.serialize()).not.toContain("subtitle");
|
||||
});
|
||||
|
||||
it("treats property-level null as restore-base (no-op on fresh base)", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.color": null },
|
||||
});
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
|
||||
it("getOverrides returns the set the session was opened with", async () => {
|
||||
const overrides = { "hf-title.style.color": "#e63946" };
|
||||
const comp = await openComposition(BASE_HTML, { overrides });
|
||||
expect(comp.getOverrides()).toEqual(overrides);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batch() transactional rollback ───────────────────────────────────────────
|
||||
|
||||
describe("batch rollback on throw", () => {
|
||||
it("reverts DOM mutations and override-set when the callback throws", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const htmlBefore = comp.serialize();
|
||||
|
||||
expect(() =>
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
comp.setText("hf-sub", "changed");
|
||||
throw new Error("user cancelled");
|
||||
}),
|
||||
).toThrowError("user cancelled");
|
||||
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
expect(comp.getElement("hf-sub")?.text).toBe("subtitle");
|
||||
expect(comp.serialize()).toBe(htmlBefore);
|
||||
expect(comp.getOverrides()).toEqual({});
|
||||
});
|
||||
|
||||
it("a throwing batch leaves no history entry — undo is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
try {
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
throw new Error("boom");
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user