1import { createHash } from 'crypto'; 2import { vol } from 'memfs'; 3import pLimit from 'p-limit'; 4import path from 'path'; 5 6import { HashSource } from '../../Fingerprint.types'; 7import { normalizeOptionsAsync } from '../../Options'; 8import { 9 createContentsHashResultsAsync, 10 createDirHashResultsAsync, 11 createFileHashResultsAsync, 12 createFingerprintFromSourcesAsync, 13 createFingerprintSourceAsync, 14 createSourceId, 15 isIgnoredPath, 16} from '../Hash'; 17 18jest.mock('fs'); 19jest.mock('fs/promises'); 20 21describe(createFingerprintFromSourcesAsync, () => { 22 afterEach(() => { 23 vol.reset(); 24 }); 25 26 it('snapshot', async () => { 27 const filePath = 'assets/icon.png'; 28 vol.mkdirSync('/app'); 29 vol.mkdirSync('/app/assets'); 30 vol.writeFileSync(path.join('/app', filePath), '{}'); 31 32 const sources: HashSource[] = [ 33 { type: 'contents', id: 'foo', contents: 'HelloWorld', reasons: ['foo'] }, 34 { type: 'file', filePath, reasons: ['icon'] }, 35 ]; 36 37 expect( 38 await createFingerprintFromSourcesAsync(sources, '/app', await normalizeOptionsAsync('/app')) 39 ).toMatchInlineSnapshot(` 40 { 41 "hash": "ca7d58cd60289daa5cddcf99fcaa1d339bfc2c1a", 42 "sources": [ 43 { 44 "contents": "HelloWorld", 45 "hash": "db8ac1c259eb89d4a131b253bacfca5f319d54f2", 46 "id": "foo", 47 "reasons": [ 48 "foo", 49 ], 50 "type": "contents", 51 }, 52 { 53 "filePath": "assets/icon.png", 54 "hash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f", 55 "reasons": [ 56 "icon", 57 ], 58 "type": "file", 59 }, 60 ], 61 } 62 `); 63 }); 64}); 65 66describe(createFingerprintSourceAsync, () => { 67 it('should merge hash value to original source', async () => { 68 const source: HashSource = { 69 type: 'contents', 70 id: 'foo', 71 contents: 'HelloWorld', 72 reasons: ['foo'], 73 }; 74 const expectedResult = { 75 ...source, 76 hash: 'db8ac1c259eb89d4a131b253bacfca5f319d54f2', 77 }; 78 expect( 79 await createFingerprintSourceAsync( 80 source, 81 pLimit(1), 82 '/app', 83 await normalizeOptionsAsync('/app') 84 ) 85 ).toEqual(expectedResult); 86 }); 87}); 88 89describe(createContentsHashResultsAsync, () => { 90 it('should return {id, hex} result', async () => { 91 const id = 'foo'; 92 const contents = '{}'; 93 const options = await normalizeOptionsAsync('/app'); 94 const result = await createContentsHashResultsAsync( 95 { 96 type: 'contents', 97 id, 98 contents, 99 reasons: [id], 100 }, 101 options 102 ); 103 104 const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex'); 105 expect(result.id).toEqual(id); 106 expect(result.hex).toEqual(expectHex); 107 }); 108}); 109 110describe(createFileHashResultsAsync, () => { 111 afterEach(() => { 112 vol.reset(); 113 }); 114 115 it('should return {id, hex} result', async () => { 116 const filePath = 'assets/icon.png'; 117 const contents = '{}'; 118 const limiter = pLimit(1); 119 const options = await normalizeOptionsAsync('/app'); 120 vol.mkdirSync('/app'); 121 vol.mkdirSync('/app/assets'); 122 vol.writeFileSync(path.join('/app', filePath), contents); 123 124 const result = await createFileHashResultsAsync(filePath, limiter, '/app', options); 125 126 const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex'); 127 expect(result?.id).toEqual(filePath); 128 expect(result?.hex).toEqual(expectHex); 129 }); 130 131 it('should ignore file if it is in options.ignorePaths', async () => { 132 const filePath = 'app.json'; 133 const contents = '{}'; 134 const limiter = pLimit(1); 135 const options = await normalizeOptionsAsync('/app'); 136 options.ignorePaths = ['*.json']; 137 vol.mkdirSync('/app'); 138 vol.writeFileSync(path.join('/app', filePath), contents); 139 140 const result = await createFileHashResultsAsync(filePath, limiter, '/app', options); 141 expect(result).toBe(null); 142 }); 143}); 144 145describe(createDirHashResultsAsync, () => { 146 afterEach(() => { 147 vol.reset(); 148 }); 149 150 it('should return {id, hex} result', async () => { 151 const limiter = pLimit(3); 152 const options = await normalizeOptionsAsync('/app'); 153 const volJSON = { 154 '/app/ios/Podfile': '...', 155 '/app/eas.json': '{}', 156 '/app/app.json': '{}', 157 '/app/android/build.gradle': '...', 158 }; 159 vol.fromJSON(volJSON); 160 const result = await createDirHashResultsAsync('.', limiter, '/app', options); 161 162 expect(result?.id).toEqual('.'); 163 expect(result?.hex).not.toBe(''); 164 }); 165 166 it('should ignore dir if it is in options.ignorePaths', async () => { 167 const limiter = pLimit(3); 168 const options = await normalizeOptionsAsync('/app'); 169 options.ignorePaths = ['ios/**/*', 'android/**/*']; 170 const volJSON = { 171 '/app/ios/Podfile': '...', 172 '/app/eas.json': '{}', 173 '/app/app.json': '{}', 174 '/app/android/build.gradle': '...', 175 }; 176 vol.fromJSON(volJSON); 177 178 const fingerprint1 = await createDirHashResultsAsync('.', limiter, '/app', options); 179 180 vol.reset(); 181 const volJSONIgnoreNativeProjects = { 182 '/app/eas.json': '{}', 183 '/app/app.json': '{}', 184 }; 185 vol.fromJSON(volJSONIgnoreNativeProjects); 186 const fingerprint2 = await createDirHashResultsAsync('.', limiter, '/app', options); 187 expect(fingerprint1).toEqual(fingerprint2); 188 }); 189 190 it('should return stable result from sorted files', async () => { 191 const limiter = pLimit(3); 192 const options = await normalizeOptionsAsync('/app'); 193 const volJSON = { 194 '/app/ios/Podfile': '...', 195 '/app/eas.json': '{}', 196 '/app/app.json': '{}', 197 '/app/android/build.gradle': '...', 198 }; 199 vol.fromJSON(volJSON); 200 const result = await createDirHashResultsAsync('.', limiter, '/app', options); 201 202 vol.reset(); 203 const sortedVolJSON = { 204 '/app/app.json': '{}', 205 '/app/eas.json': '{}', 206 '/app/android/build.gradle': '...', 207 '/app/ios/Podfile': '...', 208 }; 209 vol.fromJSON(sortedVolJSON); 210 const sortedResult = await createDirHashResultsAsync('.', limiter, '/app', options); 211 212 expect(result?.id).toEqual(sortedResult?.id); 213 expect(result?.hex).toEqual(sortedResult?.hex); 214 }); 215}); 216 217describe(createSourceId, () => { 218 it(`should use filePath as id for file or dir`, () => { 219 const fileSource: HashSource = { 220 type: 'file', 221 filePath: '/app/app.json', 222 reasons: ['expoConfig'], 223 }; 224 expect(createSourceId(fileSource)).toBe('/app/app.json'); 225 226 const dirSource: HashSource = { type: 'dir', filePath: '/app/ios', reasons: ['bareNativeDir'] }; 227 expect(createSourceId(dirSource)).toBe('/app/ios'); 228 }); 229 230 it(`should use given id for contents`, () => { 231 const source: HashSource = { 232 type: 'contents', 233 id: 'foo', 234 contents: 'HelloWorld', 235 reasons: ['foo'], 236 }; 237 expect(createSourceId(source)).toBe('foo'); 238 }); 239}); 240 241describe(isIgnoredPath, () => { 242 it('should support file pattern', () => { 243 expect(isIgnoredPath('app.json', ['app.json'])).toBe(true); 244 expect(isIgnoredPath('app.ts', ['*.{js,ts}'])).toBe(true); 245 expect(isIgnoredPath('/dir/app.json', ['/dir/*.json'])).toBe(true); 246 }); 247 248 it('should support directory pattern', () => { 249 expect(isIgnoredPath('/app/ios/Podfile', ['**/ios/**/*'])).toBe(true); 250 }); 251 252 it('case sensitive by design', () => { 253 expect(isIgnoredPath('app.json', ['APP.JSON'])).toBe(false); 254 }); 255 256 it('should include dot files from wildcard pattern', () => { 257 expect(isIgnoredPath('.bashrc', ['*'])).toBe(true); 258 }); 259 260 it('no `matchBase` and `partial` by design', () => { 261 expect(isIgnoredPath('/dir/app.json', ['app.json'])).toBe(false); 262 }); 263 264 it('match a file inside a dir should use a globstar', () => { 265 expect(isIgnoredPath('/dir/app.ts', ['*'])).toBe(false); 266 expect(isIgnoredPath('/dir/app.ts', ['**/*'])).toBe(true); 267 }); 268 269 it('should use `!` to override default ignorePaths', () => { 270 const ignorePaths = ['**/ios/**/*', '!**/ios/Podfile', '**/android/**/*']; 271 expect(isIgnoredPath('/app/ios/Podfile', ignorePaths)).toBe(false); 272 expect(isIgnoredPath('/app/ios/Podfile.lock', ignorePaths)).toBe(true); 273 }); 274}); 275