@@ -5,10 +5,16 @@ import { tmpdir } from "node:os";
55import { join } from "node:path" ;
66import { registerFileRoutes } from "./files" ;
77import type { StudioApiAdapter } from "../types" ;
8+ import {
9+ consumeFileWriteReceipt ,
10+ fileContentVersion ,
11+ resetFileWriteReceipts ,
12+ } from "../helpers/fileVersion" ;
813
914const tempDirs : string [ ] = [ ] ;
1015
1116afterEach ( ( ) => {
17+ resetFileWriteReceipts ( ) ;
1218 for ( const dir of tempDirs . splice ( 0 ) ) {
1319 rmSync ( dir , { recursive : true , force : true } ) ;
1420 }
@@ -64,6 +70,98 @@ describe("registerFileRoutes", () => {
6470 expect ( response . status ) . toBe ( 404 ) ;
6571 } ) ;
6672
73+ it ( "returns the same strong content version in JSON and ETag" , async ( ) => {
74+ const projectDir = createProjectDir ( ) ;
75+ const app = new Hono ( ) ;
76+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
77+
78+ const response = await app . request ( "http://localhost/projects/demo/files/index.html" ) ;
79+ const payload = ( await response . json ( ) ) as { content ?: string ; version ?: string } ;
80+
81+ expect ( payload . version ) . toBe ( fileContentVersion ( payload . content ! ) ) ;
82+ expect ( response . headers . get ( "etag" ) ) . toBe ( payload . version ) ;
83+ } ) ;
84+
85+ it ( "requires If-Match for updates and preserves the current bytes" , async ( ) => {
86+ const projectDir = createProjectDir ( ) ;
87+ const app = new Hono ( ) ;
88+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
89+
90+ const response = await app . request ( "http://localhost/projects/demo/files/index.html" , {
91+ method : "PUT" ,
92+ body : "stale overwrite" ,
93+ } ) ;
94+
95+ expect ( response . status ) . toBe ( 428 ) ;
96+ expect ( readFileSync ( join ( projectDir , "index.html" ) , "utf-8" ) ) . toBe (
97+ "<html><body>Preview</body></html>" ,
98+ ) ;
99+ } ) ;
100+
101+ it ( "requires an explicit create precondition for missing files" , async ( ) => {
102+ const projectDir = createProjectDir ( ) ;
103+ const app = new Hono ( ) ;
104+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
105+
106+ const response = await app . request ( "http://localhost/projects/demo/files/new.html" , {
107+ method : "PUT" ,
108+ body : "new bytes" ,
109+ } ) ;
110+
111+ expect ( response . status ) . toBe ( 428 ) ;
112+ expect ( ( ) => readFileSync ( join ( projectDir , "new.html" ) , "utf-8" ) ) . toThrow ( ) ;
113+ } ) ;
114+
115+ it ( "creates a missing file only when it is still missing" , async ( ) => {
116+ const projectDir = createProjectDir ( ) ;
117+ const app = new Hono ( ) ;
118+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
119+
120+ const created = await app . request ( "http://localhost/projects/demo/files/new.html" , {
121+ method : "PUT" ,
122+ headers : { "If-None-Match" : "*" } ,
123+ body : "new bytes" ,
124+ } ) ;
125+
126+ expect ( created . status ) . toBe ( 200 ) ;
127+ expect ( readFileSync ( join ( projectDir , "new.html" ) , "utf-8" ) ) . toBe ( "new bytes" ) ;
128+
129+ const raced = await app . request ( "http://localhost/projects/demo/files/new.html" , {
130+ method : "PUT" ,
131+ headers : { "If-None-Match" : "*" } ,
132+ body : "overwrite" ,
133+ } ) ;
134+ const payload = ( await raced . json ( ) ) as { currentContent ?: string ; currentVersion ?: string } ;
135+
136+ expect ( raced . status ) . toBe ( 409 ) ;
137+ expect ( payload . currentContent ) . toBe ( "new bytes" ) ;
138+ expect ( payload . currentVersion ) . toBe ( fileContentVersion ( "new bytes" ) ) ;
139+ expect ( readFileSync ( join ( projectDir , "new.html" ) , "utf-8" ) ) . toBe ( "new bytes" ) ;
140+ } ) ;
141+
142+ it ( "returns 409 with the current version/content for a stale writer" , async ( ) => {
143+ const projectDir = createProjectDir ( ) ;
144+ const app = new Hono ( ) ;
145+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
146+ const current = "newer external bytes" ;
147+ writeFileSync ( join ( projectDir , "index.html" ) , current ) ;
148+
149+ const response = await app . request ( "http://localhost/projects/demo/files/index.html" , {
150+ method : "PUT" ,
151+ headers : { "If-Match" : fileContentVersion ( "older bytes" ) } ,
152+ body : "stale overwrite" ,
153+ } ) ;
154+ const payload = ( await response . json ( ) ) as {
155+ currentVersion ?: string ;
156+ currentContent ?: string ;
157+ } ;
158+
159+ expect ( response . status ) . toBe ( 409 ) ;
160+ expect ( payload . currentVersion ) . toBe ( fileContentVersion ( current ) ) ;
161+ expect ( payload . currentContent ) . toBe ( current ) ;
162+ expect ( readFileSync ( join ( projectDir , "index.html" ) , "utf-8" ) ) . toBe ( current ) ;
163+ } ) ;
164+
67165 it ( "backs up the previous file content before PUT overwrite" , async ( ) => {
68166 const projectDir = createProjectDir ( ) ;
69167 writeFileSync ( join ( projectDir , "index.html" ) , "before" ) ;
@@ -72,12 +170,29 @@ describe("registerFileRoutes", () => {
72170
73171 const response = await app . request ( "http://localhost/projects/demo/files/index.html" , {
74172 method : "PUT" ,
173+ headers : {
174+ "If-Match" : fileContentVersion ( "before" ) ,
175+ "X-Hyperframes-Write-Token" : "studio-write-1" ,
176+ } ,
75177 body : "after" ,
76178 } ) ;
77- const payload = ( await response . json ( ) ) as { path ?: string ; backupPath ?: string } ;
179+ const payload = ( await response . json ( ) ) as {
180+ path ?: string ;
181+ version ?: string ;
182+ writeToken ?: string ;
183+ backupPath ?: string ;
184+ } ;
78185
79186 expect ( response . status ) . toBe ( 200 ) ;
80187 expect ( payload . path ) . toBe ( "index.html" ) ;
188+ expect ( payload . version ) . toBe ( fileContentVersion ( "after" ) ) ;
189+ expect ( payload . writeToken ) . toBe ( "studio-write-1" ) ;
190+ expect ( response . headers . get ( "etag" ) ) . toBe ( payload . version ) ;
191+ expect ( consumeFileWriteReceipt ( join ( projectDir , "index.html" ) ) ) . toEqual ( {
192+ path : "index.html" ,
193+ version : payload . version ,
194+ writeToken : "studio-write-1" ,
195+ } ) ;
81196 expect ( payload . backupPath ) . toMatch ( / ^ \. h y p e r f r a m e s \/ b a c k u p \/ / ) ;
82197 expect ( readFileSync ( join ( projectDir , payload . backupPath ! ) , "utf-8" ) ) . toBe ( "before" ) ;
83198 expect ( readFileSync ( join ( projectDir , "index.html" ) , "utf-8" ) ) . toBe ( "after" ) ;
@@ -132,6 +247,41 @@ describe("registerFileRoutes", () => {
132247 expect ( readFileSync ( join ( projectDir , "index.html" ) , "utf-8" ) ) . toContain ( "After" ) ;
133248 } ) ;
134249
250+ it ( "returns the new strong version after a split-element mutation" , async ( ) => {
251+ const projectDir = createProjectDir ( ) ;
252+ writeFileSync (
253+ join ( projectDir , "index.html" ) ,
254+ '<div id="clip" data-start="0" data-duration="4">Clip</div>' ,
255+ ) ;
256+ const app = new Hono ( ) ;
257+ registerFileRoutes ( app , createAdapter ( projectDir ) ) ;
258+
259+ const response = await app . request (
260+ "http://localhost/projects/demo/file-mutations/split-element/index.html" ,
261+ {
262+ method : "POST" ,
263+ headers : { "Content-Type" : "application/json" } ,
264+ body : JSON . stringify ( {
265+ target : { id : "clip" } ,
266+ splitTime : 2 ,
267+ newId : "clip-split" ,
268+ elementStart : 0 ,
269+ elementDuration : 4 ,
270+ } ) ,
271+ } ,
272+ ) ;
273+ const payload = ( await response . json ( ) ) as {
274+ changed ?: boolean ;
275+ content ?: string ;
276+ version ?: string ;
277+ } ;
278+
279+ expect ( response . status ) . toBe ( 200 ) ;
280+ expect ( payload . changed ) . toBe ( true ) ;
281+ expect ( payload . version ) . toBe ( fileContentVersion ( payload . content ! ) ) ;
282+ expect ( response . headers . get ( "etag" ) ) . toBe ( payload . version ) ;
283+ } ) ;
284+
135285 // A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
136286 // targeting element variables resolved from querySelector, with interleaved
137287 // gsap.set() calls. This is the shape every scaffolded composition uses.
@@ -229,13 +379,16 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
229379 ok : boolean ;
230380 mutated ?: boolean ;
231381 after : string ;
382+ version ?: string ;
232383 parsed : { animations : Array < { fromProperties ?: Record < string , number | string > } > } ;
233384 } ;
234385
235386 expect ( res . status ) . toBe ( 200 ) ;
236387 expect ( result . ok ) . toBe ( true ) ;
237388 expect ( result . mutated ) . toBe ( true ) ;
238389 expect ( result . after ) . toContain ( "opacity: 0.2" ) ;
390+ expect ( result . version ) . toBe ( fileContentVersion ( result . after ) ) ;
391+ expect ( res . headers . get ( "etag" ) ) . toBe ( result . version ) ;
239392 expect ( result . parsed . animations [ 0 ] . fromProperties ?. opacity ) . toBe ( 0.2 ) ;
240393 // x unchanged
241394 expect ( result . parsed . animations [ 0 ] . fromProperties ?. x ) . toBe ( - 50 ) ;
0 commit comments