@hyperframes/studio 0.6.106 → 0.6.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import type { Composition } from "@hyperframes/sdk";
12
12
  import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
13
13
  import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
14
14
  import { trackStudioEvent } from "./studioTelemetry";
15
+ import { relEqual } from "./sdkShadowNumeric";
15
16
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
16
17
  import type { PatchOperation } from "./sourcePatcher";
17
18
 
@@ -39,6 +40,16 @@ function isShadowableOp(op: PatchOperation): boolean {
39
40
  return true;
40
41
  }
41
42
 
43
+ // PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
44
+ // runShadowDispatch to flag any unmapped type as visible telemetry rather than
45
+ // silently dropping it (see the unmapped_type guard there).
46
+ const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
47
+ "inline-style",
48
+ "text-content",
49
+ "attribute",
50
+ "html-attribute",
51
+ ]);
52
+
42
53
  export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
43
54
  const result: EditOp[] = [];
44
55
  const styles: Record<string, string | null> = {};
@@ -121,13 +132,29 @@ function kebabToCamel(prop: string): string {
121
132
  return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
122
133
  }
123
134
 
135
+ // Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
136
+ // An empty string and absent text (null) are treated as equivalent (collapsed
137
+ // to null) so "" vs null does not flag — both mean "no text content".
138
+ function normalizeText(value: string | null | undefined): string | null {
139
+ if (value == null) return null;
140
+ const trimmed = value.trim();
141
+ return trimmed === "" ? null : trimmed;
142
+ }
143
+
124
144
  const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
125
145
  "inline-style": (op, flat) => ({
126
146
  property: op.property,
127
147
  expected: op.value,
128
148
  actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
129
149
  }),
130
- "text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }),
150
+ // snapshot.text is already TRIMMED; trim the expected op value to match, so
151
+ // trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
152
+ // collapsed in checkOpParity. A genuinely different text value still flags.
153
+ "text-content": (op, flat) => ({
154
+ property: "text",
155
+ expected: normalizeText(op.value),
156
+ actual: normalizeText(flat.text),
157
+ }),
131
158
  attribute: (op, flat) => ({
132
159
  property: attrName(op.property),
133
160
  expected: op.value ?? null,
@@ -244,6 +271,23 @@ export function runShadowDispatch(
244
271
  });
245
272
  return;
246
273
  }
274
+ // Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
275
+ // doesn't map. PatchOperation.type is a closed union today, but emit a visible
276
+ // unmapped_type event if a future type ever slips through, so the gap surfaces
277
+ // in telemetry instead of vanishing.
278
+ // Map to the type string before find, so a future unmapped type is read as a
279
+ // plain string (no object cast; find on the closed union narrows to never).
280
+ const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
281
+ if (unmappedType !== undefined) {
282
+ trackStudioEvent("sdk_shadow_dispatch", {
283
+ op: "property",
284
+ dispatched: false,
285
+ reason: "unmapped_type",
286
+ type: unmappedType,
287
+ mismatchCount: 0,
288
+ });
289
+ return;
290
+ }
247
291
  const result = sdkShadowDispatch(session, hfId, ops);
248
292
  trackStudioEvent("sdk_shadow_dispatch", {
249
293
  op: "property",
@@ -345,6 +389,20 @@ export interface ShadowTiming {
345
389
  trackIndex?: number;
346
390
  }
347
391
 
392
+ // start/duration tolerate float-precision drift (SDK computes them
393
+ // arithmetically, server stores a rounded literal) via the shared relative
394
+ // epsilon; trackIndex (integer track slot) is compared exactly.
395
+ function timingFieldEqual(
396
+ key: keyof ShadowTiming,
397
+ actual: number | null | undefined,
398
+ expected: number,
399
+ ): boolean {
400
+ if (typeof actual === "number" && key !== "trackIndex") {
401
+ return relEqual(actual, expected);
402
+ }
403
+ return actual === expected;
404
+ }
405
+
348
406
  /** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
349
407
  export function runShadowTiming(
350
408
  session: Composition,
@@ -373,15 +431,14 @@ export function runShadowTiming(
373
431
  ];
374
432
  for (const [key, actual] of fields) {
375
433
  const expected = timing[key];
376
- if (expected !== undefined && actual !== expected) {
377
- mismatches.push({
378
- kind: "value_mismatch",
379
- hfId,
380
- property: key,
381
- expected: String(expected),
382
- actual: actual == null ? null : String(actual),
383
- });
384
- }
434
+ if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
435
+ mismatches.push({
436
+ kind: "value_mismatch",
437
+ hfId,
438
+ property: key,
439
+ expected: String(expected),
440
+ actual: actual == null ? null : String(actual),
441
+ });
385
442
  }
386
443
  return mismatches;
387
444
  });
@@ -16,6 +16,7 @@ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
16
16
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
17
17
  import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
18
18
  import { trackStudioEvent } from "./studioTelemetry";
19
+ import { relEqual } from "./sdkShadowNumeric";
19
20
  import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
20
21
 
21
22
  // Marker set must match document.ts extractGsapScript so both pick the same
@@ -24,7 +25,7 @@ function isGsapScriptBody(body: string): boolean {
24
25
  return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
25
26
  }
26
27
 
27
- function extractGsapScript(html: string): string | null {
28
+ export function extractGsapScript(html: string): string | null {
28
29
  // Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
29
30
  // before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
30
31
  const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
@@ -73,17 +74,17 @@ function animByKey(
73
74
  // number-vs-string forms. Compare canonically — sort keys, coerce numeric
74
75
  // strings — so only real value drift registers, not formatting differences.
75
76
 
77
+ // Coerce string operands to numbers, then compare with the shared relative
78
+ // epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
79
+ // isn't flagged as drift while a real 2 vs 1 still is.
76
80
  function numericEqual(a: unknown, b: unknown): boolean {
77
81
  if (a === b) return true;
78
82
  const na = typeof a === "string" ? Number(a) : a;
79
83
  const nb = typeof b === "string" ? Number(b) : b;
80
- return (
81
- typeof na === "number" &&
82
- typeof nb === "number" &&
83
- !Number.isNaN(na) &&
84
- !Number.isNaN(nb) &&
85
- na === nb
86
- );
84
+ if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
85
+ return false;
86
+ }
87
+ return relEqual(na, nb);
87
88
  }
88
89
 
89
90
  function canonicalProps(obj: Record<string, unknown> | undefined): string {
@@ -187,27 +188,54 @@ export function resolveGsapFidelityArgs(
187
188
  return { before, op: shadowGsapOp, serverScript };
188
189
  }
189
190
 
190
- // Resolve a CSS selector to a canonical element id (data-hf-id) using the pre-op
191
- // document, so tweens that target the same element via different selectors
192
- // ([data-hf-id="X"] vs .X) match in the fidelity diff. Falls back to the raw
193
- // selector when it can't resolve (DOMParser unavailable, no match, bad selector).
191
+ // Resolve a CSS selector to a canonical element key using the pre-op document,
192
+ // so tweens that target the same element via different selectors
193
+ // ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff.
194
+ //
195
+ // The SDK writer emits [data-hf-id="X"] while the server may emit a class/id
196
+ // selector for the SAME element. Keying both forms to the same node prevents a
197
+ // false present/absent mismatch. Resolution order, for whatever element the
198
+ // selector matches:
199
+ // 1. data-hf-id present → "hfid:<id>" (the common, stable case)
200
+ // 2. no data-hf-id → "node:<n>" (per-document node index; identical
201
+ // regardless of which selector form found the node, so .x and [data-hf-id]
202
+ // pointing at the same attribute-less node still collapse)
203
+ // 3. selector resolves to no node / parse error / no DOM → the raw selector
204
+ // (last resort; only diverges when the two writers genuinely target
205
+ // different — or unresolvable — nodes, which is real drift to surface)
206
+ // The "hfid:"/"node:" prefixes are namespaced so a canonical key can never
207
+ // collide with a raw-selector fallback.
194
208
  //
195
209
  // ponytail: first-match heuristic — querySelector returns the FIRST match, so an
196
- // ambiguous selector (e.g. .x shared by two elements) may map to a different id
197
- // than the SDK side's [data-hf-id] target and still flag present/absent. Safe
198
- // for studio templates (one tween per data-hf-id); upgrade to querySelectorAll +
199
- // uniqueness check if ambiguous selectors appear.
200
- function makeSelectorResolver(html: string): (sel: string) => string {
210
+ // ambiguous selector (e.g. .x shared by two elements) may map to a different
211
+ // node than the SDK side's [data-hf-id] target and still flag present/absent.
212
+ // Safe for studio templates (one tween per element); upgrade to querySelectorAll
213
+ // + uniqueness check if ambiguous selectors appear.
214
+ export function makeSelectorResolver(html: string): (sel: string) => string {
201
215
  let doc: Document | null = null;
202
216
  try {
203
217
  doc = new DOMParser().parseFromString(html, "text/html");
204
218
  } catch {
205
219
  doc = null;
206
220
  }
221
+ // Stable per-node index so an attribute-less element keys identically no
222
+ // matter which selector form (class vs id vs [data-hf-id]) resolved it.
223
+ const nodeKeys = new WeakMap<Element, string>();
224
+ let nextNode = 0;
225
+ const keyForNode = (el: Element): string => {
226
+ const hfId = el.getAttribute("data-hf-id");
227
+ if (hfId != null && hfId !== "") return `hfid:${hfId}`;
228
+ const existing = nodeKeys.get(el);
229
+ if (existing != null) return existing;
230
+ const key = `node:${nextNode++}`;
231
+ nodeKeys.set(el, key);
232
+ return key;
233
+ };
207
234
  return (sel) => {
208
235
  if (!doc) return sel;
209
236
  try {
210
- return doc.querySelector(sel)?.getAttribute("data-hf-id") ?? sel;
237
+ const el = doc.querySelector(sel);
238
+ return el ? keyForNode(el) : sel;
211
239
  } catch {
212
240
  return sel;
213
241
  }
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { openComposition } from "@hyperframes/sdk";
3
+ import {
4
+ resolveKeyframeIndexByPercentage,
5
+ keyframeOpToEditOp,
6
+ gsapKeyframeFidelityMismatches,
7
+ runShadowGsapKeyframeFidelity,
8
+ type ShadowKeyframeOp,
9
+ } from "./sdkShadowGsapKeyframe";
10
+ import { runShadowDispatch } from "./sdkShadow";
11
+ import type { PatchOperation } from "./sourcePatcher";
12
+
13
+ // Capture sdk_shadow_dispatch telemetry.
14
+ const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
15
+ vi.mock("./studioTelemetry", () => ({
16
+ trackStudioEvent: (event: string, props: Record<string, unknown>) =>
17
+ trackedEvents.push({ event, props }),
18
+ }));
19
+ // STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the
20
+ // runners are active here without mocking the availability module.
21
+
22
+ beforeEach(() => {
23
+ trackedEvents.length = 0;
24
+ });
25
+ const lastShadow = () =>
26
+ trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
27
+
28
+ const ANIM_ID = "#hero-to-0-position";
29
+
30
+ function gsapHtml(scriptBody: string): string {
31
+ return /* html */ `<!DOCTYPE html><html><body>
32
+ <div data-hf-id="hf-hero" id="hero" class="clip">x</div>
33
+ <script>
34
+ ${scriptBody}
35
+ window.__timelines = [tl];
36
+ </script>
37
+ </body></html>`;
38
+ }
39
+
40
+ const KF_SCRIPT = `
41
+ const tl = gsap.timeline({ paused: true });
42
+ tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
43
+
44
+ // A script body string (not full HTML) for the index-resolution helpers.
45
+ const KF_SCRIPT_BODY = KF_SCRIPT;
46
+ const DUP_SCRIPT_BODY = `
47
+ const tl = gsap.timeline({ paused: true });
48
+ tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
49
+
50
+ describe("resolveKeyframeIndexByPercentage", () => {
51
+ it("resolves a unique percentage to its 0-based index", () => {
52
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
53
+ keyframeIndex: 1,
54
+ });
55
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({
56
+ keyframeIndex: 2,
57
+ });
58
+ });
59
+
60
+ it("matches within ~0.001 tolerance", () => {
61
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe(
62
+ 1,
63
+ );
64
+ });
65
+
66
+ it("returns null with not_found when no percentage matches", () => {
67
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({
68
+ keyframeIndex: null,
69
+ reason: "not_found",
70
+ });
71
+ });
72
+
73
+ it("returns null with no_keyframes for an unknown animation", () => {
74
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({
75
+ keyframeIndex: null,
76
+ reason: "no_keyframes",
77
+ });
78
+ });
79
+
80
+ it("returns null with no_keyframes when script is empty", () => {
81
+ expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes");
82
+ });
83
+
84
+ it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => {
85
+ expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
86
+ keyframeIndex: null,
87
+ reason: "ambiguous",
88
+ });
89
+ });
90
+
91
+ // Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a
92
+ // "-from-"/"-fromTo-" animationId must fall back to the converted id (matching
93
+ // the writer's locateAnimationWithFallback) — else the keyframe diff goes blind.
94
+ it("falls back from a -from- id to the -to- tween", () => {
95
+ const fromId = ANIM_ID.replace("-to-", "-from-");
96
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({
97
+ keyframeIndex: 1,
98
+ });
99
+ });
100
+ });
101
+
102
+ describe("keyframeOpToEditOp", () => {
103
+ it("maps add → addGsapKeyframe with position = percentage", () => {
104
+ const op: ShadowKeyframeOp = {
105
+ kind: "add",
106
+ animationId: ANIM_ID,
107
+ percentage: 25,
108
+ properties: { x: 50 },
109
+ };
110
+ expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
111
+ op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } },
112
+ });
113
+ });
114
+
115
+ it("maps remove → removeGsapKeyframe with resolved index", () => {
116
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
117
+ expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
118
+ op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 },
119
+ });
120
+ });
121
+
122
+ it("returns null op + reason when remove percentage is ambiguous", () => {
123
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
124
+ expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" });
125
+ });
126
+ });
127
+
128
+ describe("gsapKeyframeFidelityMismatches", () => {
129
+ it("reports no mismatches when keyframe arrays match", () => {
130
+ expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]);
131
+ });
132
+
133
+ it("reports a keyframes mismatch when arrays diverge", () => {
134
+ const other = `
135
+ const tl = gsap.timeline({ paused: true });
136
+ tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
137
+ const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID);
138
+ expect(mismatches.some((m) => m.property === "keyframes")).toBe(true);
139
+ });
140
+ });
141
+
142
+ describe("runShadowGsapKeyframeFidelity (add)", () => {
143
+ it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => {
144
+ const beforeHtml = gsapHtml(KF_SCRIPT);
145
+ // server script unchanged (server "failed" to add the 25% keyframe) → drift
146
+ const session = await openComposition(beforeHtml);
147
+ const serverScript = session
148
+ .serialize()
149
+ .match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
150
+ expect(serverScript).toBeTruthy();
151
+ const op: ShadowKeyframeOp = {
152
+ kind: "add",
153
+ animationId: ANIM_ID,
154
+ percentage: 25,
155
+ properties: { x: 50 },
156
+ };
157
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
158
+ const props = lastShadow();
159
+ expect(props?.op).toBe("gsap_keyframe");
160
+ expect(props?.dispatched).toBe(true);
161
+ expect(props?.mismatchCount).toBe(1);
162
+ });
163
+
164
+ it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => {
165
+ const beforeHtml = gsapHtml(KF_SCRIPT);
166
+ // Build the server's resulting script by applying the same op via the SDK.
167
+ const serverSession = await openComposition(beforeHtml);
168
+ serverSession.batch(() =>
169
+ serverSession.dispatch({
170
+ type: "addGsapKeyframe",
171
+ animationId: ANIM_ID,
172
+ position: 25,
173
+ value: { x: 50 },
174
+ }),
175
+ );
176
+ const serverScript = serverSession
177
+ .serialize()
178
+ .match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
179
+ const op: ShadowKeyframeOp = {
180
+ kind: "add",
181
+ animationId: ANIM_ID,
182
+ percentage: 25,
183
+ properties: { x: 50 },
184
+ };
185
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
186
+ const props = lastShadow();
187
+ expect(props?.op).toBe("gsap_keyframe");
188
+ expect(props?.dispatched).toBe(true);
189
+ expect(props?.mismatchCount).toBe(0);
190
+ });
191
+ });
192
+
193
+ describe("runShadowGsapKeyframeFidelity (remove)", () => {
194
+ it("no-ops with reason when remove percentage is ambiguous", async () => {
195
+ const beforeHtml = gsapHtml(DUP_SCRIPT_BODY);
196
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
197
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap");
198
+ const props = lastShadow();
199
+ expect(props?.op).toBe("gsap_keyframe");
200
+ expect(props?.dispatched).toBe(false);
201
+ expect(props?.reason).toBe("ambiguous");
202
+ });
203
+
204
+ it("dispatches a resolved remove and diffs", async () => {
205
+ const beforeHtml = gsapHtml(KF_SCRIPT);
206
+ const serverSession = await openComposition(beforeHtml);
207
+ serverSession.batch(() =>
208
+ serverSession.dispatch({
209
+ type: "removeGsapKeyframe",
210
+ animationId: ANIM_ID,
211
+ keyframeIndex: 1,
212
+ }),
213
+ );
214
+ const serverScript = serverSession
215
+ .serialize()
216
+ .match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
217
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
218
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
219
+ const props = lastShadow();
220
+ expect(props?.op).toBe("gsap_keyframe");
221
+ expect(props?.dispatched).toBe(true);
222
+ expect(props?.mismatchCount).toBe(0);
223
+ });
224
+ });
225
+
226
+ describe("runShadowGsapKeyframeFidelity (guards)", () => {
227
+ it("skips when there is no server script", async () => {
228
+ const op: ShadowKeyframeOp = {
229
+ kind: "add",
230
+ animationId: ANIM_ID,
231
+ percentage: 25,
232
+ properties: { x: 50 },
233
+ };
234
+ await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null);
235
+ expect(lastShadow()).toBeUndefined();
236
+ });
237
+ });
238
+
239
+ describe("runShadowDispatch unmapped-type guard", () => {
240
+ const ELEMENT_HTML = /* html */ `<!DOCTYPE html><html><body>
241
+ <div data-hf-id="hf-box" style="color: red;">Hi</div>
242
+ </body></html>`;
243
+
244
+ it("emits unmapped_type when a PatchOperation type isn't mapped", async () => {
245
+ const session = await openComposition(ELEMENT_HTML);
246
+ // PatchOperation.type is a closed union today; cast to exercise the defensive
247
+ // guard for a future unmapped type.
248
+ const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation];
249
+ runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
250
+ const props = lastShadow();
251
+ expect(props?.op).toBe("property");
252
+ expect(props?.dispatched).toBe(false);
253
+ expect(props?.reason).toBe("unmapped_type");
254
+ expect(props?.type).toBe("future-op");
255
+ });
256
+
257
+ it("dispatches normally for known PatchOperation types", async () => {
258
+ const session = await openComposition(ELEMENT_HTML);
259
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
260
+ runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
261
+ const props = lastShadow();
262
+ expect(props?.dispatched).toBe(true);
263
+ expect(props?.reason).toBeUndefined();
264
+ });
265
+ });