mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
* fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session Root-causes the setTiming element_not_found resolver-shadow divergence class: timeline edits carry hf-ids read from the live preview DOM, but the preview minted ids AFTER rewriting attributes (and never persisted them for sub-comps), while the SDK session mints from the raw file — content-keyed minting then yields different ids for the same element. Template-based comps were worse: the SDK excluded the whole <template> subtree, so the session had zero elements and every edit diverged. - parsers: ensureHfIds now descends into <template> subtrees (linkedom's querySelectorAll does not), minting and pinning inner ids - sdk: buildRoots/buildElement treat <template> as a transparent container, and resolution (resolveScoped, animation-id map) searches template subtrees via querySelectorAllDeep — template comps now model, resolve, and edit - studio-server: the sub-comp preview route persists hf-ids to the raw file BEFORE the rewrite pipeline (mirrors the main route), pinning one id space across served DOM, disk, and SDK session - studio: resolver-shadow skips structurally-empty sessions (no event, no attempt) and tags fail-open emissions with sourceReadFailed so read errors are distinguishable from unwired readers in telemetry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(parsers,sdk,studio-server,studio): scope template descent, guard persist route Addresses the 10 verified findings from the PR #1981 review: - Restrict template transparency to COMPOSITION templates (<template data-composition-id>) everywhere — ensureHfIds, SDK buildChildren, querySelectorAllDeep. A plain <template> (runtime clone-source) keeps its old fully-excluded behavior: stamping its interior would duplicate one persisted id across every runtime clone, and modeling it would show phantom timeline clips. - Guard the sub-comp persist: only .html files (the wildcard route can serve any project path — stamping an SVG corrupted it on disk), try/catch the read (file-removed race becomes 404, not 500), salt the etag (v2) so pre-fix cached clients don't 304 past the id pin, and thread the stamped content into buildSubCompositionHtml so served ids match the mint even when the disk write is skipped. - Rewrite querySelectorAllDeep as a document-order DOM walk — appending template matches after top-level matches made duplicate-id tiebreaks disagree with the preview's unwrapped DOM (wrong-element edits). - Recurse sourceMutation.querySelectorAllWithTemplates so server-side ops resolve ids at any template depth, matching SDK resolution. - Replace the empty-session silent skip with ONE tagged session_empty event per session — silence would blind the tripwire to exactly the modeling-gap class that exposed the template bug. Attempts stay uncounted (an unmodelable comp can't cut over). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio-server): close TOCTOU in sub-comp hf-id persist (CodeQL js/file-system-race) Replace the route-level stat/read/persist sequence with stampFileHfIds: validation (fstat), read, mint, and write-back all go through ONE open file descriptor (O_NOFOLLOW where supported), so the path cannot be swapped between validation and write. Falls back to read-only stamping when the file isn't writable — content-keyed minting means the SDK derives the same ids from the same bytes even without the disk write. Addresses miguel-heygen's blocking review on PR #1981. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio-server): linear-time template-attr match (CodeQL js/polynomial-redos) promoteTemplateCompositionId's single-pattern regex backtracked polynomially on crafted input. Two-step match: grab each <template> open tag linearly, then find data-composition-id within that short tag text. Same semantics (first template carrying the attr wins). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
199 lines
9.2 KiB
TypeScript
199 lines
9.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { ensureHfIds, mintHfId } from "./hfIds.js";
|
|
import { parseHTML } from "linkedom";
|
|
|
|
function ids(html: string): string[] {
|
|
const { document } = parseHTML(html);
|
|
return Array.from(document.querySelectorAll("[data-hf-id]")).map(
|
|
(e) => e.getAttribute("data-hf-id") as string,
|
|
);
|
|
}
|
|
|
|
// data-hf-id of the first element matching `selector`.
|
|
function idOf(html: string, selector: string): string | null {
|
|
const { document } = parseHTML(html);
|
|
return document.querySelector(selector)?.getAttribute("data-hf-id") ?? null;
|
|
}
|
|
|
|
const doc = (body: string) => `<!doctype html><html><body>${body}</body></html>`;
|
|
|
|
describe("ensureHfIds", () => {
|
|
it("mints a hf- id on every editable element node in body", () => {
|
|
const html = `<!doctype html><html><body>
|
|
<div class="card"><h1>Hi</h1><img src="a.png"><span>x</span></div>
|
|
</body></html>`;
|
|
const out = ensureHfIds(html);
|
|
for (const id of ids(out)) expect(id).toMatch(/^hf-[a-z0-9]{4}$/);
|
|
// div, h1, img, span = 4 ids
|
|
expect(ids(out)).toHaveLength(4);
|
|
});
|
|
|
|
it("skips script/style/template/meta and head", () => {
|
|
const html = `<!doctype html><html><head><meta charset="utf-8"></head>
|
|
<body><script>1</script><style>.a{}</style><p>keep</p></body></html>`;
|
|
const out = ensureHfIds(html);
|
|
// only the <p> gets an id
|
|
expect(ids(out)).toHaveLength(1);
|
|
expect(out).not.toContain("<script data-hf-id");
|
|
expect(out).not.toContain("<style data-hf-id");
|
|
expect(out).not.toContain("<meta data-hf-id");
|
|
});
|
|
|
|
it("is idempotent: a second call mints nothing and is byte-stable", () => {
|
|
const html = `<!doctype html><html><body><div><p>a</p></div></body></html>`;
|
|
const once = ensureHfIds(html);
|
|
const twice = ensureHfIds(once);
|
|
expect(twice).toBe(once);
|
|
});
|
|
|
|
it("pins existing data-hf-id and mints around it", () => {
|
|
const html = `<!doctype html><html><body>
|
|
<div data-hf-id="hf-keep"><p>a</p></div></body></html>`;
|
|
const out = ensureHfIds(html);
|
|
expect(out).toContain('data-hf-id="hf-keep"');
|
|
expect(ids(out)).toContain("hf-keep");
|
|
expect(ids(out)).toHaveLength(2); // div pinned + p minted
|
|
});
|
|
|
|
it("two identical sibling nodes get distinct ids", () => {
|
|
const html = `<!doctype html><html><body>
|
|
<p class="x">same</p><p class="x">same</p></body></html>`;
|
|
const got = ids(ensureHfIds(html));
|
|
expect(new Set(got).size).toBe(got.length);
|
|
});
|
|
|
|
it("is deterministic: same input → same ids", () => {
|
|
const html = `<!doctype html><html><body><div><p>a</p><span>b</span></div></body></html>`;
|
|
expect(ids(ensureHfIds(html))).toEqual(ids(ensureHfIds(html)));
|
|
});
|
|
|
|
it("mintHfId rehashes on collision against the assigned set", () => {
|
|
const { document } = parseHTML(`<p class="x">same</p>`);
|
|
const el = document.querySelector("p") as Element;
|
|
const assigned = new Set<string>();
|
|
const a = mintHfId(el, assigned);
|
|
const b = mintHfId(el, assigned); // identical element, same assigned set
|
|
expect(a).not.toBe(b);
|
|
expect(a).toMatch(/^hf-[a-z0-9]{4}$/);
|
|
expect(b).toMatch(/^hf-[a-z0-9]{4}$/);
|
|
});
|
|
|
|
// Post-persist stability: once data-hf-id is written back to source, edits
|
|
// don't drift the id because the attribute is already present and pinned.
|
|
it("pinned id survives text edit after first persist", () => {
|
|
const raw = `<!doctype html><html><body><div>original text</div></body></html>`;
|
|
const persisted = ensureHfIds(raw); // simulates write-back on first serve
|
|
const [originalId] = ids(persisted);
|
|
const edited = persisted.replace("original text", "edited text");
|
|
expect(ids(ensureHfIds(edited))).toContain(originalId);
|
|
});
|
|
|
|
// Hash-based stability (no prior pin): the same element content yields the
|
|
// same id regardless of what sibling elements appear in the document.
|
|
it("content-keyed minting is stable: same element content → same id in different documents", () => {
|
|
const alone = `<!doctype html><html><body><div class="card">hello</div></body></html>`;
|
|
const [idAlone] = ids(ensureHfIds(alone));
|
|
// The <div class="card">hello</div> appears alongside a new sibling here.
|
|
const withSibling = `<!doctype html><html><body><span>prefix</span><div class="card">hello</div></body></html>`;
|
|
expect(ids(ensureHfIds(withSibling))).toContain(idAlone);
|
|
});
|
|
});
|
|
|
|
// Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds
|
|
// once ids are persisted to source (pinning) AND the behavior for truly unpinned
|
|
// HTML (no data-hf-id in the input — unreachable in production after write-back
|
|
// landed in R7 Task 1-2, but still the correct contract for that path).
|
|
describe("ensureHfIds — template-inner minting", () => {
|
|
// linkedom's querySelectorAll does not descend into <template>, so extract
|
|
// ids by regex over the serialized output instead of the DOM-walk helper.
|
|
const rawIds = (html: string) => [...html.matchAll(/data-hf-id="([^"]+)"/g)].map((m) => m[1]);
|
|
|
|
it("mints ids on elements inside a <template> (template itself stays unstamped)", () => {
|
|
const html = doc(
|
|
`<template data-composition-id="t"><div class="clip" data-start="0" data-end="3">Hi</div><p>x</p></template>`,
|
|
);
|
|
const out = ensureHfIds(html);
|
|
expect(rawIds(out)).toHaveLength(2); // div + p, not the template
|
|
expect(out).not.toMatch(/<template[^>]*data-hf-id/);
|
|
});
|
|
|
|
it("template-inner ids equal the ids minted for the same content unwrapped (preview parity)", () => {
|
|
const inner = `<div class="clip" data-start="0" data-end="3">Hi</div><p>x</p>`;
|
|
const wrapped = ensureHfIds(doc(`<template data-composition-id="t">${inner}</template>`));
|
|
const unwrapped = ensureHfIds(doc(inner));
|
|
expect(rawIds(wrapped)).toEqual(rawIds(unwrapped));
|
|
});
|
|
|
|
it("pins existing template-inner ids and seeds them against fresh mints", () => {
|
|
const html = doc(
|
|
`<template data-composition-id="t"><div data-hf-id="hf-keep">a</div><p>b</p></template>`,
|
|
);
|
|
const out = ensureHfIds(html);
|
|
expect(out).toContain('data-hf-id="hf-keep"');
|
|
expect(new Set(rawIds(out)).size).toBe(2);
|
|
});
|
|
|
|
it("descends nested composition templates", () => {
|
|
const html = doc(
|
|
`<template data-composition-id="a"><div>a</div><template data-composition-id="b"><span>b</span></template></template>`,
|
|
);
|
|
const out = ensureHfIds(html);
|
|
expect(rawIds(out)).toHaveLength(2); // div + span
|
|
});
|
|
|
|
it("does NOT stamp inside a plain <template> (runtime clone-source)", () => {
|
|
// A plain template's content is cloned N times into the live DOM at
|
|
// runtime; a persisted inner id would be duplicated across every clone.
|
|
const html = doc(`<div class="stage">x</div><template><li class="row">item</li></template>`);
|
|
const out = ensureHfIds(html);
|
|
expect(rawIds(out)).toHaveLength(1); // only the stage div
|
|
expect(out).not.toMatch(/<li[^>]*data-hf-id/);
|
|
});
|
|
|
|
it("does NOT stamp inside a plain template nested in a composition template", () => {
|
|
const html = doc(
|
|
`<template data-composition-id="t"><div>a</div><template><li>clone-src</li></template></template>`,
|
|
);
|
|
const out = ensureHfIds(html);
|
|
expect(rawIds(out)).toHaveLength(1); // only the div
|
|
expect(out).not.toMatch(/<li[^>]*data-hf-id/);
|
|
});
|
|
|
|
it("is idempotent for template-inner ids", () => {
|
|
const once = ensureHfIds(doc(`<template data-composition-id="t"><div>a</div></template>`));
|
|
expect(ensureHfIds(once)).toBe(once);
|
|
});
|
|
});
|
|
|
|
describe("ensureHfIds — edit lifecycle (R1 stability)", () => {
|
|
it("pinned id survives a content edit (the §3 write-back guarantee)", () => {
|
|
// Element already carries data-hf-id in source (as it would after write-back).
|
|
const edited = doc(`<p class="body" data-hf-id="hf-abcd">Hello world</p>`);
|
|
expect(idOf(ensureHfIds(edited), "p.body")).toBe("hf-abcd");
|
|
});
|
|
|
|
it("unpinned id drifts when element text is edited (pure-hash, unreachable after write-back)", () => {
|
|
// No data-hf-id in source → every parse re-mints from content. This path is
|
|
// unreachable in production after R7 write-back: the first serve pins the id.
|
|
const before = idOf(ensureHfIds(doc(`<p class="body">Hello</p>`)), "p.body");
|
|
const after = idOf(ensureHfIds(doc(`<p class="body">Hello world</p>`)), "p.body");
|
|
expect(before).not.toBe(after);
|
|
});
|
|
|
|
it("unpinned id drifts when attribute is edited (pure-hash, unreachable after write-back)", () => {
|
|
const before = idOf(ensureHfIds(doc(`<p class="body">x</p>`)), "p");
|
|
const after = idOf(ensureHfIds(doc(`<p class="lead">x</p>`)), "p");
|
|
expect(before).not.toBe(after);
|
|
});
|
|
|
|
it("identical-content siblings: second occurrence gets a position-derived dedup id", () => {
|
|
// Insertion stability holds for DISTINCT content (covered elsewhere), but a
|
|
// second identical sibling collides and gets a position-derived dedup id.
|
|
// First element keeps the base (content-derived) id; documented in project_hfid_dedup_tiebreak.
|
|
const single = idOf(ensureHfIds(doc(`<p class="x">same</p>`)), "p.x");
|
|
const pair = ids(ensureHfIds(doc(`<p class="x">same</p><p class="x">same</p>`)));
|
|
expect(pair[0]).toBe(single); // first identical element: stable, content-derived
|
|
expect(pair[1]).not.toBe(single); // second: dedup id, exists only by position
|
|
});
|
|
});
|