@@ -13,6 +13,11 @@ import { tmpdir } from "node:os";
1313import { join } from "node:path" ;
1414import { commitElementPatchBatches , registerFileRoutes } from "./files" ;
1515import type { StudioApiAdapter } from "../types" ;
16+ import {
17+ consumeFileWriteReceipt ,
18+ fileContentVersion ,
19+ resetFileWriteReceipts ,
20+ } from "../helpers/fileVersion" ;
1621
1722const recastImportGate = vi . hoisted < {
1823 wait : Promise < void > | null ;
@@ -30,6 +35,7 @@ const tempDirs: string[] = [];
3035afterEach ( ( ) => {
3136 recastImportGate . wait = null ;
3237 recastImportGate . onEnter = null ;
38+ resetFileWriteReceipts ( ) ;
3339 for ( const dir of tempDirs . splice ( 0 ) ) {
3440 rmSync ( dir , { recursive : true , force : true } ) ;
3541 }
@@ -104,6 +110,98 @@ describe("registerFileRoutes", () => {
104110 expect ( response . status ) . toBe ( 404 ) ;
105111 } ) ;
106112
113+ it ( "returns the same strong content version in JSON and ETag" , async ( ) => {
114+ const projectDir = createProjectDir ( ) ;
115+ const app = new Hono ( ) ;
116+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
117+
118+ const response = await app . request ( "http://localhost/projects/demo/files/index.html" ) ;
119+ const payload = ( await response . json ( ) ) as { content ?: string ; version ?: string } ;
120+
121+ expect ( payload . version ) . toBe ( fileContentVersion ( payload . content ! ) ) ;
122+ expect ( response . headers . get ( "etag" ) ) . toBe ( payload . version ) ;
123+ } ) ;
124+
125+ it ( "requires If-Match for updates and preserves the current bytes" , async ( ) => {
126+ const projectDir = createProjectDir ( ) ;
127+ const app = new Hono ( ) ;
128+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
129+
130+ const response = await app . request ( "http://localhost/projects/demo/files/index.html" , {
131+ method : "PUT" ,
132+ body : "stale overwrite" ,
133+ } ) ;
134+
135+ expect ( response . status ) . toBe ( 428 ) ;
136+ expect ( readFileSync ( join ( projectDir , "index.html" ) , "utf-8" ) ) . toBe (
137+ "<html><body>Preview</body></html>" ,
138+ ) ;
139+ } ) ;
140+
141+ it ( "requires an explicit create precondition for missing files" , async ( ) => {
142+ const projectDir = createProjectDir ( ) ;
143+ const app = new Hono ( ) ;
144+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
145+
146+ const response = await app . request ( "http://localhost/projects/demo/files/new.html" , {
147+ method : "PUT" ,
148+ body : "new bytes" ,
149+ } ) ;
150+
151+ expect ( response . status ) . toBe ( 428 ) ;
152+ expect ( ( ) => readFileSync ( join ( projectDir , "new.html" ) , "utf-8" ) ) . toThrow ( ) ;
153+ } ) ;
154+
155+ it ( "creates a missing file only when it is still missing" , async ( ) => {
156+ const projectDir = createProjectDir ( ) ;
157+ const app = new Hono ( ) ;
158+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
159+
160+ const created = await app . request ( "http://localhost/projects/demo/files/new.html" , {
161+ method : "PUT" ,
162+ headers : { "If-None-Match" : "*" } ,
163+ body : "new bytes" ,
164+ } ) ;
165+
166+ expect ( created . status ) . toBe ( 200 ) ;
167+ expect ( readFileSync ( join ( projectDir , "new.html" ) , "utf-8" ) ) . toBe ( "new bytes" ) ;
168+
169+ const raced = await app . request ( "http://localhost/projects/demo/files/new.html" , {
170+ method : "PUT" ,
171+ headers : { "If-None-Match" : "*" } ,
172+ body : "overwrite" ,
173+ } ) ;
174+ const payload = ( await raced . json ( ) ) as { currentContent ?: string ; currentVersion ?: string } ;
175+
176+ expect ( raced . status ) . toBe ( 409 ) ;
177+ expect ( payload . currentContent ) . toBe ( "new bytes" ) ;
178+ expect ( payload . currentVersion ) . toBe ( fileContentVersion ( "new bytes" ) ) ;
179+ expect ( readFileSync ( join ( projectDir , "new.html" ) , "utf-8" ) ) . toBe ( "new bytes" ) ;
180+ } ) ;
181+
182+ it ( "returns 409 with the current version/content for a stale writer" , async ( ) => {
183+ const projectDir = createProjectDir ( ) ;
184+ const app = new Hono ( ) ;
185+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
186+ const current = "newer external bytes" ;
187+ writeFileSync ( join ( projectDir , "index.html" ) , current ) ;
188+
189+ const response = await app . request ( "http://localhost/projects/demo/files/index.html" , {
190+ method : "PUT" ,
191+ headers : { "If-Match" : fileContentVersion ( "older bytes" ) } ,
192+ body : "stale overwrite" ,
193+ } ) ;
194+ const payload = ( await response . json ( ) ) as {
195+ currentVersion ?: string ;
196+ currentContent ?: string ;
197+ } ;
198+
199+ expect ( response . status ) . toBe ( 409 ) ;
200+ expect ( payload . currentVersion ) . toBe ( fileContentVersion ( current ) ) ;
201+ expect ( payload . currentContent ) . toBe ( current ) ;
202+ expect ( readFileSync ( join ( projectDir , "index.html" ) , "utf-8" ) ) . toBe ( current ) ;
203+ } ) ;
204+
107205 it ( "backs up the previous file content before PUT overwrite" , async ( ) => {
108206 const projectDir = createProjectDir ( ) ;
109207 writeFileSync ( join ( projectDir , "index.html" ) , "before" ) ;
@@ -112,12 +210,29 @@ describe("registerFileRoutes", () => {
112210
113211 const response = await app . request ( "http://localhost/projects/demo/files/index.html" , {
114212 method : "PUT" ,
213+ headers : {
214+ "If-Match" : fileContentVersion ( "before" ) ,
215+ "X-Hyperframes-Write-Token" : "studio-write-1" ,
216+ } ,
115217 body : "after" ,
116218 } ) ;
117- const payload = ( await response . json ( ) ) as { path ?: string ; backupPath ?: string } ;
219+ const payload = ( await response . json ( ) ) as {
220+ path ?: string ;
221+ version ?: string ;
222+ writeToken ?: string ;
223+ backupPath ?: string ;
224+ } ;
118225
119226 expect ( response . status ) . toBe ( 200 ) ;
120227 expect ( payload . path ) . toBe ( "index.html" ) ;
228+ expect ( payload . version ) . toBe ( fileContentVersion ( "after" ) ) ;
229+ expect ( payload . writeToken ) . toBe ( "studio-write-1" ) ;
230+ expect ( response . headers . get ( "etag" ) ) . toBe ( payload . version ) ;
231+ expect ( consumeFileWriteReceipt ( join ( projectDir , "index.html" ) ) ) . toEqual ( {
232+ path : "index.html" ,
233+ version : payload . version ,
234+ writeToken : "studio-write-1" ,
235+ } ) ;
121236 expect ( payload . backupPath ) . toMatch ( / ^ \. h y p e r f r a m e s \/ b a c k u p \/ / ) ;
122237 expect ( readFileSync ( join ( projectDir , payload . backupPath ! ) , "utf-8" ) ) . toBe ( "before" ) ;
123238 expect ( readFileSync ( join ( projectDir , "index.html" ) , "utf-8" ) ) . toBe ( "after" ) ;
@@ -404,6 +519,41 @@ describe("registerFileRoutes", () => {
404519 expect ( existsSync ( join ( projectDir , ".hyperframes" , "backup" ) ) ) . toBe ( false ) ;
405520 } ) ;
406521
522+ it ( "returns the new strong version after a split-element mutation" , async ( ) => {
523+ const projectDir = createProjectDir ( ) ;
524+ writeFileSync (
525+ join ( projectDir , "index.html" ) ,
526+ '<div id="clip" data-start="0" data-duration="4">Clip</div>' ,
527+ ) ;
528+ const app = new Hono ( ) ;
529+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
530+
531+ const response = await app . request (
532+ "http://localhost/projects/demo/file-mutations/split-element/index.html" ,
533+ {
534+ method : "POST" ,
535+ headers : { "Content-Type" : "application/json" } ,
536+ body : JSON . stringify ( {
537+ target : { id : "clip" } ,
538+ splitTime : 2 ,
539+ newId : "clip-split" ,
540+ elementStart : 0 ,
541+ elementDuration : 4 ,
542+ } ) ,
543+ } ,
544+ ) ;
545+ const payload = ( await response . json ( ) ) as {
546+ changed ?: boolean ;
547+ content ?: string ;
548+ version ?: string ;
549+ } ;
550+
551+ expect ( response . status ) . toBe ( 200 ) ;
552+ expect ( payload . changed ) . toBe ( true ) ;
553+ expect ( payload . version ) . toBe ( fileContentVersion ( payload . content ! ) ) ;
554+ expect ( response . headers . get ( "etag" ) ) . toBe ( payload . version ) ;
555+ } ) ;
556+
407557 // A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
408558 // targeting element variables resolved from querySelector, with interleaved
409559 // gsap.set() calls. This is the shape every scaffolded composition uses.
@@ -710,13 +860,16 @@ const tl = gsap.timeline({ paused: true });
710860 ok : boolean ;
711861 mutated ?: boolean ;
712862 after : string ;
863+ version ?: string ;
713864 parsed : { animations : Array < { fromProperties ?: Record < string , number | string > } > } ;
714865 } ;
715866
716867 expect ( res . status ) . toBe ( 200 ) ;
717868 expect ( result . ok ) . toBe ( true ) ;
718869 expect ( result . mutated ) . toBe ( true ) ;
719870 expect ( result . after ) . toContain ( "opacity: 0.2" ) ;
871+ expect ( result . version ) . toBe ( fileContentVersion ( result . after ) ) ;
872+ expect ( res . headers . get ( "etag" ) ) . toBe ( result . version ) ;
720873 expect ( result . parsed . animations [ 0 ] . fromProperties ?. opacity ) . toBe ( 0.2 ) ;
721874 // x unchanged
722875 expect ( result . parsed . animations [ 0 ] . fromProperties ?. x ) . toBe ( - 50 ) ;
0 commit comments