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>
116 lines
4.5 KiB
TypeScript
116 lines
4.5 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { persistHfIdsIfNeeded, stampFileHfIds } from "./hfIdPersist.js";
|
|
|
|
describe("persistHfIdsIfNeeded", () => {
|
|
const tmpDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
tmpDirs.length = 0;
|
|
});
|
|
|
|
function tmpFile(content: string): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
|
|
tmpDirs.push(dir);
|
|
const file = join(dir, "index.html");
|
|
writeFileSync(file, content, "utf-8");
|
|
return file;
|
|
}
|
|
|
|
it("writes data-hf-id to disk when source is untagged", () => {
|
|
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
|
|
const file = tmpFile(raw);
|
|
const returned = persistHfIdsIfNeeded(file, raw);
|
|
expect(returned).toContain('data-hf-id="hf-');
|
|
const onDisk = readFileSync(file, "utf-8");
|
|
expect(onDisk).toContain('data-hf-id="hf-');
|
|
expect(onDisk).toBe(returned);
|
|
});
|
|
|
|
it("does not rewrite disk when source is already tagged", () => {
|
|
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
|
|
const file = tmpFile(raw);
|
|
const tagged = persistHfIdsIfNeeded(file, raw);
|
|
const diskAfterFirst = readFileSync(file, "utf-8");
|
|
const returned2 = persistHfIdsIfNeeded(file, tagged);
|
|
expect(returned2).toBe(tagged);
|
|
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
|
|
});
|
|
|
|
it("does not rewrite when source is already tagged with non-standard HTML formatting", () => {
|
|
// Single-quoted attrs would cause a false-positive write under string-equality
|
|
// change detection; count-based detection handles this correctly.
|
|
const alreadyTagged = `<!doctype html><html><body><div data-hf-id='hf-ab12'>hello</div></body></html>`;
|
|
const file = tmpFile(alreadyTagged);
|
|
persistHfIdsIfNeeded(file, alreadyTagged);
|
|
expect(readFileSync(file, "utf-8")).toBe(alreadyTagged);
|
|
});
|
|
|
|
it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
|
|
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
|
|
const file = tmpFile(raw);
|
|
const result = persistHfIdsIfNeeded(file, raw);
|
|
const onDisk = readFileSync(file, "utf-8");
|
|
expect(result).toBe(onDisk);
|
|
});
|
|
|
|
it("skips write if file was modified concurrently (TOCTOU guard)", () => {
|
|
const old = `<!doctype html><html><body><div>original</div></body></html>`;
|
|
const newer = `<!doctype html><html><body><div>modified by user</div></body></html>`;
|
|
// Disk has newer content — simulates a concurrent save after the server read old.
|
|
const file = tmpFile(newer);
|
|
const returned = persistHfIdsIfNeeded(file, old);
|
|
// Serve-time HTML gets ids based on what we read.
|
|
expect(returned).toContain('data-hf-id="hf-');
|
|
// Disk must not be overwritten — user's concurrent save is preserved.
|
|
expect(readFileSync(file, "utf-8")).toBe(newer);
|
|
});
|
|
});
|
|
|
|
describe("stampFileHfIds", () => {
|
|
const tmpDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
tmpDirs.length = 0;
|
|
});
|
|
|
|
function tmpFile(content: string): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "hfid-stamp-test-"));
|
|
tmpDirs.push(dir);
|
|
const file = join(dir, "scene.html");
|
|
writeFileSync(file, content, "utf-8");
|
|
return file;
|
|
}
|
|
|
|
it("stamps ids and writes back through the same fd", () => {
|
|
const file = tmpFile(`<div class="clip" data-start="0" data-end="3">Hi</div>`);
|
|
const returned = stampFileHfIds(file);
|
|
expect(returned).toContain('data-hf-id="hf-');
|
|
expect(readFileSync(file, "utf-8")).toBe(returned);
|
|
});
|
|
|
|
it("does not rewrite an already-stamped file", () => {
|
|
const file = tmpFile(`<div data-hf-id="hf-keep">Hi</div>`);
|
|
const before = readFileSync(file, "utf-8");
|
|
const returned = stampFileHfIds(file);
|
|
expect(returned).toContain('data-hf-id="hf-keep"');
|
|
expect(readFileSync(file, "utf-8")).toBe(before); // byte-identical, no write
|
|
});
|
|
|
|
it("returns null for a missing file", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "hfid-stamp-test-"));
|
|
tmpDirs.push(dir);
|
|
expect(stampFileHfIds(join(dir, "nope.html"))).toBeNull();
|
|
});
|
|
|
|
it("returns null for a directory", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "hfid-stamp-test-"));
|
|
tmpDirs.push(dir);
|
|
expect(stampFileHfIds(dir)).toBeNull();
|
|
});
|
|
});
|