mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.
Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.
Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types
Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test
GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile
Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame
Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
toolbar actions, navigation, and render starts
150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js";
|
|
|
|
describe("removeElementFromHtml", () => {
|
|
it("removes a self-closing element by id", () => {
|
|
const html = `<!doctype html><html><body><div data-composition-id="main"><img id="photo" src="asset.png" /><div id="rest"></div></div></body></html>`;
|
|
|
|
const updated = removeElementFromHtml(html, { id: "photo" });
|
|
|
|
expect(updated).not.toContain(`id="photo"`);
|
|
expect(updated).toContain(`id="rest"`);
|
|
});
|
|
|
|
it("removes a matched composition host by selector", () => {
|
|
const html = `<!doctype html><html><body><div data-composition-id="main"><div data-composition-id="scene-a"><span>Scene A</span></div><div data-composition-id="scene-b"></div></div></body></html>`;
|
|
|
|
const updated = removeElementFromHtml(html, {
|
|
selector: '[data-composition-id="scene-a"]',
|
|
});
|
|
|
|
expect(updated).not.toContain(`data-composition-id="scene-a"`);
|
|
expect(updated).toContain(`data-composition-id="scene-b"`);
|
|
});
|
|
|
|
it("supports fragment html by returning updated body markup", () => {
|
|
const html = `<div id="photo"></div><div id="rest"></div>`;
|
|
|
|
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
|
|
});
|
|
});
|
|
|
|
describe("patchElementInHtml", () => {
|
|
const FIXTURE = `<!doctype html><html><head></head><body>
|
|
<div id="root" data-composition-id="main">
|
|
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
|
|
<div class="chrome">
|
|
<span class="brand">HyperFrames</span>
|
|
</div>
|
|
</div>
|
|
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
|
|
</div>
|
|
</body></html>`;
|
|
|
|
it("patches inline style by id", () => {
|
|
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "inline-style", property: "color", value: "red" },
|
|
]);
|
|
|
|
expect(result).toMatch(/color:\s*red/);
|
|
expect(result).toContain('id="hero"');
|
|
});
|
|
|
|
it("patches inline style by class selector", () => {
|
|
const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
|
|
{ type: "inline-style", property: "font-size", value: "72px" },
|
|
]);
|
|
|
|
expect(result).toMatch(/font-size:\s*72px/);
|
|
});
|
|
|
|
it("patches data attribute", () => {
|
|
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
|
|
]);
|
|
|
|
expect(result).toContain('data-hf-studio-path-offset="true"');
|
|
});
|
|
|
|
it("patches html attribute", () => {
|
|
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "title", value: "greeting" },
|
|
]);
|
|
|
|
expect(result).toContain('title="greeting"');
|
|
});
|
|
|
|
it("patches text content", () => {
|
|
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "text-content", property: "", value: "New Title" },
|
|
]);
|
|
|
|
expect(result).toContain("New Title");
|
|
expect(result).not.toContain("Hello World");
|
|
});
|
|
|
|
it("applies multiple operations in one call", () => {
|
|
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "inline-style", property: "color", value: "blue" },
|
|
{ type: "inline-style", property: "font-size", value: "96px" },
|
|
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
|
|
]);
|
|
|
|
expect(result).toMatch(/color:\s*blue/);
|
|
expect(result).toMatch(/font-size:\s*96px/);
|
|
expect(result).toContain('data-hf-studio-path-offset="true"');
|
|
});
|
|
|
|
it("finds element by composition-id selector", () => {
|
|
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
|
|
{ type: "inline-style", property: "opacity", value: "0.5" },
|
|
]);
|
|
|
|
expect(result).toMatch(/opacity:\s*0\.5/);
|
|
});
|
|
|
|
it("finds element by class with selectorIndex", () => {
|
|
const html = `<div class="item">A</div><div class="item">B</div>`;
|
|
const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
|
|
{ type: "text-content", property: "", value: "Changed" },
|
|
]);
|
|
|
|
expect(result).toContain("A");
|
|
expect(result).toContain("Changed");
|
|
expect(result).not.toContain(">B<");
|
|
});
|
|
|
|
it("returns unchanged html when target not found", () => {
|
|
const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
|
|
{ type: "inline-style", property: "color", value: "red" },
|
|
]);
|
|
|
|
expect(result).toBe(FIXTURE);
|
|
});
|
|
|
|
it("removes inline style when value is null", () => {
|
|
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "inline-style", property: "font-size", value: null },
|
|
]);
|
|
|
|
expect(result).not.toContain("font-size");
|
|
});
|
|
|
|
it("removes attribute when value is null", () => {
|
|
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
|
|
{ type: "html-attribute", property: "data-composition-src", value: null },
|
|
]);
|
|
|
|
expect(result).not.toContain("data-composition-src");
|
|
});
|
|
|
|
it("patches fragment html without doctype", () => {
|
|
const fragment = `<div id="card" style="padding: 8px"><span>Title</span></div>`;
|
|
const result = patchElementInHtml(fragment, { id: "card" }, [
|
|
{ type: "inline-style", property: "padding", value: "16px" },
|
|
]);
|
|
|
|
expect(result).toMatch(/padding:\s*16px/);
|
|
});
|
|
});
|