@hyperframes/studio 0.8.128 → 0.8.130

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.
@@ -0,0 +1,544 @@
1
+ // @vitest-environment happy-dom
2
+ import { gsap } from "gsap";
3
+ import { retimeClipTweensInScript } from "@hyperframes/parsers/gsap-writer-acorn";
4
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
+ import { finishTimelineTimingFallback } from "../hooks/timelineTimingSync";
6
+ import { applyLiveRetime, planLiveRetime, planLiveRetimeFromPreview } from "./gsapLiveRetime";
7
+
8
+ const BEFORE = [
9
+ "var tl = gsap.timeline({ paused: true });",
10
+ 'tl.set("#b", { y: 3 }, 0.2);',
11
+ 'tl.to("#a", { x: 100, duration: 1 }, 0.5);',
12
+ 'tl.from("#b", { opacity: 0, duration: 2 }, 1);',
13
+ 'tl.to("#c", { keyframes: { "0%": { x: 0 }, "50%": { x: 80 }, "100%": { x: 50 } }, duration: 1.5 }, 2);',
14
+ 'window.__timelines["t"] = tl;',
15
+ ].join("\n");
16
+
17
+ const SAMPLES = Array.from({ length: 25 }, (_, i) => i * 0.25);
18
+
19
+ /** Runs a composition script and binds its timeline at `at`, as the preview's runtime does. */
20
+ function play(script: string, at = 0, nested = "") {
21
+ const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
22
+ new Function("gsap", "window", script)(gsap, win);
23
+ const timeline = win.__timelines.t!;
24
+ // The runtime nests each sub-composition's timeline into its host's; the script does not know them...
25
+ const sub = gsap.timeline().to({}, { duration: 6.7 });
26
+ new Function("gsap", "sub", nested)(gsap, sub);
27
+ timeline.add(sub, 0);
28
+ // ...and pads the timeline to the composition's length with a filler tween.
29
+ timeline.to({}, { duration: 0, data: "hf-runtime-filler" }, 8);
30
+ timeline.progress(0.0001, true).totalTime(at, false);
31
+ return { win, timeline };
32
+ }
33
+
34
+ /** Each tween's start and length, and what every element shows across the timeline. */
35
+ function observe(timeline: gsap.core.Timeline) {
36
+ const tweens = timeline
37
+ .getChildren(false, true, false)
38
+ .map((t) => [round(t.startTime()), round(t.duration())]);
39
+ const shown = SAMPLES.map((at) => {
40
+ timeline.seek(at);
41
+ return shownNow();
42
+ });
43
+ return { tweens, length: round(timeline.duration()), shown };
44
+ }
45
+
46
+ const round = (n: number) => Math.round(n * 1000) / 1000;
47
+
48
+ /** What every element shows now. */
49
+ const shownNow = () =>
50
+ ["#a", "#b", "#c"].map((id) =>
51
+ ["x", "y", "opacity"].map((p) => round(Number(gsap.getProperty(id, p)))),
52
+ );
53
+
54
+ /** A preview iframe whose live script is `script`, built and bound. */
55
+ function preview(script: string, nested = "") {
56
+ const tag = document.createElement("script");
57
+ tag.type = "text/plain"; // the runtime already ran it; happy-dom must not run it again
58
+ tag.textContent = script;
59
+ document.body.appendChild(tag);
60
+ const { win, timeline } = play(script, 0, nested);
61
+ const rebind = vi.fn();
62
+ // The runtime hooks a rebind needs; a soft reload would also need `gsap`, which this preview lacks.
63
+ Object.assign(win, { __hfForceTimelineRebind: rebind, __player: { seek: vi.fn() } });
64
+ const iframe = { contentWindow: win, contentDocument: document } as unknown as HTMLIFrameElement;
65
+ return { iframe, timeline, tag, rebind };
66
+ }
67
+
68
+ beforeEach(() => {
69
+ for (const id of ["a", "b", "c"]) {
70
+ const element = document.body.appendChild(document.createElement("div"));
71
+ element.id = id;
72
+ element.className = "item";
73
+ }
74
+ });
75
+
76
+ afterEach(() => {
77
+ document.body.replaceChildren();
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ const moveA = () =>
82
+ retimeClipTweensInScript(BEFORE, [{ kind: "shift", targetSelector: "#a", delta: 0.75 }], document)
83
+ .script;
84
+
85
+ async function dropInto(iframe: HTMLIFrameElement, after: string) {
86
+ const reloadPreview = vi.fn();
87
+ await finishTimelineTimingFallback({
88
+ iframe,
89
+ projectId: null,
90
+ reloadPreview,
91
+ gsapMutation: async () => ({ mutated: true, scriptText: after }),
92
+ onGsapError: () => {},
93
+ rebindWhenUnmutated: true,
94
+ });
95
+ return reloadPreview;
96
+ }
97
+
98
+ /** Wraps `element` in a composition host with id `id`, and returns the host. */
99
+ function inComposition(id: string, element: Element) {
100
+ const host = document.createElement("div");
101
+ host.setAttribute("data-composition-id", id);
102
+ element.replaceWith(host);
103
+ host.appendChild(element);
104
+ return host;
105
+ }
106
+
107
+ /** `code` as the preview serves it: indented inside the page, with the blank lines around it. */
108
+ const reformatted = (code: string) => `\n${code.replace(/^/gm, " ")}\n`;
109
+
110
+ const script = (...lines: string[]) =>
111
+ ["var tl = gsap.timeline({ paused: true });", ...lines, 'window.__timelines["t"] = tl;'].join(
112
+ "\n",
113
+ );
114
+
115
+ it.each([
116
+ {
117
+ name: "a tween with no written length",
118
+ path: "rerun",
119
+ before: script('tl.to("#a", { x: 100 }, 0);', 'tl.to("#b", { x: 50, duration: 1 }, 1);'),
120
+ after: script('tl.to("#a", { x: 100 }, 0);', 'tl.to("#b", { x: 50, duration: 1 }, 2);'),
121
+ },
122
+ {
123
+ name: "a step list with no lengths",
124
+ path: "retime",
125
+ before: script(
126
+ 'tl.to("#a", { keyframes: [{ x: 10 }, { x: 20 }] }, 0);',
127
+ 'tl.to("#b", { x: 5, duration: 1 }, 1);',
128
+ ),
129
+ after: script(
130
+ 'tl.to("#a", { keyframes: [{ x: 10 }, { x: 20 }] }, 0);',
131
+ 'tl.to("#b", { x: 5, duration: 1 }, 2);',
132
+ ),
133
+ },
134
+ {
135
+ name: "a stagger beside the moved tween",
136
+ path: "rerun",
137
+ before: script(
138
+ 'tl.to(".item", { x: 100, duration: 1, stagger: 0.2 }, 0);',
139
+ 'tl.to("#a", { y: 5, duration: 1 }, 2);',
140
+ ),
141
+ after: script(
142
+ 'tl.to(".item", { x: 100, duration: 1, stagger: 0.2 }, 0);',
143
+ 'tl.to("#a", { y: 5, duration: 1 }, 3);',
144
+ ),
145
+ },
146
+ {
147
+ name: "a repeat before an implicit position",
148
+ path: "rerun",
149
+ before: script(
150
+ 'tl.to("#a", { x: 1, duration: 1, repeat: 1 }, 0);',
151
+ 'tl.to("#b", { x: 1, duration: 1 });',
152
+ ),
153
+ after: script(
154
+ 'tl.to("#a", { x: 1, duration: 1, repeat: 1 }, 0.5);',
155
+ 'tl.to("#b", { x: 1, duration: 1 });',
156
+ ),
157
+ },
158
+ {
159
+ name: "a delay before a '<' position",
160
+ path: "rerun",
161
+ before: script(
162
+ 'tl.to("#a", { x: 1, duration: 1, delay: 0.5 }, 0);',
163
+ 'tl.to("#b", { x: 1, duration: 1 }, "<");',
164
+ ),
165
+ after: script(
166
+ 'tl.to("#a", { x: 1, duration: 1, delay: 0.5 }, 1);',
167
+ 'tl.to("#b", { x: 1, duration: 1 }, "<");',
168
+ ),
169
+ },
170
+ {
171
+ name: "two tweens on one element whose live order differs from the script's",
172
+ path: "rerun",
173
+ before: script(
174
+ 'tl.to("#a", { x: 100, duration: 1, delay: 1 }, 0);',
175
+ 'tl.to("#a", { y: 50, duration: 1 }, 0.5);',
176
+ ),
177
+ after: script(
178
+ 'tl.to("#a", { x: 100, duration: 1, delay: 1 }, 0);',
179
+ 'tl.to("#a", { y: 50, duration: 1 }, 1.5);',
180
+ ),
181
+ },
182
+ {
183
+ name: "two tweens on one element that swap order",
184
+ path: "rerun",
185
+ before: script(
186
+ 'tl.to("#a", { x: 100, duration: 1 }, 0);',
187
+ 'tl.to("#a", { x: 200, duration: 1 }, 2);',
188
+ ),
189
+ after: script(
190
+ 'tl.to("#a", { x: 100, duration: 1 }, 3);',
191
+ 'tl.to("#a", { x: 200, duration: 1 }, 2);',
192
+ ),
193
+ },
194
+ {
195
+ name: "a call in the timeline",
196
+ path: "rerun",
197
+ before: script(
198
+ 'tl.to("#a", { x: 1, duration: 1 }, 0);',
199
+ "tl.call(() => {}, [], 1);",
200
+ 'tl.to("#b", { x: 1, duration: 1 }, 2);',
201
+ ),
202
+ after: script(
203
+ 'tl.to("#a", { x: 1, duration: 1 }, 0);',
204
+ "tl.call(() => {}, [], 1);",
205
+ 'tl.to("#b", { x: 1, duration: 1 }, 3);',
206
+ ),
207
+ },
208
+ {
209
+ name: "a counter tween on a plain object",
210
+ path: "rerun",
211
+ before: script(
212
+ "var counter = { n: 0 };",
213
+ "tl.to(counter, { n: 10, duration: 1 }, 0);",
214
+ 'tl.to("#b", { x: 1, duration: 1 }, 1);',
215
+ ),
216
+ after: script(
217
+ "var counter = { n: 0 };",
218
+ "tl.to(counter, { n: 10, duration: 1 }, 0);",
219
+ 'tl.to("#b", { x: 1, duration: 1 }, 2);',
220
+ ),
221
+ },
222
+ {
223
+ name: "a paused playhead past the moved tween",
224
+ path: "retime",
225
+ before: script('tl.to("#a", { x: 100, duration: 1 }, 0.5);'),
226
+ after: script('tl.to("#a", { x: 100, duration: 1 }, 1.5);'),
227
+ at: 2,
228
+ },
229
+ {
230
+ name: "an unmoved tween on the same element that overlaps the moved one",
231
+ path: "rerun",
232
+ before: script(
233
+ 'tl.to(".item", { x: 100, duration: 2 }, 0);',
234
+ 'tl.to("#a", { x: 0, duration: 1 }, 4);',
235
+ ),
236
+ after: script(
237
+ 'tl.to(".item", { x: 100, duration: 2 }, 0);',
238
+ 'tl.to("#a", { x: 0, duration: 1 }, 1);',
239
+ ),
240
+ },
241
+ {
242
+ name: "a sub-composition tween on the moved tween's element",
243
+ path: "rerun",
244
+ before: script('tl.to("#a", { x: 100, duration: 1 }, 0);'),
245
+ after: script('tl.to("#a", { x: 100, duration: 1 }, 3);'),
246
+ nested: 'sub.to("#a", { x: 50, duration: 1 }, 2);',
247
+ },
248
+ {
249
+ name: "a delayed tween that GSAP orders after the moved one",
250
+ path: "rerun",
251
+ before: script(
252
+ 'tl.to("#a", { opacity: 0.5, duration: 1, delay: 2 }, 0);',
253
+ 'tl.to("#a", { x: 100, duration: 1 }, 0);',
254
+ ),
255
+ after: script(
256
+ 'tl.to("#a", { opacity: 0.5, duration: 1, delay: 2 }, 0);',
257
+ 'tl.to("#a", { x: 100, duration: 1 }, 3);',
258
+ ),
259
+ },
260
+ {
261
+ name: "a set moved onto a playhead at 0",
262
+ path: "retime",
263
+ before: script('tl.set("#a", { x: 30 }, 2);', 'tl.to("#b", { x: 1, duration: 1 }, 3);'),
264
+ after: script('tl.set("#a", { x: 30 }, 0);', 'tl.to("#b", { x: 1, duration: 1 }, 3);'),
265
+ },
266
+ {
267
+ name: "a tween whose callback draws another element",
268
+ path: "retime",
269
+ before: script(
270
+ 'tl.to("#a", { x: 100, duration: 2, onUpdate: function () { gsap.set("#c", { y: Math.round(this.progress() * 100) }); } }, 1);',
271
+ ),
272
+ after: script(
273
+ 'tl.to("#a", { x: 100, duration: 2, onUpdate: function () { gsap.set("#c", { y: Math.round(this.progress() * 100) }); } }, 1.5);',
274
+ ),
275
+ at: 2,
276
+ },
277
+ {
278
+ name: "a trim of an element with an entrance and an exit, rounded as the writer writes it",
279
+ path: "retime",
280
+ before: script(
281
+ 'tl.to("#a", { y: 40, duration: 1 }, 0);',
282
+ 'tl.to("#a", { x: 50, duration: 1 }, 2);',
283
+ ),
284
+ after: script(
285
+ 'tl.to("#a", { y: 40, duration: 0.833 }, 0);',
286
+ 'tl.to("#a", { x: 50, duration: 0.833 }, 1.667);',
287
+ ),
288
+ },
289
+ {
290
+ name: "a trim of an element whose tween has a delay",
291
+ path: "rerun",
292
+ before: script(
293
+ 'tl.to("#a", { x: 100, duration: 1, delay: 1 }, 0);',
294
+ 'tl.to("#a", { x: 200, duration: 1 }, 2);',
295
+ ),
296
+ after: script(
297
+ 'tl.to("#a", { x: 100, duration: 0.5, delay: 1 }, 0);',
298
+ 'tl.to("#a", { x: 200, duration: 0.5 }, 1);',
299
+ ),
300
+ },
301
+ {
302
+ name: "a move of a sub-composition's host",
303
+ path: "rerun",
304
+ before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
305
+ after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
306
+ setup: () => {
307
+ const host = document.createElement("div");
308
+ host.setAttribute("data-composition-id", "scene");
309
+ document.getElementById("a")!.appendChild(host);
310
+ },
311
+ },
312
+ {
313
+ name: "a tween on an element inside a sub-composition's host",
314
+ path: "rerun",
315
+ before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
316
+ after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
317
+ setup: () => inComposition("t", inComposition("scene", document.getElementById("a")!)),
318
+ },
319
+ {
320
+ name: "a tween on an element of the timeline's own composition",
321
+ path: "retime",
322
+ before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
323
+ after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
324
+ setup: () => inComposition("t", document.getElementById("a")!),
325
+ },
326
+ {
327
+ name: "a set and a tween on one element that round to the same start",
328
+ path: "rerun",
329
+ before: script(
330
+ 'tl.to("#a", { x: 50, duration: 1 }, 0);',
331
+ 'tl.set("#a", { x: 0 }, 2.0004);',
332
+ 'tl.to("#a", { x: 100, duration: 1 }, 2);',
333
+ ),
334
+ after: script(
335
+ 'tl.to("#a", { x: 50, duration: 1 }, 1);',
336
+ 'tl.set("#a", { x: 0 }, 3);',
337
+ 'tl.to("#a", { x: 100, duration: 1 }, 3);',
338
+ ),
339
+ },
340
+ {
341
+ name: "a slight trim of an element whose tween has a delay",
342
+ path: "rerun",
343
+ before: script(
344
+ 'tl.to("#a", { x: 100, duration: 1, delay: 1 }, 0);',
345
+ 'tl.to("#a", { x: 200, duration: 1 }, 2);',
346
+ ),
347
+ after: script(
348
+ 'tl.to("#a", { x: 100, duration: 0.999, delay: 1 }, 0);',
349
+ 'tl.to("#a", { x: 200, duration: 0.999 }, 1.998);',
350
+ ),
351
+ },
352
+ {
353
+ name: "a set that never played moved onto a playhead at 0",
354
+ path: "retime",
355
+ before: script('tl.set("#a", { x: 30 }, 2);', 'tl.to("#b", { x: 1, duration: 1 }, 3);'),
356
+ after: script('tl.set("#a", { x: 30 }, 0);', 'tl.to("#b", { x: 1, duration: 1 }, 3);'),
357
+ unplayed: true,
358
+ },
359
+ ])(
360
+ "after a drop over $name, the live preview equals a fresh load or the script re-runs",
361
+ async ({ before, after, nested = "", at = 0, path, setup, unplayed }) => {
362
+ setup?.();
363
+ const error = vi.spyOn(console, "error");
364
+ const live = preview(before, nested);
365
+ // Played through once, then parked: every tween has recorded its start values.
366
+ if (!unplayed) live.timeline.totalTime(live.timeline.duration(), false);
367
+ live.timeline.totalTime(at, false);
368
+ const reloadPreview = await dropInto(live.iframe, after);
369
+ expect(error).not.toHaveBeenCalled();
370
+ if (reloadPreview.mock.calls.length === 0) {
371
+ const shown = shownNow();
372
+ const got = observe(live.timeline);
373
+ live.timeline.revert();
374
+ const fresh = play(after, at, nested).timeline;
375
+ expect(shown).toEqual(shownNow());
376
+ expect(got).toEqual(observe(fresh));
377
+ }
378
+ expect(reloadPreview.mock.calls.length > 0 ? "rerun" : "retime").toBe(path);
379
+ },
380
+ );
381
+
382
+ it("leaves the live preview equal to a fresh load of the saved script after a move and a resize", () => {
383
+ const { script: after } = retimeClipTweensInScript(
384
+ BEFORE,
385
+ [
386
+ { kind: "shift", targetSelector: "#a", delta: 0.75 },
387
+ {
388
+ kind: "scale",
389
+ targetSelector: "#c",
390
+ oldStart: 2,
391
+ oldDuration: 1.5,
392
+ newStart: 2.5,
393
+ newDuration: 3,
394
+ },
395
+ ],
396
+ document,
397
+ );
398
+ const fresh = play(after);
399
+ const want = observe(fresh.timeline);
400
+ fresh.timeline.revert();
401
+
402
+ const live = preview(BEFORE);
403
+ const plan = planLiveRetimeFromPreview(live.iframe, after);
404
+ expect(plan.kind).toBe("retime");
405
+ if (plan.kind !== "retime") return;
406
+ expect(plan.tweens.filter((t) => t.moved).map((t) => t.selector)).toEqual(["#a", "#c"]);
407
+ expect(applyLiveRetime(live.iframe, plan)).toBe(true);
408
+
409
+ expect(observe(live.timeline)).toEqual(want);
410
+ expect(live.tag.textContent).toBe(after);
411
+ });
412
+
413
+ it("re-runs the script for an edit that changes more than timing", () => {
414
+ const after = BEFORE.replace("x: 100", "x: 140").replace(", 0.5);", ", 0.9);");
415
+ expect(planLiveRetime(BEFORE, after).kind).toBe("rerun");
416
+ });
417
+
418
+ it("re-runs the script when the edit also changes code the parser does not read", () => {
419
+ const before = script('tl.to("#a", { x: 1, duration: 1 }, 0);', 'document.title = "one";');
420
+ const after = script('tl.to("#a", { x: 1, duration: 1 }, 1);', 'document.title = "two";');
421
+ expect(planLiveRetime(before, after).kind).toBe("rerun");
422
+ });
423
+
424
+ it("reloads the preview, and says so, when moving the live tweens throws", async () => {
425
+ const before = script('tl.to("#a", { x: () => { throw new Error("boom"); }, duration: 1 }, 5);');
426
+ const live = preview(before);
427
+ live.timeline.totalTime(3, false);
428
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
429
+
430
+ const reloadPreview = await dropInto(live.iframe, before.replace("}, 5);", "}, 1);"));
431
+
432
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
433
+ expect(error).toHaveBeenCalledTimes(1);
434
+ });
435
+
436
+ it("re-runs the script when the edit also joins two words of code", () => {
437
+ const before = script('tl.to("#a", { x: 1, duration: 1 }, 0);', "var b = typeof a;");
438
+ const after = script('tl.to("#a", { x: 1, duration: 1 }, 1);', "var b = typeofa;");
439
+ expect(planLiveRetime(before, after).kind).toBe("rerun");
440
+ });
441
+
442
+ it("moves the live tweens beside a helper-built tween in a reformatted copy of the script", async () => {
443
+ const lines = [
444
+ "function pop(sel, at) { tl.to(sel, { x: 5, duration: 1 }, at); }",
445
+ 'pop("#b", 2);',
446
+ ];
447
+ const before = script('tl.to("#a", { x: 1, duration: 1 }, 0);', ...lines);
448
+ const live = preview(before);
449
+ live.tag.textContent = reformatted(before);
450
+
451
+ const reloadPreview = await dropInto(
452
+ live.iframe,
453
+ script('tl.to("#a", { x: 1, duration: 1 }, 0.5);', ...lines),
454
+ );
455
+
456
+ expect(reloadPreview).not.toHaveBeenCalled();
457
+ });
458
+
459
+ it("moves the live tweens beside a multi-line value in a reformatted copy of the script", async () => {
460
+ const raw = 'tl.to("#b", { x: [\n 1,\n 2,\n].length, duration: 1 }, 2);';
461
+ const before = script('tl.to("#a", { y: 1, duration: 1 }, 0);', raw);
462
+ const live = preview(before);
463
+ live.tag.textContent = reformatted(before);
464
+
465
+ const reloadPreview = await dropInto(
466
+ live.iframe,
467
+ script('tl.to("#a", { y: 1, duration: 1 }, 0.5);', raw),
468
+ );
469
+
470
+ expect(reloadPreview).not.toHaveBeenCalled();
471
+ });
472
+
473
+ it("re-runs the script when the edit adds a tween", () => {
474
+ const after = BEFORE.replace(
475
+ "window.__timelines",
476
+ 'tl.to("#a", { y: 9, duration: 1 }, 4);\nwindow.__timelines',
477
+ );
478
+ expect(planLiveRetime(BEFORE, after).kind).toBe("rerun");
479
+ });
480
+
481
+ it("leaves a live timeline that does not pair with its script untouched", () => {
482
+ // The preview runs a script with one more tween than the text it claims to be running.
483
+ const ran = BEFORE.replace(
484
+ "window.__timelines",
485
+ 'tl.to("#b", { x: 5, duration: 1 }, 0);\nwindow.__timelines',
486
+ );
487
+ const live = preview(ran);
488
+ live.tag.textContent = BEFORE;
489
+ const { script: after } = retimeClipTweensInScript(
490
+ BEFORE,
491
+ [{ kind: "shift", targetSelector: "#a", delta: 0.75 }],
492
+ document,
493
+ );
494
+ const plan = planLiveRetimeFromPreview(live.iframe, after);
495
+ expect(plan.kind).toBe("retime");
496
+ if (plan.kind !== "retime") return;
497
+ const starts = live.timeline.getChildren(false, true, false).map((t) => t.startTime());
498
+
499
+ expect(applyLiveRetime(live.iframe, plan)).toBe(false);
500
+ expect(live.timeline.getChildren(false, true, false).map((t) => t.startTime())).toEqual(starts);
501
+ expect(live.tag.textContent).toBe(BEFORE);
502
+ });
503
+
504
+ it("syncs a timeline move by moving the live tweens and rebinding, without re-running the script", async () => {
505
+ const live = preview(BEFORE);
506
+ const after = moveA();
507
+ const reloadPreview = await dropInto(live.iframe, after);
508
+
509
+ expect(reloadPreview).not.toHaveBeenCalled();
510
+ expect(live.rebind).toHaveBeenCalledTimes(1);
511
+ const got = observe(live.timeline);
512
+ live.timeline.revert();
513
+ expect(got).toEqual(observe(play(after).timeline));
514
+ expect(document.querySelectorAll("script")).toHaveLength(1);
515
+ });
516
+
517
+ it("moves the live tweens when the preview runs a reformatted copy of the saved script", async () => {
518
+ const live = preview(BEFORE);
519
+ live.tag.textContent = reformatted(BEFORE);
520
+
521
+ const reloadPreview = await dropInto(live.iframe, moveA());
522
+
523
+ expect(reloadPreview).not.toHaveBeenCalled();
524
+ });
525
+
526
+ it("pairs by start time, so a second move after a reorder still lands on the right tweens", async () => {
527
+ const live = preview(BEFORE);
528
+ const first = moveA(); // #a now starts after #b, so the live children re-sort
529
+ await dropInto(live.iframe, first);
530
+ const { script: second } = retimeClipTweensInScript(
531
+ first,
532
+ [{ kind: "shift", targetSelector: "#b", delta: 1 }],
533
+ document,
534
+ );
535
+ const error = vi.spyOn(console, "error");
536
+
537
+ const reloadPreview = await dropInto(live.iframe, second);
538
+
539
+ expect(error).not.toHaveBeenCalled();
540
+ expect(reloadPreview).not.toHaveBeenCalled();
541
+ const got = observe(live.timeline);
542
+ live.timeline.revert();
543
+ expect(got).toEqual(observe(play(second).timeline));
544
+ });