mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* test(studio): add T5b rotation+motion build-patches characterization Extends manualEditsDomPatches.test.ts with rotation and motion pairs. Same 4-pattern structure: populated, empty, clear restores originals, build/clear symmetry. Merges duplicate manualEditsTypes import block. * test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization Fixes four gaps identified in max-setting code review: - Box-size clear: replace arrayContaining with full ordered toEqual (30 ops) - Box-size / pathOffset / rotation clear: add empty-string coercion tests (origVal||null must produce null, not set property to "") - Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR - Motion clear: prove input-independence by calling with both empty and populated element and asserting identical output * refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU) Deduplicate helpers shared by T1 (htmlParser.roundtrip.test.ts) and T2 (stableIds.test.ts). Both files inline identical implementations; extract to test-utils.ts so future parser tests (T6a…) import one copy. Also fix lefthook fallow command to unset GIT_DIR+GIT_INDEX_FILE before running — those vars are set by git in worktree hook context and block fallow’s internal temp-worktree creation. * test(core): add T10 PreviewAdapter contract stubs (spec for R7) All 14 tests are it.todo, following the T4 pattern. The stubs define the full createPreviewAdapter interface — elementAtPoint (root exclusion, hf-id ancestor walk, opacity filter), applyDraft/revertDraft (draft marker lifecycle), commitPreview (patch derivation), and getElementTimings (data-start/data-end reader). createPreviewAdapter does not exist yet; R7 implements it and converts these stubs to real assertions. * test(core): add T6a GSAP parser golden baselines (Recast/Babel snapshot) 6 toMatchFileSnapshot tests across 3 representative scripts (minimal, moderate, complex). Captures parseGsapScript + serializeGsapAnimations output before the Recast → Meriyah swap so any parser change is detected as a golden diff rather than a silent behavioral regression. Goldens live in src/parsers/__goldens__/ and are checked in. Add __goldens__/** to fallow ignorePatterns (data files, not modules) and to .prettierignore so oxfmt does not reformat vitest-written snapshot files. * test(core,studio): add T3+T7 hfId targeting stubs (spec for R1) T3 (sourcePatcher.test.ts): 5 it.todo stubs for PatchTarget.hfId targeting — style, text, attribute patches plus preservation and fallthrough cases. T7 (sourceMutation.test.ts): 2 it.todo stubs for SourceMutationTarget.hfId — basic patch and data-hf-id survival after patch. Neither interface has hfId yet. R1 adds the field + [data-hf-id="…"] branch in findTagByTarget / findTargetElement, then converts these to real assertions.
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
removeElementFromHtml,
|
|
patchElementInHtml,
|
|
probeElementInSource,
|
|
} 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 { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "inline-style", property: "color", value: "red" },
|
|
]);
|
|
|
|
expect(matched).toBe(true);
|
|
expect(result).toMatch(/color:\s*red/);
|
|
expect(result).toContain('id="hero"');
|
|
});
|
|
|
|
it("patches inline style by class selector", () => {
|
|
const { html: 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 { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
|
|
]);
|
|
|
|
expect(result).toContain('data-hf-studio-path-offset="true"');
|
|
});
|
|
|
|
it("does not double data- prefix when property already has it", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
|
|
]);
|
|
|
|
expect(result).toContain('data-hf-studio-path-offset="true"');
|
|
expect(result).not.toContain("data-data-hf-studio-path-offset");
|
|
});
|
|
|
|
it("does not double data- prefix for any studio attribute", () => {
|
|
const attrs = [
|
|
"data-hf-studio-path-offset",
|
|
"data-hf-studio-original-translate",
|
|
"data-hf-studio-original-inline-translate",
|
|
"data-hf-studio-box-size",
|
|
"data-hf-studio-rotation",
|
|
];
|
|
for (const attr of attrs) {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "attribute", property: attr, value: "true" },
|
|
]);
|
|
expect(result).toContain(`${attr}="true"`);
|
|
expect(result).not.toContain(`data-${attr}`);
|
|
}
|
|
});
|
|
|
|
it("removes attribute with data- prefix already present", () => {
|
|
const { html: withAttr } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
|
|
]);
|
|
expect(withAttr).toContain('data-hf-studio-path-offset="true"');
|
|
|
|
const { html: removed } = patchElementInHtml(withAttr, { id: "hero" }, [
|
|
{ type: "attribute", property: "data-hf-studio-path-offset", value: null },
|
|
]);
|
|
expect(removed).not.toContain("hf-studio-path-offset");
|
|
});
|
|
|
|
it("patches html attribute", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "title", value: "greeting" },
|
|
]);
|
|
|
|
expect(result).toContain('title="greeting"');
|
|
});
|
|
|
|
it("patches text content", () => {
|
|
const { html: 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 { html: 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 { html: 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 { html: 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 and matched:false when target not found", () => {
|
|
const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
|
|
{ type: "inline-style", property: "color", value: "red" },
|
|
]);
|
|
|
|
expect(matched).toBe(false);
|
|
expect(result).toBe(FIXTURE);
|
|
});
|
|
|
|
it("removes inline style when value is null", () => {
|
|
const { html: 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 { html: 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 { html: result } = patchElementInHtml(fragment, { id: "card" }, [
|
|
{ type: "inline-style", property: "padding", value: "16px" },
|
|
]);
|
|
|
|
expect(result).toMatch(/padding:\s*16px/);
|
|
});
|
|
|
|
it("rejects event handler attributes", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "onload", value: "fetch('/evil')" },
|
|
]);
|
|
|
|
expect(result).not.toContain("onload");
|
|
expect(result).not.toContain("fetch");
|
|
});
|
|
|
|
it("rejects javascript: URLs in src", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "src", value: "javascript:alert(1)" },
|
|
]);
|
|
|
|
expect(result).not.toContain("javascript:");
|
|
});
|
|
|
|
it("allows aria-* and data-* attributes", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "aria-label", value: "greeting" },
|
|
{ type: "html-attribute", property: "data-custom", value: "test" },
|
|
]);
|
|
|
|
expect(result).toContain('aria-label="greeting"');
|
|
expect(result).toContain('data-custom="test"');
|
|
});
|
|
|
|
it("rejects srcdoc and formaction attributes", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "srcdoc", value: "<script>alert(1)</script>" },
|
|
{ type: "html-attribute", property: "formaction", value: "javascript:void(0)" },
|
|
]);
|
|
|
|
expect(result).not.toContain("srcdoc");
|
|
expect(result).not.toContain("formaction");
|
|
});
|
|
|
|
it("rejects on* event handlers regardless of casing", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "onClick", value: "alert(1)" },
|
|
{ type: "html-attribute", property: "ONERROR", value: "alert(2)" },
|
|
{ type: "html-attribute", property: "onmouseover", value: "alert(3)" },
|
|
]);
|
|
|
|
expect(result).not.toContain("alert");
|
|
});
|
|
|
|
it("rejects data:text/html URIs in src", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{
|
|
type: "html-attribute",
|
|
property: "src",
|
|
value: "data:text/html,<script>alert(1)</script>",
|
|
},
|
|
]);
|
|
|
|
expect(result).not.toContain("data:text/html");
|
|
});
|
|
|
|
it("allows safe href values", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "href", value: "https://example.com" },
|
|
]);
|
|
|
|
expect(result).toContain('href="https://example.com"');
|
|
});
|
|
|
|
it("rejects javascript: in href", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "href", value: "javascript:alert(1)" },
|
|
]);
|
|
|
|
expect(result).not.toContain("javascript:");
|
|
});
|
|
|
|
it("allows legitimate form and media attributes", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "placeholder", value: "Enter text" },
|
|
{ type: "html-attribute", property: "target", value: "_blank" },
|
|
{ type: "html-attribute", property: "rel", value: "noopener" },
|
|
{ type: "html-attribute", property: "srcset", value: "img-2x.png 2x" },
|
|
]);
|
|
|
|
expect(result).toContain('placeholder="Enter text"');
|
|
expect(result).toContain('target="_blank"');
|
|
expect(result).toContain('rel="noopener"');
|
|
expect(result).toContain("srcset");
|
|
});
|
|
|
|
it("rejects unknown/dangerous attributes", () => {
|
|
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
|
{ type: "html-attribute", property: "xmlns", value: "http://evil.com" },
|
|
{ type: "html-attribute", property: "background", value: "http://evil.com/bg.js" },
|
|
{ type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" },
|
|
]);
|
|
|
|
expect(result).not.toContain("xmlns");
|
|
expect(result).not.toContain("background=");
|
|
expect(result).not.toContain("dynsrc");
|
|
});
|
|
});
|
|
|
|
describe("probeElementInSource", () => {
|
|
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("returns true for an element found by id", () => {
|
|
expect(probeElementInSource(FIXTURE, { id: "hero" })).toBe(true);
|
|
});
|
|
|
|
it("returns true for an element found by class selector", () => {
|
|
expect(probeElementInSource(FIXTURE, { selector: ".hero-heading" })).toBe(true);
|
|
});
|
|
|
|
it("returns true for an element found by data-composition-id selector", () => {
|
|
expect(probeElementInSource(FIXTURE, { selector: '[data-composition-id="overlay"]' })).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("returns false for an id that does not exist in source", () => {
|
|
expect(probeElementInSource(FIXTURE, { id: "arrows-svg" })).toBe(false);
|
|
});
|
|
|
|
it("returns false for a class selector that does not exist", () => {
|
|
expect(probeElementInSource(FIXTURE, { selector: ".phone-frame" })).toBe(false);
|
|
});
|
|
|
|
it("returns false when target has neither id nor selector", () => {
|
|
expect(probeElementInSource(FIXTURE, {})).toBe(false);
|
|
});
|
|
|
|
it("returns true for class selector with valid selectorIndex", () => {
|
|
const html = `<div class="item">A</div><div class="item">B</div>`;
|
|
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 1 })).toBe(true);
|
|
});
|
|
|
|
it("returns false for class selector with out-of-bounds selectorIndex", () => {
|
|
const html = `<div class="item">A</div><div class="item">B</div>`;
|
|
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 5 })).toBe(false);
|
|
});
|
|
|
|
it("returns false for an element that would only exist after JS execution", () => {
|
|
const sourceHtml = `<!doctype html><html><head></head><body>
|
|
<div id="root" data-composition-id="main">
|
|
<div id="canvas"></div>
|
|
<script>
|
|
const svg = document.createElement("div");
|
|
svg.id = "arrows-svg";
|
|
document.getElementById("canvas").appendChild(svg);
|
|
</script>
|
|
</div>
|
|
</body></html>`;
|
|
|
|
expect(probeElementInSource(sourceHtml, { id: "arrows-svg" })).toBe(false);
|
|
expect(probeElementInSource(sourceHtml, { id: "canvas" })).toBe(true);
|
|
});
|
|
});
|
|
|
|
// T7 — data-hf-id targeting (spec for R1).
|
|
// R1 adds `hfId?: string` to SourceMutationTarget and a `[data-hf-id="…"]` branch
|
|
// in findTargetElement (sourceMutation.ts:34). Convert from it.todo in the R1 PR.
|
|
describe("T7 — data-hf-id targeting (spec for R1)", () => {
|
|
it.todo("patches element by data-hf-id when no HTML id attribute is present");
|
|
|
|
it.todo("data-hf-id attribute survives the patch (can be targeted again)");
|
|
});
|