@@ -10,6 +10,17 @@ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
1010import { useElementLifecycleOps } from "./useElementLifecycleOps" ;
1111import { useTimelineEditing } from "./useTimelineEditing" ;
1212
13+ vi . mock ( "../components/editor/manualEditingAvailability" , async ( importOriginal ) => {
14+ const actual =
15+ await importOriginal < typeof import ( "../components/editor/manualEditingAvailability" ) > ( ) ;
16+ return {
17+ ...actual ,
18+ STUDIO_SDK_CUTOVER_ENABLED : true ,
19+ STUDIO_SDK_CUTOVER_FAMILIES : new Set ( [ "timing" ] ) ,
20+ STUDIO_SDK_RESOLVER_SHADOW_ENABLED : false ,
21+ } ;
22+ } ) ;
23+
1324( globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT : boolean } ) . IS_REACT_ACT_ENVIRONMENT = true ;
1425
1526type ZIndexEntry = {
@@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: {
108119 } ) => Promise < void > ;
109120 reloadPreview ?: ( ) => void ;
110121 sdkSession ?: Awaited < ReturnType < typeof openComposition > > | null ;
122+ publishSdkSession ?: NonNullable < Parameters < typeof useTimelineEditing > [ 0 ] [ "publishSdkSession" ] > ;
111123 forceReloadSdkSession ?: ( ) => void ;
124+ invalidateGsapCache ?: ( ) => void ;
112125 showToast ?: ( message : string , kind ?: string ) => void ;
113126} ) : {
114127 move : ReturnType < typeof useTimelineEditing > [ "handleTimelineElementMove" ] ;
@@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: {
140153 pendingTimelineEditPathRef : { current : new Set < string > ( ) } ,
141154 uploadProjectFiles : vi . fn ( ) ,
142155 sdkSession : input . sdkSession ,
156+ publishSdkSession : input . publishSdkSession ,
143157 forceReloadSdkSession : input . forceReloadSdkSession ,
158+ invalidateGsapCache : input . invalidateGsapCache ,
144159 handleDomZIndexReorderCommitRef : commitRef ,
145160 } ) ;
146161 move = hook . handleTimelineElementMove ;
@@ -163,6 +178,9 @@ function renderTimelineEditingHook(input: {
163178type TimelineRecordEdit = NonNullable <
164179 Parameters < typeof renderTimelineEditingHook > [ 0 ] [ "recordEdit" ]
165180> ;
181+ type TimelinePublishSdkSession = NonNullable <
182+ Parameters < typeof renderTimelineEditingHook > [ 0 ] [ "publishSdkSession" ]
183+ > ;
166184
167185function renderTimelineEditingHookWithLifecycle ( input : {
168186 timelineElements : TimelineElement [ ] ;
@@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise<void> {
227245 * with `gsapBody`. Returns the mock for call inspection.
228246 */
229247function stubProjectFetch ( files : string | Record < string , string > , gsapBody ?: unknown ) {
230- // Keep this test server's capability, file-read, and mutation routes together;
231- // splitting the fixture would obscure the request sequence asserted by callers.
232- // fallow-ignore-next-line complexity
233- const fetchMock = vi . fn ( async ( input : Parameters < typeof fetch > [ 0 ] ) : Promise < Response > => {
234- const url = requestUrl ( input ) ;
235- if ( url . includes ( "/api/projects/p1/gsap-mutation-capabilities" ) ) {
236- return jsonResponse ( { atomicOwnershipPairs : true } ) ;
237- }
238- if ( url . includes ( "/api/projects/p1/files/" ) ) {
239- if ( typeof files === "string" ) return jsonResponse ( { content : files } ) ;
240- const path = decodeURIComponent ( url . split ( "/files/" ) [ 1 ] ?? "index.html" ) ;
241- return jsonResponse ( { content : files [ path ] } ) ;
242- }
243- if ( url . includes ( "/api/projects/p1/gsap-mutations/" ) ) {
244- const path = decodeURIComponent ( url . split ( "/gsap-mutations/" ) [ 1 ] ?? "index.html" ) ;
245- const content = typeof files === "string" ? files : ( files [ path ] ?? "" ) ;
246- return jsonResponse (
247- gsapBody ?? { mutated : false , scriptText : null , before : content , after : content } ,
248- ) ;
249- }
250- throw new Error ( `Unexpected fetch: ${ url } ` ) ;
251- } ) ;
248+ const pathAfter = ( url : string , marker : string ) =>
249+ decodeURIComponent ( url . split ( marker ) [ 1 ] ?? "index.html" ) ;
250+ const fileContent = ( path : string ) => ( typeof files === "string" ? files : files [ path ] ) ;
251+ // One handler per route, so the mock itself stays a lookup: the request
252+ // sequence callers assert on is still readable top to bottom.
253+ const routes : Array < [ marker : string , respond : ( url : string ) => Response ] > = [
254+ [
255+ "/api/projects/p1/gsap-mutation-capabilities" ,
256+ ( ) => jsonResponse ( { atomicOwnershipPairs : true } ) ,
257+ ] ,
258+ [
259+ "/api/projects/p1/files/" ,
260+ ( url ) => jsonResponse ( { content : fileContent ( pathAfter ( url , "/files/" ) ) } ) ,
261+ ] ,
262+ [
263+ "/api/projects/p1/gsap-mutations/" ,
264+ ( url ) => {
265+ const content = fileContent ( pathAfter ( url , "/gsap-mutations/" ) ) ?? "" ;
266+ return jsonResponse (
267+ gsapBody ?? { mutated : false , scriptText : null , before : content , after : content } ,
268+ ) ;
269+ } ,
270+ ] ,
271+ ] ;
272+ const fetchMock = vi . fn (
273+ async (
274+ input : Parameters < typeof fetch > [ 0 ] ,
275+ _init ?: Parameters < typeof fetch > [ 1 ] ,
276+ ) : Promise < Response > => {
277+ const url = requestUrl ( input ) ;
278+ const route = routes . find ( ( [ marker ] ) => url . includes ( marker ) ) ;
279+ if ( ! route ) throw new Error ( `Unexpected fetch: ${ url } ` ) ;
280+ return route [ 1 ] ( url ) ;
281+ } ,
282+ ) ;
252283 vi . stubGlobal ( "fetch" , fetchMock ) ;
253284 return fetchMock ;
254285}
@@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: {
285316 return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook } ;
286317}
287318
319+ const SDK_KEYFRAMED_SOURCE = [
320+ `<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">` ,
321+ ` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>` ,
322+ `</div>` ,
323+ `<script>` ,
324+ `const tl = gsap.timeline({ paused: true });` ,
325+ `tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);` ,
326+ `window.__timelines = [tl];` ,
327+ `</script>` ,
328+ ] . join ( "\n" ) ;
329+
330+ async function setupSdkKeyframedClipHarness ( ) {
331+ const iframe = createPreviewIframe ( [ { id : "clip" , track : 0 } ] ) ;
332+ const clip = timelineElement ( { id : "clip" , track : 0 , zIndex : 0 , start : 1 } ) ;
333+ const sdkSession = await openComposition ( SDK_KEYFRAMED_SOURCE ) ;
334+ const writeProjectFile = vi . fn < ( ...args : unknown [ ] ) => Promise < void > > ( async ( ) => { } ) ;
335+ const invalidateGsapCache = vi . fn ( ) ;
336+ const fetchMock = stubProjectFetch ( SDK_KEYFRAMED_SOURCE ) ;
337+ usePlayerStore . getState ( ) . setDuration ( 10 ) ;
338+ const hook = renderTimelineEditingHook ( {
339+ timelineElements : [ clip ] ,
340+ iframe,
341+ onZIndexCommit : vi . fn ( ) . mockResolvedValue ( undefined ) ,
342+ projectId : "p1" ,
343+ writeProjectFile,
344+ recordEdit : vi . fn ( async ( ) => { } ) ,
345+ sdkSession,
346+ publishSdkSession : vi . fn < TimelinePublishSdkSession > ( ( ) => "published" ) ,
347+ invalidateGsapCache,
348+ } ) ;
349+ return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } ;
350+ }
351+
288352/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
289353function expectLanePersisted (
290354 iframe : HTMLIFrameElement ,
@@ -710,6 +774,58 @@ describe("useTimelineEditing timeline z-index reorder", () => {
710774 h . unmount ( ) ;
711775 } ) ;
712776
777+ it ( "shifts authored GSAP positions after an SDK-backed clip move commits" , async ( ) => {
778+ const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
779+ await setupSdkKeyframedClipHarness ( ) ;
780+
781+ await act ( async ( ) => {
782+ await hook . move ( clip , { start : 2.25 , track : clip . track } ) ;
783+ } ) ;
784+
785+ expect ( writeProjectFile . mock . calls [ 0 ] ?. [ 1 ] ) . toContain ( 'data-start="2.25"' ) ;
786+ const mutationCall = fetchMock . mock . calls . find ( ( call ) =>
787+ requestUrl ( call [ 0 ] ) . includes ( "/gsap-mutations/" ) ,
788+ ) ;
789+ expect ( mutationCall ) . toBeDefined ( ) ;
790+ const init = mutationCall ?. [ 1 ] as RequestInit | undefined ;
791+ expect ( JSON . parse ( String ( init ?. body ) ) ) . toEqual ( {
792+ type : "shift-positions" ,
793+ targetSelector : "#clip" ,
794+ delta : 1.25 ,
795+ } ) ;
796+ expect ( invalidateGsapCache ) . toHaveBeenCalledTimes ( 1 ) ;
797+
798+ hook . unmount ( ) ;
799+ } ) ;
800+
801+ it ( "scales authored GSAP positions after an SDK-backed clip resize commits" , async ( ) => {
802+ const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
803+ await setupSdkKeyframedClipHarness ( ) ;
804+
805+ await act ( async ( ) => {
806+ await hook . resize ( clip , { start : 2 , duration : 4 , playbackStart : undefined } ) ;
807+ } ) ;
808+
809+ expect ( writeProjectFile . mock . calls [ 0 ] ?. [ 1 ] ) . toContain ( 'data-start="2"' ) ;
810+ expect ( writeProjectFile . mock . calls [ 0 ] ?. [ 1 ] ) . toContain ( 'data-duration="4"' ) ;
811+ const mutationCall = fetchMock . mock . calls . find ( ( call ) =>
812+ requestUrl ( call [ 0 ] ) . includes ( "/gsap-mutations/" ) ,
813+ ) ;
814+ expect ( mutationCall ) . toBeDefined ( ) ;
815+ const init = mutationCall ?. [ 1 ] as RequestInit | undefined ;
816+ expect ( JSON . parse ( String ( init ?. body ) ) ) . toEqual ( {
817+ type : "scale-positions" ,
818+ targetSelector : "#clip" ,
819+ oldStart : 1 ,
820+ oldDuration : 2 ,
821+ newStart : 2 ,
822+ newDuration : 4 ,
823+ } ) ;
824+ expect ( invalidateGsapCache ) . toHaveBeenCalledTimes ( 1 ) ;
825+
826+ hook . unmount ( ) ;
827+ } ) ;
828+
713829 it ( "persists a vertical-only lane move (start unchanged) through the single-element fallback" , async ( ) => {
714830 // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
715831 // pure lane change routed through onMoveElement (no onMoveElements wired)
@@ -821,6 +937,55 @@ describe("useTimelineEditing timeline z-index reorder", () => {
821937 unmount ( ) ;
822938 } ) ;
823939
940+ it ( "shifts every keyed clip and invalidates the cache after an SDK-backed group move" , async ( ) => {
941+ const source = [
942+ `<div data-hf-id="hf-stage" data-hf-root data-duration="10">` ,
943+ ` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>` ,
944+ ` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>` ,
945+ `</div>` ,
946+ `<script>` ,
947+ `const tl = gsap.timeline({ paused: true });` ,
948+ `tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);` ,
949+ `tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);` ,
950+ `window.__timelines = [tl];` ,
951+ `</script>` ,
952+ ] . join ( "\n" ) ;
953+ const { iframe, a, b } = makeTwoClipPair ( ) ;
954+ const sdkSession = await openComposition ( source ) ;
955+ const fetchMock = stubProjectFetch ( source ) ;
956+ const invalidateGsapCache = vi . fn ( ) ;
957+ usePlayerStore . getState ( ) . setDuration ( 10 ) ;
958+ const hook = renderTimelineEditingHook ( {
959+ timelineElements : [ a , b ] ,
960+ iframe,
961+ onZIndexCommit : vi . fn ( ) . mockResolvedValue ( undefined ) ,
962+ projectId : "p1" ,
963+ writeProjectFile : vi . fn < ( ...args : unknown [ ] ) => Promise < void > > ( async ( ) => { } ) ,
964+ recordEdit : vi . fn ( async ( ) => { } ) ,
965+ sdkSession,
966+ publishSdkSession : vi . fn < TimelinePublishSdkSession > ( ( ) => "published" ) ,
967+ invalidateGsapCache,
968+ } ) ;
969+
970+ await act ( async ( ) => {
971+ await hook . groupMove ( [
972+ { element : a , start : 1 } ,
973+ { element : b , start : 2 } ,
974+ ] ) ;
975+ } ) ;
976+
977+ const mutations = fetchMock . mock . calls
978+ . filter ( ( call ) => requestUrl ( call [ 0 ] ) . includes ( "/gsap-mutations/" ) )
979+ . map ( ( call ) => JSON . parse ( String ( ( call [ 1 ] as RequestInit | undefined ) ?. body ) ) ) ;
980+ expect ( mutations ) . toEqual ( [
981+ { type : "shift-positions" , targetSelector : "#a" , delta : 1 } ,
982+ { type : "shift-positions" , targetSelector : "#b" , delta : 1 } ,
983+ ] ) ;
984+ expect ( invalidateGsapCache ) . toHaveBeenCalledTimes ( 1 ) ;
985+
986+ hook . unmount ( ) ;
987+ } ) ;
988+
824989 it ( "partitions a group move by source file while keeping one undo entry" , async ( ) => {
825990 const files : Record < string , string > = {
826991 "index.html" : '<div id="a" data-start="0" data-duration="1"></div>' ,
0 commit comments