From 3d817d7439c520ee0692672e1de94edc15721253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 19 Jun 2026 15:48:57 +0000 Subject: [PATCH] fix(sdk,studio): restore DOM edit cutover parity - Add splitStyleDeclarations with quote/paren-aware CSS parsing - Fix backslash escape handling inside quoted CSS string values - Close html-attribute safety gap in SDK cutover (event handlers, dangerous URIs) - Consolidate HTML attribute safety constants to core/utils/htmlAttrSafety.ts - Extract NON_HTML_CHILD_TAGS set for foreign-content decline gate - Add sdkCutoverParity test corpus (shorthand/longhand, mixed batches) --- packages/core/package.json | 4 + .../src/studio-api/helpers/sourceMutation.ts | 91 +------------- packages/core/src/utils/htmlAttrSafety.ts | 96 +++++++++++++++ packages/sdk/src/document.ts | 16 +-- packages/sdk/src/engine/model.ts | 71 ++++++++++- packages/sdk/src/engine/mutate.test.ts | 46 +++++++ packages/sdk/src/engine/mutate.ts | 17 +-- packages/sdk/src/types.ts | 2 +- packages/studio/src/utils/htmlAttrSafety.ts | 90 ++++++++++++++ packages/studio/src/utils/sdkCutover.test.ts | 51 ++++++++ packages/studio/src/utils/sdkCutover.ts | 56 ++++++++- .../studio/src/utils/sdkCutoverParity.test.ts | 113 ++++++++++++++++++ 12 files changed, 534 insertions(+), 119 deletions(-) create mode 100644 packages/core/src/utils/htmlAttrSafety.ts create mode 100644 packages/studio/src/utils/htmlAttrSafety.ts create mode 100644 packages/studio/src/utils/sdkCutoverParity.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index aec432b5ac..552270a54a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,10 @@ "import": "./src/beats/index.ts", "types": "./src/beats/index.ts" }, + "./html-attr-safety": { + "import": "./src/utils/htmlAttrSafety.ts", + "types": "./src/utils/htmlAttrSafety.ts" + }, "./slideshow": { "import": "./src/slideshow/index.ts", "types": "./src/slideshow/index.ts" diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index bfec5eb4cf..ddbf1c1036 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -1,6 +1,7 @@ import { parseHTML } from "linkedom"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; +import { isAllowedHtmlAttribute, isSafeAttributeValue } from "../../utils/htmlAttrSafety"; export interface SourceMutationTarget { id?: string | null; @@ -133,96 +134,6 @@ export interface PatchOperation { value: string | null; } -const ALLOWED_HTML_ATTRS = new Set([ - // Identity & structure - "id", - "class", - "style", - "title", - "name", - "for", - "type", - // Internationalization - "lang", - "dir", - "translate", - // Interaction - "hidden", - "tabindex", - "draggable", - "contenteditable", - // Accessibility - "role", - "slot", - // Links & navigation - "href", - "target", - "rel", - // Media - "src", - "srcset", - "sizes", - "alt", - "poster", - "loading", - "decoding", - "crossorigin", - "preload", - "autoplay", - "loop", - "muted", - "controls", - "playsinline", - // Layout - "width", - "height", - "colspan", - "rowspan", - "scope", - // Form - "placeholder", - "value", - "min", - "max", - "step", - "pattern", - "required", - "disabled", - "readonly", - "checked", - "selected", - "multiple", - "accept", - "maxlength", - "minlength", - "rows", - "cols", - "wrap", -]); - -const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i; -const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i; - -function isAllowedHtmlAttribute(name: string): boolean { - const lower = name.toLowerCase(); - if (lower.startsWith("on")) return false; - if (ALLOWED_HTML_ATTRS.has(lower)) return true; - if (lower.startsWith("data-")) return true; - if (lower.startsWith("aria-")) return true; - return false; -} - -const URI_ATTRS = new Set(["src", "href", "action", "formaction", "poster", "srcset"]); - -function isSafeAttributeValue(name: string, value: string): boolean { - if (URI_ATTRS.has(name.toLowerCase())) { - const trimmed = value.trim(); - if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false; - if (DANGEROUS_DATA_URI.test(trimmed)) return false; - } - return true; -} - // fallow-ignore-next-line complexity function patchStyleAttrString(style: string, property: string, value: string | null): string { const props = new Map(); diff --git a/packages/core/src/utils/htmlAttrSafety.ts b/packages/core/src/utils/htmlAttrSafety.ts new file mode 100644 index 0000000000..59bc764e7e --- /dev/null +++ b/packages/core/src/utils/htmlAttrSafety.ts @@ -0,0 +1,96 @@ +/** + * Shared HTML attribute safety constants. + * + * Single source of truth for attribute allowlists and dangerous-URI patterns + * used by sourceMutation (core), sdkCutover (studio), and mutate (sdk). + */ + +export const ALLOWED_HTML_ATTRS = new Set([ + "id", + "class", + "style", + "title", + "name", + "for", + "type", + "lang", + "dir", + "translate", + "hidden", + "tabindex", + "draggable", + "contenteditable", + "role", + "slot", + "href", + "target", + "rel", + "src", + "srcset", + "sizes", + "alt", + "poster", + "loading", + "decoding", + "crossorigin", + "preload", + "autoplay", + "loop", + "muted", + "controls", + "playsinline", + "width", + "height", + "colspan", + "rowspan", + "scope", + "placeholder", + "value", + "min", + "max", + "step", + "pattern", + "required", + "disabled", + "readonly", + "checked", + "selected", + "multiple", + "accept", + "maxlength", + "minlength", + "rows", + "cols", + "wrap", +]); + +export const URI_BEARING_ATTRS = new Set([ + "src", + "href", + "action", + "formaction", + "poster", + "srcset", + "xlink:href", +]); + +export const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i; +export const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i; + +export function isAllowedHtmlAttribute(name: string): boolean { + const lower = name.toLowerCase(); + if (lower.startsWith("on")) return false; + if (ALLOWED_HTML_ATTRS.has(lower)) return true; + if (lower.startsWith("data-")) return true; + if (lower.startsWith("aria-")) return true; + return false; +} + +export function isSafeAttributeValue(name: string, value: string): boolean { + if (URI_BEARING_ATTRS.has(name.toLowerCase())) { + const trimmed = value.trim(); + if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false; + if (DANGEROUS_DATA_URI.test(trimmed)) return false; + } + return true; +} diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts index a9fa6d7140..c84adc141a 100644 --- a/packages/sdk/src/document.ts +++ b/packages/sdk/src/document.ts @@ -11,7 +11,7 @@ import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/core/hf-ids"; import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; -import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js"; +import { findRoot, getElementStyles, getOwnText, isNewHostBoundary } from "./engine/model.js"; import type { HyperFramesElement, SdkDocument } from "./types.js"; // Tags that carry no editable content and must not enter the element tree. @@ -27,14 +27,10 @@ const EXCLUDED_TAGS = new Set([ ]); // Snapshot text is TRIMMED for display (markup indentation produces noisy -// whitespace text nodes). setText writes verbatim — engine getOwnText/setOwnText -// operate on raw text. el.text is a display value, not a round-trip identity. -function ownText(el: Element): string | null { - let text = ""; - el.childNodes.forEach((n) => { - if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; - }); - const trimmed = text.trim(); +// whitespace text nodes). The raw text target is shared with setText so shadow +// value checks and dispatch serialization use the same DOM target. +function snapshotText(el: Element): string | null { + const trimmed = getOwnText(el).trim(); return trimmed.length > 0 ? trimmed : null; } @@ -147,7 +143,7 @@ function buildElement( inlineStyles, classNames, attributes, - text: ownText(el), + text: snapshotText(el), start, duration, trackIndex, diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts index 6c61b9e41f..112a7ed1e1 100644 --- a/packages/sdk/src/engine/model.ts +++ b/packages/sdk/src/engine/model.ts @@ -138,14 +138,58 @@ function toKebab(prop: string): string { } /** Parse style attribute string → camelCase map (custom props kept as-is). */ +interface StyleDeclarationScan { + depth: number; + quote: "'" | '"' | null; + skip: boolean; +} + +function advanceStyleDeclarationScan(scan: StyleDeclarationScan, ch: string, next: string): void { + if (scan.quote) { + if (ch === "\\" && next) { + scan.skip = true; + return; + } + if (ch === scan.quote) scan.quote = null; + return; + } + if (ch === "'" || ch === '"') { + scan.quote = ch; + return; + } + if (ch === "(") scan.depth++; + else if (ch === ")") scan.depth = Math.max(0, scan.depth - 1); +} + +function splitStyleDeclarations(style: string): string[] { + const declarations: string[] = []; + const scan: StyleDeclarationScan = { depth: 0, quote: null, skip: false }; + let start = 0; + for (let i = 0; i < style.length; i++) { + if (scan.skip) { + scan.skip = false; + continue; + } + const ch = style[i] ?? ""; + if (ch === ";" && scan.depth === 0 && scan.quote === null) { + declarations.push(style.slice(start, i)); + start = i + 1; + } else { + advanceStyleDeclarationScan(scan, ch, style[i + 1] ?? ""); + } + } + declarations.push(style.slice(start)); + return declarations; +} + function parseStyleAttr(styleAttr: string): Record { const result: Record = {}; - for (const decl of styleAttr.split(";")) { + for (const decl of splitStyleDeclarations(styleAttr)) { const idx = decl.indexOf(":"); if (idx === -1) continue; const rawProp = decl.slice(0, idx).trim(); const value = decl.slice(idx + 1).trim(); - if (!rawProp || !value) continue; + if (!rawProp) continue; result[toCamel(rawProp)] = value; } return result; @@ -185,8 +229,21 @@ export function setElementStyles(el: Element, updates: Record { if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; @@ -194,8 +251,14 @@ export function getOwnText(el: Element): string { return text; } -/** Replace only direct text nodes — preserves child elements. */ +/** Replace the SDK text target without destroying multi-child element structure. */ export function setOwnText(el: Element, text: string): void { + const singleChild = resolveSingleChildTextTarget(el); + if (singleChild) { + singleChild.textContent = text; + return; + } + const doc = el.ownerDocument; const children = Array.from(el.childNodes); // Track original position of the first text node so we restore there, not at firstChild. diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 51aab6c223..82fa26aa4f 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -221,6 +221,37 @@ describe("setText", () => { expect(result.forward[0]?.value).toBe("Added"); }); + it("matches legacy single-child text targeting", () => { + const parsed = parseMutable( + '', + ); + const result = applyOp(parsed, { type: "setText", target: "hf-target", value: "New" }); + expect(serializeDocument(parsed)).toContain( + '', + ); + expect(result.inverse[0]?.value).toBe("Old"); + }); + + it("preserves parent text when the legacy target is a single child", () => { + const parsed = parseMutable( + '
Lead Old
', + ); + applyOp(parsed, { type: "setText", target: "hf-target", value: "New" }); + expect(serializeDocument(parsed)).toContain( + '
Lead New
', + ); + }); + + it("keeps non-HTML single children out of the child text shortcut", () => { + const parsed = parseMutable( + '
Old
', + ); + applyOp(parsed, { type: "setText", target: "hf-target", value: "New" }); + const html = serializeDocument(parsed); + expect(html).toContain('New"); + }); + it("override-set key maps correctly", () => { expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text"); }); @@ -695,6 +726,21 @@ describe("setElementStyles key normalization", () => { setElementStyles(el, { "transform-origin": "top left" }); expect(getElementStyles(el).transformOrigin).toBe("top left"); }); + + it("preserves semicolon-bearing CSS values when updating another property", () => { + const el = elWith("background: url(data:image/svg+xml;utf8,); color: red"); + setElementStyles(el, { color: "blue" }); + expect(el.getAttribute("style")).toContain("background: url(data:image/svg+xml;utf8"); + expect(el.getAttribute("style")).toContain("color: blue"); + }); + + it("handles escaped quotes inside CSS string values", () => { + const el = elWith("color: red"); + el.setAttribute("style", 'content: "a\\";b"; color: red'); + setElementStyles(el, { color: "blue" }); + expect(getElementStyles(el).content).toBe('"a\\";b"'); + expect(getElementStyles(el).color).toBe("blue"); + }); }); // ─── setVariableValue ───────────────────────────────────────────────────────── diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index f8fb751381..6580506dd1 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -76,6 +76,11 @@ import { } from "@hyperframes/core/gsap-writer-acorn"; import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js"; import { readVariableDefault, writeVariableDefault } from "./variableModel.js"; +import { + URI_BEARING_ATTRS, + DANGEROUS_URI_SCHEMES, + DANGEROUS_DATA_URI, +} from "@hyperframes/core/html-attr-safety"; export interface MutationResult { forward: JsonPatchOp[]; @@ -102,18 +107,6 @@ const RESERVED_ATTRS = new Set([ "data-hold-fill", ]); -const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i; -const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i; -const URI_BEARING_ATTRS = new Set([ - "src", - "href", - "action", - "formaction", - "poster", - "srcset", - "xlink:href", -]); - function validateSetAttribute(name: string, value: string | null): void { const lower = name.toLowerCase(); if (RESERVED_ATTRS.has(lower)) { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 3730218b98..81115b2641 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -18,7 +18,7 @@ export interface HyperFramesElement { readonly classNames: readonly string[]; /** All attributes except style, class, and data-hf-* (those are model-level) */ readonly attributes: Readonly>; - /** Direct text node content (not descendant text) */ + /** Display text for the SDK setText target, not a full descendant-text snapshot. */ readonly text: string | null; // Timing — null when element has no data-start readonly start: number | null; diff --git a/packages/studio/src/utils/htmlAttrSafety.ts b/packages/studio/src/utils/htmlAttrSafety.ts new file mode 100644 index 0000000000..be35f4deea --- /dev/null +++ b/packages/studio/src/utils/htmlAttrSafety.ts @@ -0,0 +1,90 @@ +// keep-in-sync-with: packages/core/src/utils/htmlAttrSafety.ts +const ALLOWED_HTML_ATTRS = new Set([ + "id", + "class", + "style", + "title", + "name", + "for", + "type", + "lang", + "dir", + "translate", + "hidden", + "tabindex", + "draggable", + "contenteditable", + "role", + "slot", + "href", + "target", + "rel", + "src", + "srcset", + "sizes", + "alt", + "poster", + "loading", + "decoding", + "crossorigin", + "preload", + "autoplay", + "loop", + "muted", + "controls", + "playsinline", + "width", + "height", + "colspan", + "rowspan", + "scope", + "placeholder", + "value", + "min", + "max", + "step", + "pattern", + "required", + "disabled", + "readonly", + "checked", + "selected", + "multiple", + "accept", + "maxlength", + "minlength", + "rows", + "cols", + "wrap", +]); + +const URI_BEARING_ATTRS = new Set([ + "src", + "href", + "action", + "formaction", + "poster", + "srcset", + "xlink:href", +]); + +const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i; +const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i; + +export function isAllowedHtmlAttribute(name: string): boolean { + const lower = name.toLowerCase(); + if (lower.startsWith("on")) return false; + if (ALLOWED_HTML_ATTRS.has(lower)) return true; + if (lower.startsWith("data-")) return true; + if (lower.startsWith("aria-")) return true; + return false; +} + +export function isSafeAttributeValue(name: string, value: string): boolean { + if (URI_BEARING_ATTRS.has(name.toLowerCase())) { + const trimmed = value.trim(); + if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false; + if (DANGEROUS_DATA_URI.test(trimmed)) return false; + } + return true; +} diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 67d9ee4f5a..5b0bf6e7a7 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -98,6 +98,36 @@ describe("shouldUseSdkCutover", () => { expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false); }); + it("declines html-attribute ops with event handler names", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe( + false, + ); + expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onload", "fetch()")])).toBe( + false, + ); + }); + + it("declines html-attribute ops with disallowed attribute names", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("formaction", "/x")])).toBe(false); + }); + + it("declines html-attribute ops with dangerous URI schemes", () => { + expect( + shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("href", "javascript:alert(1)")]), + ).toBe(false); + expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("src", "vbscript:run")])).toBe( + false, + ); + }); + + it("declines html-attribute ops with dangerous data URIs", () => { + expect( + shouldUseSdkCutover(true, true, "hf-abc", [ + htmlAttrOp("href", "data:text/html,"), + ]), + ).toBe(false); + }); + it("returns true when ops mix all supported types", () => { expect( shouldUseSdkCutover(true, true, "hf-abc", [ @@ -205,6 +235,27 @@ describe("sdkCutoverPersist", () => { }); }); + it.each([ + { name: "multi-child targets", children: [{ id: "a" }, { id: "b" }] }, + { name: "single non-html children", children: [{ id: "a", tag: "svg" }] }, + ])("declines text-content cutover for $name", async ({ children }) => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.getElement as ReturnType).mockReturnValue({ children }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [textOp("Hello world")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(session!.dispatch).not.toHaveBeenCalled(); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); + it("dispatches setAttribute for attribute op with data- prefix", async () => { const deps = makeDeps(); const session = makeSession(true); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index b870c739a1..90c8af53b8 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -8,6 +8,7 @@ import { trackStudioEvent } from "./studioTelemetry"; import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; import { patchOpsToSdkEditOps } from "./sdkOpMapping"; import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow"; +import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety"; const CUTOVER_OP_TYPES = new Set([ "inline-style", @@ -52,6 +53,54 @@ function mapsToReservedAttr(op: PatchOperation): boolean { return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase()); } +// ─── html-attribute safety ─────────────────────────────────────────────────── + +function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean { + return ops.some( + (op) => + op.type === "html-attribute" && + (!isAllowedHtmlAttribute(op.property) || + (op.value !== null && !isSafeAttributeValue(op.property, op.value))), + ); +} + +function hasTextContentOp(ops: PatchOperation[]): boolean { + return ops.some((op) => op.type === "text-content"); +} + +function targetChildren(target: unknown): unknown[] | null { + if (!target || typeof target !== "object" || !("children" in target)) return null; + const children = (target as { children?: unknown }).children; + return Array.isArray(children) ? children : null; +} + +function elementTag(element: unknown): string | null { + if (!element || typeof element !== "object" || !("tag" in element)) return null; + const tag = (element as { tag?: unknown }).tag; + return typeof tag === "string" ? tag.toLowerCase() : null; +} + +// Tags that are non-HTML namespace elements in a linkedom-parsed HTML body. +// Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof +// HTMLElement` — that runtime check catches the same set, but we can't use it +// here because `target` is a plain SDK object, not a DOM Element. If linkedom +// (or a future parser) surfaces additional foreign-content elements as +// non-HTMLElement, add them here. +const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]); + +function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean { + if (!hasTextContentOp(ops)) return false; + const children = targetChildren(target); + if (!children) return false; + // Legacy patch-element replaces the whole element for multi-child targets and + // for single non-HTML children. The SDK text patch stream stores a scalar + // inverse, so those shapes cannot be made both byte-identical and undo-safe + // here. Let the server path remain authoritative for them. + if (children.length > 1) return true; + const tag = elementTag(children[0]); + return tag !== null && NON_HTML_CHILD_TAGS.has(tag); +} + export function shouldUseSdkCutover( flagEnabled: boolean, hasSession: boolean, @@ -64,7 +113,8 @@ export function shouldUseSdkCutover( !!hfId && ops.length > 0 && ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) && - !ops.some(mapsToReservedAttr) + !ops.some(mapsToReservedAttr) && + !hasUnsafeHtmlAttributeOp(ops) ); } @@ -186,7 +236,9 @@ export async function sdkCutoverPersist( if (!sdkSession) return false; const hfId = selection.hfId; if (!hfId) return false; - if (!sdkSession.getElement(hfId)) return false; + const target = sdkSession.getElement(hfId); + if (!target) return false; + if (shouldDeclineTextCutoverForTarget(target, ops)) return false; if (wrongCompositionFile(deps, targetPath)) return false; try { const before = sdkSession.serialize(); diff --git a/packages/studio/src/utils/sdkCutoverParity.test.ts b/packages/studio/src/utils/sdkCutoverParity.test.ts new file mode 100644 index 0000000000..0245b3eae4 --- /dev/null +++ b/packages/studio/src/utils/sdkCutoverParity.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { openComposition } from "@hyperframes/sdk"; +import { patchElementInHtml } from "../../../core/src/studio-api/helpers/sourceMutation.js"; +import type { PatchOperation } from "./sourcePatcher"; +import { patchOpsToSdkEditOps } from "./sdkOpMapping"; + +const shell = (body: string) => ` +${body}`; + +async function applySdkDomCutover(source: string, ops: PatchOperation[]): Promise { + const session = await openComposition(source, { history: false }); + session.batch(() => { + for (const op of patchOpsToSdkEditOps("hf-target", ops)) { + session.dispatch(op); + } + }); + return session.serialize(); +} + +const cases: Array<{ name: string; source: string; ops: PatchOperation[] }> = [ + { + name: "coalesces multi-property inline style including transform and custom props", + source: shell( + '
OldChild
', + ), + ops: [ + { type: "inline-style", property: "transform", value: "translateX(10px)" }, + { type: "inline-style", property: "transform-origin", value: "50% 50%" }, + { type: "inline-style", property: "--x", value: "12px" }, + ], + }, + { + name: "removes one inline style from a multi-property declaration", + source: shell( + '
Old
', + ), + ops: [{ type: "inline-style", property: "opacity", value: null }], + }, + { + name: "preserves semicolon-bearing CSS values when updating another style", + source: shell( + '
Old
', + ), + ops: [{ type: "inline-style", property: "color", value: "blue" }], + }, + { + name: "sets direct text content", + source: shell('
Old
'), + ops: [{ type: "text-content", property: "text", value: "New" }], + }, + { + name: "sets text on the single child target used by the legacy path", + source: shell(''), + ops: [{ type: "text-content", property: "text", value: "New" }], + }, + { + name: "sets text on the single child target while preserving parent text", + source: shell('
Lead Old
'), + ops: [{ type: "text-content", property: "text", value: "New" }], + }, + { + name: "sets data attributes through attribute ops", + source: shell('
Old
'), + ops: [{ type: "attribute", property: "mode", value: "hero" }], + }, + { + name: "removes data attributes through attribute ops", + source: shell('
Old
'), + ops: [{ type: "attribute", property: "mode", value: null }], + }, + { + name: "sets allowed html attributes", + source: shell('Old'), + ops: [{ type: "html-attribute", property: "aria-label", value: "Primary link" }], + }, + { + name: "sets shorthand over existing longhands (inset over top/right/bottom/left)", + source: shell( + '
Old
', + ), + ops: [{ type: "inline-style", property: "inset", value: "0" }], + }, + { + name: "sets longhand over existing shorthand (top over inset)", + source: shell('
Old
'), + ops: [{ type: "inline-style", property: "top", value: "10px" }], + }, + { + name: "mixed-type batch: inline-style + text-content in one op list", + source: shell('
Old
'), + ops: [ + { type: "inline-style", property: "color", value: "blue" }, + { type: "text-content", property: "text", value: "New" }, + ], + }, + { + name: "mixed-type batch: inline-style + attribute in one op list", + source: shell('
Old
'), + ops: [ + { type: "inline-style", property: "opacity", value: "1" }, + { type: "attribute", property: "mode", value: "hero" }, + ], + }, +]; + +describe("SDK cutover DOM serialization parity", () => { + it.each(cases)("$name", async ({ source, ops }) => { + const legacy = patchElementInHtml(source, { hfId: "hf-target" }, ops); + expect(legacy.matched).toBe(true); + + await expect(applySdkDomCutover(source, ops)).resolves.toBe(legacy.html); + }); +});