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