Files
hyperframes/packages/parsers/src/hfIds.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

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