22// See LICENSE in the project root for license information.
33
44import { FileSystem , type FolderItem } from '@rushstack/node-core-library' ;
5- import { StringBufferTerminalProvider , Terminal } from '@rushstack/terminal' ;
5+ import { StringBufferTerminalProvider , Terminal , type ITerminal } from '@rushstack/terminal' ;
66
77import type { BuildCacheConfiguration } from '../../../api/BuildCacheConfiguration' ;
88import type { RushConfigurationProject } from '../../../api/RushConfigurationProject' ;
@@ -52,6 +52,7 @@ describe(OperationBuildCache.name, () => {
5252 packageName : 'acme-wizard' ,
5353 projectRelativeFolder : 'apps/acme-wizard' ,
5454 projectFolder : '/repo/apps/acme-wizard' ,
55+ projectRushTempFolder : '/repo/common/temp/project' ,
5556 dependencyProjects : [ ]
5657 } as unknown as RushConfigurationProject ,
5758 // Value from past tests, for consistency.
@@ -75,6 +76,106 @@ describe(OperationBuildCache.name, () => {
7576 } ) ;
7677 } ) ;
7778
79+ describe ( 'direct file cloud cache restore' , ( ) => {
80+ afterEach ( ( ) => {
81+ Reflect . set ( OperationBuildCache , '_tarUtilityPromise' , undefined ) ;
82+ jest . restoreAllMocks ( ) ;
83+ } ) ;
84+
85+ function prepareDirectTransferSubject ( cloudBuildCacheProvider : {
86+ tryDownloadCacheEntryToFileAsync : jest . Mock < Promise < boolean > , [ ITerminal , string , string ] > ;
87+ } ) : OperationBuildCache {
88+ const terminal : Terminal = new Terminal ( new StringBufferTerminalProvider ( ) ) ;
89+
90+ return OperationBuildCache . getOperationBuildCache ( {
91+ buildCacheConfiguration : {
92+ buildCacheEnabled : true ,
93+ getCacheEntryId : ( opts : IGenerateCacheEntryIdOptions ) =>
94+ `${ opts . projectName } /${ opts . projectStateHash } ` ,
95+ localCacheProvider : {
96+ getCacheEntryPath : jest . fn ( ) . mockReturnValue ( '/cache/acme-wizard-cache-entry' ) ,
97+ tryGetCacheEntryPathByIdAsync : jest . fn ( ) . mockResolvedValue ( undefined )
98+ } ,
99+ cloudCacheProvider : {
100+ isCacheWriteAllowed : false ,
101+ ...cloudBuildCacheProvider
102+ }
103+ } as unknown as BuildCacheConfiguration ,
104+ projectOutputFolderNames : [ 'dist' ] ,
105+ project : {
106+ packageName : 'acme-wizard' ,
107+ projectRelativeFolder : 'apps/acme-wizard' ,
108+ projectFolder : '/repo/apps/acme-wizard' ,
109+ projectRushTempFolder : '/repo/common/temp/project' ,
110+ dependencyProjects : [ ]
111+ } as unknown as RushConfigurationProject ,
112+ operationStateHash : '1926f30e8ed24cb47be89aea39e7efd70fcda075' ,
113+ terminal,
114+ phaseName : 'build' ,
115+ excludeAppleDoubleFiles : false ,
116+ useDirectFileTransfersForBuildCache : true
117+ } ) ;
118+ }
119+
120+ it ( 'downloads cloud cache entries to a temp file before atomically moving them into place' , async ( ) => {
121+ const tryDownloadCacheEntryToFileAsync : jest . Mock < Promise < boolean > , [ ITerminal , string , string ] > = jest
122+ . fn ( )
123+ . mockResolvedValue ( true ) ;
124+ const subject : OperationBuildCache = prepareDirectTransferSubject ( {
125+ tryDownloadCacheEntryToFileAsync
126+ } ) ;
127+ const terminal : Terminal = new Terminal ( new StringBufferTerminalProvider ( ) ) ;
128+ const tryUntarAsync : jest . Mock = jest . fn ( ) . mockResolvedValue ( 0 ) ;
129+
130+ jest . spyOn ( FileSystem , 'deleteFolderAsync' ) . mockResolvedValue ( ) ;
131+ const moveAsyncSpy : jest . SpyInstance = jest . spyOn ( FileSystem , 'moveAsync' ) . mockResolvedValue ( ) ;
132+ const deleteFileAsyncSpy : jest . SpyInstance = jest
133+ . spyOn ( FileSystem , 'deleteFileAsync' )
134+ . mockResolvedValue ( ) ;
135+ Reflect . set ( OperationBuildCache , '_tarUtilityPromise' , Promise . resolve ( { tryUntarAsync } ) ) ;
136+
137+ const result : boolean = await subject . tryRestoreFromCacheAsync ( terminal ) ;
138+
139+ expect ( result ) . toBe ( true ) ;
140+ expect ( tryDownloadCacheEntryToFileAsync ) . toHaveBeenCalledTimes ( 1 ) ;
141+ const [ , , tempPath ] : [ ITerminal , string , string ] = tryDownloadCacheEntryToFileAsync . mock . calls [ 0 ] ;
142+ expect ( tempPath ) . toMatch ( / ^ \/ c a c h e \/ a c m e - w i z a r d - c a c h e - e n t r y - [ 0 - 9 a - f ] + \. t e m p $ / ) ;
143+ expect ( moveAsyncSpy ) . toHaveBeenCalledWith ( {
144+ sourcePath : tempPath ,
145+ destinationPath : '/cache/acme-wizard-cache-entry' ,
146+ overwrite : true
147+ } ) ;
148+ expect ( tryUntarAsync ) . toHaveBeenCalledWith (
149+ expect . objectContaining ( {
150+ archivePath : '/cache/acme-wizard-cache-entry'
151+ } )
152+ ) ;
153+ expect ( deleteFileAsyncSpy ) . not . toHaveBeenCalled ( ) ;
154+ } ) ;
155+
156+ it ( 'cleans up the temp file when a direct file download misses or fails' , async ( ) => {
157+ const tryDownloadCacheEntryToFileAsync : jest . Mock < Promise < boolean > , [ ITerminal , string , string ] > = jest
158+ . fn ( )
159+ . mockResolvedValue ( false ) ;
160+ const subject : OperationBuildCache = prepareDirectTransferSubject ( {
161+ tryDownloadCacheEntryToFileAsync
162+ } ) ;
163+ const terminal : Terminal = new Terminal ( new StringBufferTerminalProvider ( ) ) ;
164+
165+ const deleteFileAsyncSpy : jest . SpyInstance = jest
166+ . spyOn ( FileSystem , 'deleteFileAsync' )
167+ . mockResolvedValue ( ) ;
168+
169+ const result : boolean = await subject . tryRestoreFromCacheAsync ( terminal ) ;
170+
171+ expect ( result ) . toBe ( false ) ;
172+ expect ( tryDownloadCacheEntryToFileAsync ) . toHaveBeenCalledTimes ( 1 ) ;
173+ const [ , , tempPath ] : [ ITerminal , string , string ] = tryDownloadCacheEntryToFileAsync . mock . calls [ 0 ] ;
174+ expect ( tempPath ) . toMatch ( / ^ \/ c a c h e \/ a c m e - w i z a r d - c a c h e - e n t r y - [ 0 - 9 a - f ] + \. t e m p $ / ) ;
175+ expect ( deleteFileAsyncSpy ) . toHaveBeenCalledWith ( tempPath ) ;
176+ } ) ;
177+ } ) ;
178+
78179 describe ( 'AppleDouble file exclusion' , ( ) => {
79180 const originalPlatform : NodeJS . Platform = process . platform ;
80181
0 commit comments