@@ -29,6 +29,13 @@ const createFs = (overrides: Partial<FileSystem> = {}): FileSystem =>
2929 ...overrides ,
3030 } ) as unknown as FileSystem ;
3131
32+ // 等待若干轮微任务,确保所有已就绪的 await 都被推进
33+ const flushMicrotasks = async ( rounds = 10 ) => {
34+ for ( let i = 0 ; i < rounds ; i ++ ) {
35+ await Promise . resolve ( ) ;
36+ }
37+ } ;
38+
3239describe ( "SynchronizeService" , ( ) => {
3340 beforeEach ( ( ) => {
3441 vi . clearAllMocks ( ) ;
@@ -41,25 +48,26 @@ describe("SynchronizeService", () => {
4148 releaseFirst = resolve ;
4249 } ) ;
4350 const order : string [ ] = [ ] ;
44- const fs1 = createFs ( {
45- list : vi
46- . fn ( )
47- . mockImplementationOnce ( async ( ) => {
48- order . push ( "first:start" ) ;
49- await firstGate ;
50- order . push ( "first:end" ) ;
51- return [ ] ;
52- } )
53- . mockResolvedValue ( [ ] ) ,
54- } ) ;
51+ // gate 放在第一轮的最后一步(updateFileDigest 内部的 fs.list)
52+ // 这样如果未来锁粒度被改小,第二轮提前进入也会被这个测试捕获
53+ const fs1List = vi
54+ . fn ( )
55+ . mockImplementationOnce ( async ( ) => {
56+ order . push ( "first:list" ) ;
57+ return [ ] ;
58+ } )
59+ . mockImplementationOnce ( async ( ) => {
60+ order . push ( "first:digest" ) ;
61+ await firstGate ;
62+ order . push ( "first:end" ) ;
63+ return [ ] ;
64+ } ) ;
65+ const fs1 = createFs ( { list : fs1List } ) ;
5566 const fs2 = createFs ( {
56- list : vi
57- . fn ( )
58- . mockImplementationOnce ( async ( ) => {
59- order . push ( "second:start" ) ;
60- return [ ] ;
61- } )
62- . mockResolvedValue ( [ ] ) ,
67+ list : vi . fn ( ) . mockImplementation ( async ( ) => {
68+ order . push ( "second:list" ) ;
69+ return [ ] ;
70+ } ) ,
6371 } ) ;
6472 const service = new SynchronizeService (
6573 { } as any ,
@@ -76,17 +84,18 @@ describe("SynchronizeService", () => {
7684 ) ;
7785
7886 const first = service . syncOnce ( syncConfig , fs1 ) ;
79- await Promise . resolve ( ) ;
8087 const second = service . syncOnce ( syncConfig , fs2 ) ;
81- await Promise . resolve ( ) ;
88+ await flushMicrotasks ( ) ;
8289
83- expect ( order ) . toEqual ( [ "first:start" ] ) ;
90+ // 第一轮已经跑到末尾的 updateFileDigest,第二轮一步都没开始
91+ expect ( order ) . toEqual ( [ "first:list" , "first:digest" ] ) ;
8492 expect ( ( fs2 . list as any ) . mock . calls . length ) . toBe ( 0 ) ;
8593
8694 releaseFirst ( ) ;
8795 await Promise . all ( [ first , second ] ) ;
8896
89- expect ( order ) . toEqual ( [ "first:start" , "first:end" , "second:start" ] ) ;
97+ // 第一轮整体结束(first:end)后第二轮才能开始(second:list)
98+ expect ( order . slice ( 0 , 4 ) ) . toEqual ( [ "first:list" , "first:digest" , "first:end" , "second:list" ] ) ;
9099 } ) ;
91100
92101 it ( "does not delete orphan cloud script without meta" , async ( ) => {
@@ -121,6 +130,61 @@ describe("SynchronizeService", () => {
121130 expect ( fs . delete ) . not . toHaveBeenCalled ( ) ;
122131 } ) ;
123132
133+ it ( "preserves cloudStatus for skipped orphan uuid when writing scriptcat-sync.json" , async ( ) => {
134+ const orphanStatus = { enable : false , sort : 7 , updatetime : 100 } ;
135+ const writeMock = vi . fn ( ) . mockResolvedValue ( undefined ) ;
136+ const fs = createFs ( {
137+ list : vi . fn ( ) . mockResolvedValue ( [
138+ {
139+ name : "orphan.user.js" ,
140+ path : "orphan.user.js" ,
141+ size : 1 ,
142+ digest : "d1" ,
143+ createtime : 1 ,
144+ updatetime : 1 ,
145+ } ,
146+ {
147+ name : "scriptcat-sync.json" ,
148+ path : "scriptcat-sync.json" ,
149+ size : 1 ,
150+ digest : "d2" ,
151+ createtime : 1 ,
152+ updatetime : 1 ,
153+ } ,
154+ ] ) ,
155+ open : vi . fn ( ) . mockResolvedValue ( {
156+ read : vi . fn ( ) . mockResolvedValue (
157+ JSON . stringify ( {
158+ version : "1.0.0" ,
159+ status : { scripts : { orphan : orphanStatus } } ,
160+ } )
161+ ) ,
162+ } ) ,
163+ create : vi . fn ( ) . mockResolvedValue ( { write : writeMock } ) ,
164+ } ) ;
165+ const service = new SynchronizeService (
166+ { } as any ,
167+ { } as any ,
168+ { } as any ,
169+ { } as any ,
170+ { } as any ,
171+ { } as any ,
172+ { } as any ,
173+ {
174+ scriptCodeDAO : { } ,
175+ all : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
176+ } as any
177+ ) ;
178+
179+ await service . syncOnce ( syncConfig , fs ) ;
180+
181+ // 第一次 write 是 scriptcat-sync.json 的内容
182+ expect ( writeMock ) . toHaveBeenCalled ( ) ;
183+ const writtenContent = writeMock . mock . calls [ 0 ] [ 0 ] as string ;
184+ const written = JSON . parse ( writtenContent ) ;
185+ expect ( written . status . scripts . orphan ) . toEqual ( orphanStatus ) ;
186+ } ) ;
187+
124188 it ( "waits for installScript during pullScript" , async ( ) => {
125189 let releaseInstall ! : ( ) => void ;
126190 const installGate = new Promise < void > ( ( resolve ) => {
@@ -193,4 +257,142 @@ console.log("ok");`
193257 expect ( installScript ) . toHaveBeenCalledTimes ( 1 ) ;
194258 expect ( settled ) . toBe ( true ) ;
195259 } ) ;
260+
261+ it ( "waits for deleteScript before updating file digest" , async ( ) => {
262+ let releaseDelete ! : ( ) => void ;
263+ const deleteGate = new Promise < void > ( ( resolve ) => {
264+ releaseDelete = resolve ;
265+ } ) ;
266+ const order : string [ ] = [ ] ;
267+ const deleteScript = vi . fn ( ) . mockImplementation ( async ( ) => {
268+ order . push ( "delete:start" ) ;
269+ await deleteGate ;
270+ order . push ( "delete:end" ) ;
271+ } ) ;
272+ // fs.list 第二次调用对应 updateFileDigest,这是 syncOnce 的最后一步
273+ const fsList = vi
274+ . fn ( )
275+ . mockImplementationOnce ( async ( ) => [
276+ {
277+ name : "del-uuid.meta.json" ,
278+ path : "del-uuid.meta.json" ,
279+ size : 1 ,
280+ digest : "d1" ,
281+ createtime : 1 ,
282+ updatetime : 1 ,
283+ } ,
284+ ] )
285+ . mockImplementationOnce ( async ( ) => {
286+ order . push ( "digest:list" ) ;
287+ return [ ] ;
288+ } ) ;
289+ const fs = createFs ( {
290+ list : fsList ,
291+ open : vi . fn ( ) . mockResolvedValue ( {
292+ read : vi . fn ( ) . mockResolvedValue ( JSON . stringify ( { uuid : "del-uuid" , isDeleted : true } ) ) ,
293+ } ) ,
294+ } ) ;
295+ const service = new SynchronizeService (
296+ { } as any ,
297+ { } as any ,
298+ { deleteScript } as any ,
299+ { } as any ,
300+ { } as any ,
301+ { } as any ,
302+ { } as any ,
303+ {
304+ scriptCodeDAO : { } ,
305+ all : vi . fn ( ) . mockResolvedValue ( [
306+ {
307+ uuid : "del-uuid" ,
308+ name : "del" ,
309+ updatetime : 1 ,
310+ createtime : 1 ,
311+ status : 1 ,
312+ sort : 0 ,
313+ metadata : { } ,
314+ } ,
315+ ] ) ,
316+ } as any
317+ ) ;
318+
319+ const promise = service . syncOnce ( syncConfig , fs ) ;
320+ await flushMicrotasks ( ) ;
321+
322+ // delete 已经开始但没结束,updateFileDigest 还没被调用
323+ expect ( order ) . toEqual ( [ "delete:start" ] ) ;
324+
325+ releaseDelete ( ) ;
326+ await promise ;
327+
328+ // delete 必须在 updateFileDigest 之前完成
329+ expect ( order ) . toEqual ( [ "delete:start" , "delete:end" , "digest:list" ] ) ;
330+ } ) ;
331+
332+ it ( "waits for pushScript before updating file digest" , async ( ) => {
333+ let releasePush ! : ( ) => void ;
334+ const pushGate = new Promise < void > ( ( resolve ) => {
335+ releasePush = resolve ;
336+ } ) ;
337+ const order : string [ ] = [ ] ;
338+ // pushScript 内部第一步是 fs.create(uuid.user.js),gate 在这里就能拦住整个 push
339+ const fsCreate = vi . fn ( ) . mockImplementation ( async ( filename : string ) => {
340+ if ( filename === "push-uuid.user.js" ) {
341+ order . push ( "push:start" ) ;
342+ await pushGate ;
343+ order . push ( "push:end" ) ;
344+ }
345+ return { write : vi . fn ( ) . mockResolvedValue ( undefined ) } ;
346+ } ) ;
347+ const fsList = vi
348+ . fn ( )
349+ . mockImplementationOnce ( async ( ) => [ ] )
350+ . mockImplementationOnce ( async ( ) => {
351+ order . push ( "digest:list" ) ;
352+ return [ ] ;
353+ } ) ;
354+ const fs = createFs ( {
355+ list : fsList ,
356+ create : fsCreate ,
357+ } ) ;
358+ const service = new SynchronizeService (
359+ { } as any ,
360+ { } as any ,
361+ { } as any ,
362+ { } as any ,
363+ { } as any ,
364+ { } as any ,
365+ { } as any ,
366+ {
367+ scriptCodeDAO : {
368+ get : vi . fn ( ) . mockResolvedValue ( { code : "// code" } ) ,
369+ } ,
370+ all : vi . fn ( ) . mockResolvedValue ( [
371+ {
372+ uuid : "push-uuid" ,
373+ name : "push" ,
374+ updatetime : 1 ,
375+ createtime : 1 ,
376+ status : 1 ,
377+ sort : 0 ,
378+ metadata : { } ,
379+ } ,
380+ ] ) ,
381+ } as any
382+ ) ;
383+
384+ const promise = service . syncOnce ( syncConfig , fs ) ;
385+ await flushMicrotasks ( ) ;
386+
387+ // push 已经开始但没结束,updateFileDigest 还没被调用
388+ expect ( order ) . toEqual ( [ "push:start" ] ) ;
389+
390+ releasePush ( ) ;
391+ await promise ;
392+
393+ // push 必须在 updateFileDigest 之前完成
394+ expect ( order ) . toContain ( "push:end" ) ;
395+ expect ( order ) . toContain ( "digest:list" ) ;
396+ expect ( order . indexOf ( "push:end" ) ) . toBeLessThan ( order . indexOf ( "digest:list" ) ) ;
397+ } ) ;
196398} ) ;
0 commit comments