diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index 6aae4b769..13df9f5e0 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -26,13 +26,13 @@ describe("parseHtml", () => { const result = parseHtml(html); expect(result.elements).toHaveLength(2); - expect(result.elements[0].id).toBe("text1"); + expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/); expect(result.elements[0].startTime).toBe(0); expect(result.elements[0].duration).toBe(5); expect(result.elements[0].name).toBe("Title"); expect(result.elements[0].type).toBe("text"); - expect(result.elements[1].id).toBe("text2"); + expect(result.elements[1].id).toMatch(/^hf-[a-z0-9]{4}$/); expect(result.elements[1].startTime).toBe(2); expect(result.elements[1].duration).toBe(5); }); @@ -53,7 +53,7 @@ describe("parseHtml", () => { expect(result.elements).toHaveLength(1); expect(result.elements[0].type).toBe("composition"); - expect(result.elements[0].id).toBe("comp1"); + expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/); if (result.elements[0].type === "composition") { expect(result.elements[0].compositionId).toBe("abc123"); expect(result.elements[0].src).toBe("/compositions/abc123"); @@ -76,20 +76,20 @@ describe("parseHtml", () => { expect(result.elements).toHaveLength(3); - const video = result.elements.find((e) => e.id === "vid1"); + const video = result.elements.find((e) => e.type === "video"); expect(video).toBeDefined(); - expect(video?.type).toBe("video"); + expect(video?.id).toMatch(/^hf-[a-z0-9]{4}$/); if (video?.type === "video") { expect(video.src).toBe("video.mp4"); } - const audio = result.elements.find((e) => e.id === "aud1"); + const audio = result.elements.find((e) => e.type === "audio"); expect(audio).toBeDefined(); - expect(audio?.type).toBe("audio"); + expect(audio?.id).toMatch(/^hf-[a-z0-9]{4}$/); - const img = result.elements.find((e) => e.id === "img1"); + const img = result.elements.find((e) => e.type === "image"); expect(img).toBeDefined(); - expect(img?.type).toBe("image"); + expect(img?.id).toMatch(/^hf-[a-z0-9]{4}$/); }); it("handles missing attributes gracefully", () => { @@ -398,9 +398,12 @@ describe("parseHtml", () => { `; const result = parseHtml(html); - expect(result.keyframes["text1"]).toBeDefined(); - expect(result.keyframes["text1"]).toHaveLength(2); - expect(result.keyframes["text1"][0].id).toBe("kf1"); + const elId = result.elements[0]?.id ?? ""; + expect(elId).toMatch(/^hf-[a-z0-9]{4}$/); + const kfs = result.keyframes[elId]; + expect(kfs).toBeDefined(); + expect(kfs).toHaveLength(2); + expect(kfs[0].id).toBe("kf1"); }); it("parses stage zoom keyframes", () => { diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index c23b45911..8915d411d 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -368,13 +368,90 @@ describe("probeElementInSource", () => { // 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.todo("updates inline style by data-hf-id when no HTML id attribute is present"); + it("updates inline style by data-hf-id when no HTML id attribute is present", () => { + const source = `
Old text
`; + 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.todo("updates attribute by data-hf-id"); + it("updates attribute by data-hf-id", () => { + const source = ``; + 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.todo("data-hf-id attribute survives the patch (can be targeted again)"); + it("data-hf-id attribute survives the patch (can be targeted again)", () => { + const source = `Old
`; + 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"'); + }); }); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 8cdc5ae05..d7c2c2ef7 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -2,6 +2,7 @@ import { parseHTML } from "linkedom"; export interface SourceMutationTarget { id?: string | null; + hfId?: string; selector?: string; selectorIndex?: number; } @@ -31,7 +32,41 @@ function querySelectorAllWithTemplates(root: Document | Element, selector: strin return []; } +// Prevent CSS attribute-selector injection via a crafted hfId: escape +// backslashes first, then double-quotes. Keeps a malformed/hostile value from +// breaking out of the `[data-hf-id="…"]` selector once callers beyond the +// internal mint contract (R2+ user flows) pass values here. +function escapeCssAttrValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function findByHfId(document: Document, hfId: string): Element | null { + try { + const matches = querySelectorAllWithTemplates( + document, + `[data-hf-id="${escapeCssAttrValue(hfId)}"]`, + ); + if (matches.length > 1) { + // The mint contract guarantees uniqueness; a duplicate means upstream + // id drift. Don't silently patch an arbitrary one — surface it. + // eslint-disable-next-line no-console + console.warn( + `sourceMutation: data-hf-id "${hfId}" matched ${matches.length} elements; using the first. ids must be unique per document.`, + ); + } + return matches[0] ?? null; + } catch { + // Malformed selector despite escaping — let the caller fall back. + return null; + } +} + function findTargetElement(document: Document, target: SourceMutationTarget): Element | null { + if (target.hfId) { + const el = findByHfId(document, target.hfId); + if (el) return el; + } + if (target.id) { const byId = document.getElementById(target.id); if (byId) return byId; @@ -207,7 +242,7 @@ export function patchElementInHtml( } export function probeElementInSource(source: string, target: SourceMutationTarget): boolean { - if (!target.id && !target.selector) return false; + if (!target.id && !target.hfId && !target.selector) return false; const { document } = parseSourceDocument(source); const el = findTargetElement(document, target); return el != null && isHTMLElement(el);