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} from '../Hash';
16
17jest.mock('fs');
18jest.mock('fs/promises');
19
20describe(createFingerprintFromSourcesAsync, () => {
21  afterEach(() => {
22    vol.reset();
23  });
24
25  it('snapshot', async () => {
26    const filePath = 'assets/icon.png';
27    vol.mkdirSync('/app');
28    vol.mkdirSync('/app/assets');
29    vol.writeFileSync(path.join('/app', filePath), '{}');
30
31    const sources: HashSource[] = [
32      { type: 'contents', id: 'foo', contents: 'HelloWorld', reasons: ['foo'] },
33      { type: 'file', filePath, reasons: ['icon'] },
34    ];
35
36    expect(
37      await createFingerprintFromSourcesAsync(sources, '/app', await normalizeOptionsAsync('/app'))
38    ).toMatchInlineSnapshot(`
39      {
40        "hash": "ca7d58cd60289daa5cddcf99fcaa1d339bfc2c1a",
41        "sources": [
42          {
43            "contents": "HelloWorld",
44            "hash": "db8ac1c259eb89d4a131b253bacfca5f319d54f2",
45            "id": "foo",
46            "reasons": [
47              "foo",
48            ],
49            "type": "contents",
50          },
51          {
52            "filePath": "assets/icon.png",
53            "hash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f",
54            "reasons": [
55              "icon",
56            ],
57            "type": "file",
58          },
59        ],
60      }
61    `);
62  });
63});
64
65describe(createFingerprintSourceAsync, () => {
66  it('should merge hash value to original source', async () => {
67    const source: HashSource = {
68      type: 'contents',
69      id: 'foo',
70      contents: 'HelloWorld',
71      reasons: ['foo'],
72    };
73    const expectedResult = {
74      ...source,
75      hash: 'db8ac1c259eb89d4a131b253bacfca5f319d54f2',
76    };
77    expect(
78      await createFingerprintSourceAsync(
79        source,
80        pLimit(1),
81        '/app',
82        await normalizeOptionsAsync('/app')
83      )
84    ).toEqual(expectedResult);
85  });
86});
87
88describe(createContentsHashResultsAsync, () => {
89  it('should return {id, hex} result', async () => {
90    const id = 'foo';
91    const contents = '{}';
92    const options = await normalizeOptionsAsync('/app');
93    const result = await createContentsHashResultsAsync(
94      {
95        type: 'contents',
96        id,
97        contents,
98        reasons: [id],
99      },
100      options
101    );
102
103    const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex');
104    expect(result.id).toEqual(id);
105    expect(result.hex).toEqual(expectHex);
106  });
107});
108
109describe(createFileHashResultsAsync, () => {
110  afterEach(() => {
111    vol.reset();
112  });
113
114  it('should return {id, hex} result', async () => {
115    const filePath = 'assets/icon.png';
116    const contents = '{}';
117    const limiter = pLimit(1);
118    const options = await normalizeOptionsAsync('/app');
119    vol.mkdirSync('/app');
120    vol.mkdirSync('/app/assets');
121    vol.writeFileSync(path.join('/app', filePath), contents);
122
123    const result = await createFileHashResultsAsync(filePath, limiter, '/app', options);
124
125    const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex');
126    expect(result?.id).toEqual(filePath);
127    expect(result?.hex).toEqual(expectHex);
128  });
129
130  it('should ignore file if it is in options.ignorePaths', async () => {
131    const filePath = 'app.json';
132    const contents = '{}';
133    const limiter = pLimit(1);
134    const options = await normalizeOptionsAsync('/app');
135    options.ignorePaths = ['*.json'];
136    vol.mkdirSync('/app');
137    vol.writeFileSync(path.join('/app', filePath), contents);
138
139    const result = await createFileHashResultsAsync(filePath, limiter, '/app', options);
140    expect(result).toBe(null);
141  });
142});
143
144describe(createDirHashResultsAsync, () => {
145  afterEach(() => {
146    vol.reset();
147  });
148
149  it('should return {id, hex} result', async () => {
150    const limiter = pLimit(3);
151    const options = await normalizeOptionsAsync('/app');
152    const volJSON = {
153      '/app/ios/Podfile': '...',
154      '/app/eas.json': '{}',
155      '/app/app.json': '{}',
156      '/app/android/build.gradle': '...',
157    };
158    vol.fromJSON(volJSON);
159    const result = await createDirHashResultsAsync('.', limiter, '/app', options);
160
161    expect(result?.id).toEqual('.');
162    expect(result?.hex).not.toBe('');
163  });
164
165  it('should ignore dir if it is in options.ignorePaths', async () => {
166    const limiter = pLimit(3);
167    const options = await normalizeOptionsAsync('/app');
168    options.ignorePaths = ['ios/**/*', 'android/**/*'];
169    const volJSON = {
170      '/app/ios/Podfile': '...',
171      '/app/eas.json': '{}',
172      '/app/app.json': '{}',
173      '/app/android/build.gradle': '...',
174    };
175    vol.fromJSON(volJSON);
176
177    const fingerprint1 = await createDirHashResultsAsync('.', limiter, '/app', options);
178
179    vol.reset();
180    const volJSONIgnoreNativeProjects = {
181      '/app/eas.json': '{}',
182      '/app/app.json': '{}',
183    };
184    vol.fromJSON(volJSONIgnoreNativeProjects);
185    const fingerprint2 = await createDirHashResultsAsync('.', limiter, '/app', options);
186    expect(fingerprint1).toEqual(fingerprint2);
187  });
188
189  it('should return stable result from sorted files', async () => {
190    const limiter = pLimit(3);
191    const options = await normalizeOptionsAsync('/app');
192    const volJSON = {
193      '/app/ios/Podfile': '...',
194      '/app/eas.json': '{}',
195      '/app/app.json': '{}',
196      '/app/android/build.gradle': '...',
197    };
198    vol.fromJSON(volJSON);
199    const result = await createDirHashResultsAsync('.', limiter, '/app', options);
200
201    vol.reset();
202    const sortedVolJSON = {
203      '/app/app.json': '{}',
204      '/app/eas.json': '{}',
205      '/app/android/build.gradle': '...',
206      '/app/ios/Podfile': '...',
207    };
208    vol.fromJSON(sortedVolJSON);
209    const sortedResult = await createDirHashResultsAsync('.', limiter, '/app', options);
210
211    expect(result?.id).toEqual(sortedResult?.id);
212    expect(result?.hex).toEqual(sortedResult?.hex);
213  });
214});
215
216describe(createSourceId, () => {
217  it(`should use filePath as id for file or dir`, () => {
218    const fileSource: HashSource = {
219      type: 'file',
220      filePath: '/app/app.json',
221      reasons: ['expoConfig'],
222    };
223    expect(createSourceId(fileSource)).toBe('/app/app.json');
224
225    const dirSource: HashSource = { type: 'dir', filePath: '/app/ios', reasons: ['bareNativeDir'] };
226    expect(createSourceId(dirSource)).toBe('/app/ios');
227  });
228
229  it(`should use given id for contents`, () => {
230    const source: HashSource = {
231      type: 'contents',
232      id: 'foo',
233      contents: 'HelloWorld',
234      reasons: ['foo'],
235    };
236    expect(createSourceId(source)).toBe('foo');
237  });
238});
239