Files
hyperframes/packages/studio-server/src/helpers/hfIdPersist.test.ts
T
Vance IngallsandClaude Fable 5 3a717fa719 fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session (#1981)
* 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>
2026-07-06 00:16:07 -07:00

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();
});
});