mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
refactor: extract @hyperframes/studio-server from core (#1757)
* refactor: extract @hyperframes/studio-server package from core Moves all studio-api routes, helpers, and Hono server wiring from packages/core/src/studio-api/ into a new standalone packages/studio-server package (@hyperframes/studio-server). Core keeps thin re-export stubs at @hyperframes/core/studio-api and the subpath helpers (screenshot-clip, draft-markers, etc.) for backward compatibility. Consumer imports (cli studioServer, vite adapter/config, producer htmlCompiler, studio manualEditsTypes) are updated to import from @hyperframes/studio-server directly. Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was in compiler/rewriteSubCompPaths.ts but not re-exported), required by @hyperframes/studio-server/helpers/subComposition. Removes postcss-selector-parser from @hyperframes/core dependencies (moved to @hyperframes/studio-server which owns the routes that used it). Depends on @hyperframes/parsers (PR #1755). * fix(ci): add parsers+studio-server to Dockerfile and build before preview tests * fix(ci): build @hyperframes/studio-server before Test and studio load smoke Studio's vite.config.ts imports @hyperframes/studio-server, which resolves via its "node" export condition to built dist. The Test and studio-load-smoke jobs only built parsers + core, so esbuild's config load failed to resolve the package entry. Build studio-server too. * fix(studio): repoint sdkCutoverParity test import to studio-server sourceMutation moved from core's studio-api to @hyperframes/studio-server; the test still imported the deleted core path. This was masked while studio's vite.config failed to load (couldn't resolve studio-server); now that the config loads, the test runs and the stale import surfaced.
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
splitElementInHtml,
|
||||
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.
|
||||
// Covers the same surface as T3 (Studio sourcePatcher) — Core sourceMutation supports
|
||||
// all patch types (inline-style, attribute, text-content) via patchElementInHtml.
|
||||
describe("T7 — data-hf-id targeting (spec for R1)", () => {
|
||||
it("updates inline style by data-hf-id when no HTML id attribute is present", () => {
|
||||
const source = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
|
||||
const { html, matched } = patchElementInHtml(source, { hfId: "hf-x7k2" }, [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(matched).toBe(true);
|
||||
expect(html).toMatch(/color:\s*blue/);
|
||||
expect(html).toContain('data-hf-id="hf-x7k2"');
|
||||
});
|
||||
|
||||
it("updates text content by data-hf-id", () => {
|
||||
const source = `<p data-hf-id="hf-a1b2">Old text</p>`;
|
||||
const { html, matched } = patchElementInHtml(source, { hfId: "hf-a1b2" }, [
|
||||
{ type: "text-content", property: "", value: "New text" },
|
||||
]);
|
||||
expect(matched).toBe(true);
|
||||
expect(html).toContain("New text");
|
||||
});
|
||||
|
||||
it("updates attribute by data-hf-id", () => {
|
||||
const source = `<div data-hf-id="hf-c3d4" data-start="0"></div>`;
|
||||
const { html, matched } = patchElementInHtml(source, { hfId: "hf-c3d4" }, [
|
||||
{ type: "attribute", property: "start", value: "2.5" },
|
||||
]);
|
||||
expect(matched).toBe(true);
|
||||
expect(html).toContain('data-start="2.5"');
|
||||
});
|
||||
|
||||
it("data-hf-id attribute survives the patch (can be targeted again)", () => {
|
||||
const source = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
|
||||
const { html } = patchElementInHtml(source, { hfId: "hf-x7k2" }, [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(html).toContain('data-hf-id="hf-x7k2"');
|
||||
});
|
||||
|
||||
it("hfId lookup falls through to selector when hfId is not found in the document", () => {
|
||||
const source = `<h1 class="headline" style="color: red">Hello</h1>`;
|
||||
const { html, matched } = patchElementInHtml(
|
||||
source,
|
||||
{ hfId: "hf-missing", selector: ".headline" },
|
||||
[{ type: "inline-style", property: "color", value: "blue" }],
|
||||
);
|
||||
expect(matched).toBe(true);
|
||||
expect(html).toMatch(/color:\s*blue/);
|
||||
});
|
||||
|
||||
it("does not break out of the selector on a crafted hfId (CSS injection guard)", () => {
|
||||
// A value with a quote/bracket must be escaped, not injected — it should
|
||||
// simply match nothing and leave the source untouched, never throw.
|
||||
const source = `<h1 class="safe">A</h1><h1 class="victim">B</h1>`;
|
||||
const evil = `x"] , [class="victim`;
|
||||
const run = () =>
|
||||
patchElementInHtml(source, { hfId: evil }, [
|
||||
{ type: "text-content", property: "textContent", value: "HACKED" },
|
||||
]);
|
||||
expect(run).not.toThrow();
|
||||
const { html, matched } = run();
|
||||
expect(matched).toBe(false);
|
||||
expect(html).toBe(source);
|
||||
expect(html).not.toContain("HACKED");
|
||||
});
|
||||
|
||||
// The Studio edit path targets by id/selector (it never sends hfId). Once a
|
||||
// persisted data-hf-id exists in source, those edits must NOT strip it — else
|
||||
// the stable handle is destroyed by the next edit. This is the preservation
|
||||
// guarantee the write-back design depends on.
|
||||
it("preserves an existing data-hf-id when the element is patched by id", () => {
|
||||
const source = `<h1 id="hero" data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
|
||||
const { html, matched } = patchElementInHtml(source, { id: "hero" }, [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(matched).toBe(true);
|
||||
expect(html).toMatch(/color:\s*blue/);
|
||||
expect(html).toContain('data-hf-id="hf-x7k2"');
|
||||
});
|
||||
|
||||
it("preserves an existing data-hf-id when the element is patched by selector", () => {
|
||||
const source = `<p class="body" data-hf-id="hf-a1b2">Old</p>`;
|
||||
const { html, matched } = patchElementInHtml(source, { selector: ".body" }, [
|
||||
{ type: "text-content", property: "textContent", value: "New" },
|
||||
]);
|
||||
expect(matched).toBe(true);
|
||||
expect(html).toContain("New");
|
||||
expect(html).toContain('data-hf-id="hf-a1b2"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitElementInHtml — hfId clone isolation", () => {
|
||||
it("does not copy data-hf-id to the cloned second half", () => {
|
||||
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
|
||||
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
|
||||
|
||||
expect(matched).toBe(true);
|
||||
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitElementInHtml", () => {
|
||||
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
|
||||
|
||||
it("splits element at the given time", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.html).toContain('data-duration="2"');
|
||||
expect(result.html).toContain('id="box-split"');
|
||||
expect(result.html).toContain('data-start="3"');
|
||||
expect(result.html).toContain('data-duration="4"');
|
||||
});
|
||||
|
||||
it("duplicates CSS rules for the new element ID", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toContain("#box-split");
|
||||
expect(result.html).toContain("background: red");
|
||||
const cssMatches = result.html.match(/#box-split\s*\{/g);
|
||||
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("deduplicates IDs when the requested newId already exists", () => {
|
||||
const withExisting = source.replace(
|
||||
"</div></div>",
|
||||
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
|
||||
);
|
||||
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.html).toContain('id="box-split-2"');
|
||||
});
|
||||
|
||||
it("keeps clip class on the cloned element", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
|
||||
});
|
||||
|
||||
it("returns matched false for out-of-range split time", () => {
|
||||
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
|
||||
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
|
||||
});
|
||||
|
||||
it("splits a GSAP element with no authored timing using fallback timing", () => {
|
||||
// #title has no data-start/data-duration (GSAP-driven); the store supplies the range.
|
||||
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
|
||||
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
|
||||
start: 0,
|
||||
duration: 6,
|
||||
});
|
||||
expect(result.matched).toBe(true);
|
||||
// original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined)
|
||||
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/)![0];
|
||||
expect(original).toContain('data-start="0"');
|
||||
expect(original).toContain('data-duration="2"');
|
||||
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/)![0];
|
||||
expect(clone).toContain('data-start="2"');
|
||||
expect(clone).toContain('data-duration="4"');
|
||||
});
|
||||
|
||||
it("still rejects a no-timing element when no fallback timing is given", () => {
|
||||
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
|
||||
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
|
||||
});
|
||||
|
||||
it("adjusts media playback-start for the second half", () => {
|
||||
const mediaSource = source.replace(
|
||||
'id="box" class="clip" data-start="1" data-duration="6"',
|
||||
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
|
||||
);
|
||||
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user