@expo/fingerprint 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +70 -0
  3. package/__mocks__/@expo/spawn-async.ts +30 -0
  4. package/__mocks__/fs/promises.ts +15 -0
  5. package/build/Fingerprint.js +1 -1
  6. package/build/Fingerprint.js.map +1 -1
  7. package/build/Fingerprint.types.d.ts +12 -1
  8. package/build/Fingerprint.types.js.map +1 -1
  9. package/build/Options.d.ts +3 -1
  10. package/build/Options.js +63 -9
  11. package/build/Options.js.map +1 -1
  12. package/build/hash/Hash.d.ts +1 -1
  13. package/build/hash/Hash.js +15 -18
  14. package/build/hash/Hash.js.map +1 -1
  15. package/build/sourcer/Expo.js +42 -44
  16. package/build/sourcer/Expo.js.map +1 -1
  17. package/build/sourcer/ExpoConfigLoader.d.ts +7 -0
  18. package/build/sourcer/ExpoConfigLoader.js +80 -0
  19. package/build/sourcer/ExpoConfigLoader.js.map +1 -0
  20. package/build/sourcer/Utils.d.ts +4 -0
  21. package/build/sourcer/Utils.js +38 -1
  22. package/build/sourcer/Utils.js.map +1 -1
  23. package/build/utils/Path.d.ts +5 -0
  24. package/build/utils/Path.js +26 -0
  25. package/build/utils/Path.js.map +1 -0
  26. package/e2e/__tests__/__snapshots__/managed-test.ts.snap +3 -2
  27. package/e2e/__tests__/bare-test.ts +7 -0
  28. package/e2e/__tests__/managed-test.ts +13 -3
  29. package/package.json +3 -3
  30. package/src/Fingerprint.ts +2 -2
  31. package/src/Fingerprint.types.ts +13 -1
  32. package/src/Options.ts +70 -7
  33. package/src/__tests__/Fingerprint-test.ts +43 -17
  34. package/src/hash/Hash.ts +20 -21
  35. package/src/hash/__tests__/Hash-test.ts +62 -16
  36. package/src/sourcer/Expo.ts +48 -55
  37. package/src/sourcer/ExpoConfigLoader.ts +84 -0
  38. package/src/sourcer/Utils.ts +39 -0
  39. package/src/sourcer/__tests__/Bare-test.ts +11 -5
  40. package/src/sourcer/__tests__/Expo-test.ts +88 -109
  41. package/src/sourcer/__tests__/PatchPackage-test.ts +6 -3
  42. package/src/sourcer/__tests__/Sourcer-test.ts +9 -2
  43. package/src/sourcer/__tests__/Utils-test.ts +41 -0
  44. package/src/utils/Path.ts +26 -0
  45. package/src/utils/__tests__/Path-test.ts +36 -0
@@ -4,7 +4,7 @@ import pLimit from 'p-limit';
4
4
  import path from 'path';
5
5
 
6
6
  import { HashSource } from '../../Fingerprint.types';
7
- import { normalizeOptions } from '../../Options';
7
+ import { normalizeOptionsAsync } from '../../Options';
8
8
  import {
9
9
  createContentsHashResultsAsync,
10
10
  createDirHashResultsAsync,
@@ -23,18 +23,21 @@ describe(createFingerprintFromSourcesAsync, () => {
23
23
  });
24
24
 
25
25
  it('snapshot', async () => {
26
+ const filePath = 'assets/icon.png';
26
27
  vol.mkdirSync('/app');
27
- vol.writeFileSync(path.join('/app', 'app.json'), '{}');
28
+ vol.mkdirSync('/app/assets');
29
+ vol.writeFileSync(path.join('/app', filePath), '{}');
28
30
 
29
31
  const sources: HashSource[] = [
30
32
  { type: 'contents', id: 'foo', contents: 'HelloWorld', reasons: ['foo'] },
31
- { type: 'file', filePath: 'app.json', reasons: ['expoConfig'] },
33
+ { type: 'file', filePath, reasons: ['icon'] },
32
34
  ];
33
35
 
34
- expect(await createFingerprintFromSourcesAsync(sources, '/app', normalizeOptions()))
35
- .toMatchInlineSnapshot(`
36
+ expect(
37
+ await createFingerprintFromSourcesAsync(sources, '/app', await normalizeOptionsAsync('/app'))
38
+ ).toMatchInlineSnapshot(`
36
39
  {
37
- "hash": "ec7d81780f735d5e289b27cdcc04a6c99d2621dc",
40
+ "hash": "ca7d58cd60289daa5cddcf99fcaa1d339bfc2c1a",
38
41
  "sources": [
39
42
  {
40
43
  "contents": "HelloWorld",
@@ -46,10 +49,10 @@ describe(createFingerprintFromSourcesAsync, () => {
46
49
  "type": "contents",
47
50
  },
48
51
  {
49
- "filePath": "app.json",
52
+ "filePath": "assets/icon.png",
50
53
  "hash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f",
51
54
  "reasons": [
52
- "expoConfig",
55
+ "icon",
53
56
  ],
54
57
  "type": "file",
55
58
  },
@@ -72,7 +75,12 @@ describe(createFingerprintSourceAsync, () => {
72
75
  hash: 'db8ac1c259eb89d4a131b253bacfca5f319d54f2',
73
76
  };
74
77
  expect(
75
- await createFingerprintSourceAsync(source, pLimit(1), '/app', normalizeOptions())
78
+ await createFingerprintSourceAsync(
79
+ source,
80
+ pLimit(1),
81
+ '/app',
82
+ await normalizeOptionsAsync('/app')
83
+ )
76
84
  ).toEqual(expectedResult);
77
85
  });
78
86
  });
@@ -81,7 +89,7 @@ describe(createContentsHashResultsAsync, () => {
81
89
  it('should return {id, hex} result', async () => {
82
90
  const id = 'foo';
83
91
  const contents = '{}';
84
- const options = normalizeOptions();
92
+ const options = await normalizeOptionsAsync('/app');
85
93
  const result = await createContentsHashResultsAsync(
86
94
  {
87
95
  type: 'contents',
@@ -104,18 +112,32 @@ describe(createFileHashResultsAsync, () => {
104
112
  });
105
113
 
106
114
  it('should return {id, hex} result', async () => {
107
- const filePath = 'app.json';
115
+ const filePath = 'assets/icon.png';
108
116
  const contents = '{}';
109
117
  const limiter = pLimit(1);
110
- const options = normalizeOptions();
118
+ const options = await normalizeOptionsAsync('/app');
111
119
  vol.mkdirSync('/app');
120
+ vol.mkdirSync('/app/assets');
112
121
  vol.writeFileSync(path.join('/app', filePath), contents);
113
122
 
114
123
  const result = await createFileHashResultsAsync(filePath, limiter, '/app', options);
115
124
 
116
125
  const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex');
117
- expect(result.id).toEqual(filePath);
118
- expect(result.hex).toEqual(expectHex);
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);
119
141
  });
120
142
  });
121
143
 
@@ -126,7 +148,7 @@ describe(createDirHashResultsAsync, () => {
126
148
 
127
149
  it('should return {id, hex} result', async () => {
128
150
  const limiter = pLimit(3);
129
- const options = normalizeOptions();
151
+ const options = await normalizeOptionsAsync('/app');
130
152
  const volJSON = {
131
153
  '/app/ios/Podfile': '...',
132
154
  '/app/eas.json': '{}',
@@ -140,9 +162,33 @@ describe(createDirHashResultsAsync, () => {
140
162
  expect(result?.hex).not.toBe('');
141
163
  });
142
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
+
143
189
  it('should return stable result from sorted files', async () => {
144
190
  const limiter = pLimit(3);
145
- const options = normalizeOptions();
191
+ const options = await normalizeOptionsAsync('/app');
146
192
  const volJSON = {
147
193
  '/app/ios/Podfile': '...',
148
194
  '/app/eas.json': '{}',
@@ -1,12 +1,13 @@
1
1
  import spawnAsync from '@expo/spawn-async';
2
- import assert from 'assert';
3
2
  import chalk from 'chalk';
4
3
  import type { ExpoConfig, ProjectConfig } from 'expo/config';
5
- import findUp from 'find-up';
4
+ import fs from 'fs/promises';
5
+ import os from 'os';
6
6
  import path from 'path';
7
7
  import resolveFrom from 'resolve-from';
8
8
 
9
- import { getFileBasedHashSourceAsync } from './Utils';
9
+ import { getExpoConfigLoaderPath } from './ExpoConfigLoader';
10
+ import { getFileBasedHashSourceAsync, stringifyJsonSorted } from './Utils';
10
11
  import type { HashSource, NormalizedOptions } from '../Fingerprint.types';
11
12
 
12
13
  const debug = require('debug')('expo:fingerprint:sourcer:Expo');
@@ -15,32 +16,36 @@ export async function getExpoConfigSourcesAsync(
15
16
  projectRoot: string,
16
17
  options: NormalizedOptions
17
18
  ): Promise<HashSource[]> {
19
+ if (!resolveFrom.silent(path.resolve(projectRoot), 'expo/config')) {
20
+ return [];
21
+ }
22
+
23
+ const results: HashSource[] = [];
18
24
  let config: ProjectConfig;
25
+ let loadedModules: string[] = [];
26
+ const ignoredFile = await createTempIgnoredFileAsync(options);
19
27
  try {
20
- const { getConfig } = require(resolveFrom(path.resolve(projectRoot), 'expo/config'));
21
- config = await getConfig(projectRoot, { skipSDKVersionRequirement: true });
28
+ const { stdout } = await spawnAsync(
29
+ 'node',
30
+ [getExpoConfigLoaderPath(), path.resolve(projectRoot), ignoredFile],
31
+ { cwd: __dirname }
32
+ );
33
+ const stdoutJson = JSON.parse(stdout);
34
+ config = stdoutJson.config;
35
+ loadedModules = stdoutJson.loadedModules;
36
+ results.push({
37
+ type: 'contents',
38
+ id: 'expoConfig',
39
+ contents: normalizeExpoConfig(config.exp),
40
+ reasons: ['expoConfig'],
41
+ });
22
42
  } catch (e: unknown) {
23
- debug('Cannot get Expo config: ' + e);
43
+ if (e instanceof Error) {
44
+ console.warn(`Cannot get Expo config from an Expo project - ${e.message}: `, e.stack);
45
+ }
24
46
  return [];
25
47
  }
26
48
 
27
- const results: HashSource[] = [];
28
-
29
- // app config files
30
- const configFiles = ['app.config.ts', 'app.config.js', 'app.config.json', 'app.json'];
31
- const configFileSources = (
32
- await Promise.all(
33
- configFiles.map(async (file) => {
34
- const result = await getFileBasedHashSourceAsync(projectRoot, file, 'expoConfig');
35
- if (result != null) {
36
- debug(`Adding config file - ${chalk.dim(file)}`);
37
- }
38
- return result;
39
- })
40
- )
41
- ).filter(Boolean) as HashSource[];
42
- results.push(...configFileSources);
43
-
44
49
  // external files in config
45
50
  const isAndroid = options.platforms.includes('android');
46
51
  const isIos = options.platforms.includes('ios');
@@ -86,44 +91,32 @@ export async function getExpoConfigSourcesAsync(
86
91
  results.push(...externalFileSources);
87
92
 
88
93
  // config plugins
89
- const configPluginSources = getConfigPluginSourcesAsync(projectRoot, config.exp.plugins);
90
- results.push(...configPluginSources);
94
+ const configPluginModules: HashSource[] = loadedModules.map((modulePath) => ({
95
+ type: 'file',
96
+ filePath: modulePath,
97
+ reasons: ['expoConfigPlugins'],
98
+ }));
99
+ results.push(...configPluginModules);
91
100
 
92
101
  return results;
93
102
  }
94
103
 
95
- function findUpPluginRoot(entryFile: string): string {
96
- const entryRoot = path.dirname(entryFile);
97
- const packageJson = findUp.sync('package.json', { cwd: path.dirname(entryFile) });
98
- assert(packageJson, `No package.json found for module "${entryRoot}"`);
99
- return path.dirname(packageJson);
104
+ function normalizeExpoConfig(config: ExpoConfig): string {
105
+ // Deep clone by JSON.parse/stringify that assumes the config is serializable.
106
+ const normalizedConfig: ExpoConfig = JSON.parse(JSON.stringify(config));
107
+ delete normalizedConfig.runtimeVersion;
108
+ delete normalizedConfig._internal;
109
+ return stringifyJsonSorted(normalizedConfig);
100
110
  }
101
111
 
102
- function getConfigPluginSourcesAsync(
103
- projectRoot: string,
104
- plugins: ExpoConfig['plugins']
105
- ): HashSource[] {
106
- if (plugins == null) {
107
- return [];
108
- }
109
-
110
- const reasons = ['expoConfigPlugins'];
111
- const nullableResults: (HashSource | null)[] = plugins.map((plugin) => {
112
- const pluginPackageName = Array.isArray(plugin) ? plugin[0] : plugin;
113
- if (typeof pluginPackageName === 'string') {
114
- const pluginPackageEntryFile = resolveFrom.silent(projectRoot, pluginPackageName);
115
- const pluginPackageRoot = pluginPackageEntryFile
116
- ? findUpPluginRoot(pluginPackageEntryFile)
117
- : null;
118
- if (pluginPackageRoot) {
119
- debug(`Adding config-plugin root - ${chalk.dim(pluginPackageRoot)}`);
120
- return { type: 'dir', filePath: path.relative(projectRoot, pluginPackageRoot), reasons };
121
- }
122
- }
123
- return null;
124
- });
125
- const results = nullableResults.filter(Boolean) as HashSource[];
126
- return results;
112
+ /**
113
+ * Create a temporary file with ignored paths from options that will be read by the ExpoConfigLoader.
114
+ */
115
+ async function createTempIgnoredFileAsync(options: NormalizedOptions): Promise<string> {
116
+ await fs.mkdtemp(path.join(os.tmpdir(), 'expo-fingerprint-'));
117
+ const ignoredFile = path.join(os.tmpdir(), '.fingerprintignore');
118
+ await fs.writeFile(ignoredFile, options.ignorePaths.join('\n'));
119
+ return ignoredFile;
127
120
  }
128
121
 
129
122
  export async function getEasBuildSourcesAsync(projectRoot: string, options: NormalizedOptions) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A helper script to load the Expo config and loaded plugins from a project
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import module from 'module';
7
+ import path from 'path';
8
+ import resolveFrom from 'resolve-from';
9
+
10
+ import { DEFAULT_IGNORE_PATHS } from '../Options';
11
+ import { isIgnoredPath } from '../utils/Path';
12
+
13
+ async function runAsync(programName: string, args: string[] = []) {
14
+ if (args.length < 1) {
15
+ console.log(`Usage: ${programName} <projectRoot> [ignoredFile]`);
16
+ return;
17
+ }
18
+
19
+ const projectRoot = path.resolve(args[0]);
20
+ const ignoredFile = args[1] ? path.resolve(args[1]) : null;
21
+
22
+ // @ts-expect-error: module internal _cache
23
+ const loadedModulesBefore = new Set(Object.keys(module._cache));
24
+
25
+ const { getConfig } = require(resolveFrom(path.resolve(projectRoot), 'expo/config'));
26
+ const config = await getConfig(projectRoot, { skipSDKVersionRequirement: true });
27
+ // @ts-expect-error: module internal _cache
28
+ const loadedModules = Object.keys(module._cache)
29
+ .filter((modulePath) => !loadedModulesBefore.has(modulePath))
30
+ .map((modulePath) => path.relative(projectRoot, modulePath));
31
+
32
+ const ignoredPaths = await loadIgnoredPathsAsync(ignoredFile);
33
+ const filteredLoadedModules = loadedModules.filter(
34
+ (modulePath) => !isIgnoredPath(modulePath, ignoredPaths)
35
+ );
36
+ console.log(JSON.stringify({ config, loadedModules: filteredLoadedModules }));
37
+ }
38
+
39
+ // If running from the command line
40
+ if (require.main?.filename === __filename) {
41
+ (async () => {
42
+ const programIndex = process.argv.findIndex((arg) => arg === __filename);
43
+ try {
44
+ await runAsync(process.argv[programIndex], process.argv.slice(programIndex + 1));
45
+ } catch (e) {
46
+ console.error('Uncaught Error', e);
47
+ process.exit(1);
48
+ }
49
+ })();
50
+ }
51
+
52
+ /**
53
+ * Load the generated ignored paths file from caller and remove the file after loading
54
+ */
55
+ async function loadIgnoredPathsAsync(ignoredFile: string | null) {
56
+ if (!ignoredFile) {
57
+ return DEFAULT_IGNORE_PATHS;
58
+ }
59
+
60
+ const ignorePaths = [];
61
+ try {
62
+ const fingerprintIgnore = await fs.readFile(ignoredFile, 'utf8');
63
+ const fingerprintIgnoreLines = fingerprintIgnore.split('\n');
64
+ for (const line of fingerprintIgnoreLines) {
65
+ const trimmedLine = line.trim();
66
+ if (trimmedLine) {
67
+ ignorePaths.push(trimmedLine);
68
+ }
69
+ }
70
+ } catch {}
71
+
72
+ try {
73
+ await fs.rm(ignoredFile);
74
+ } catch {}
75
+
76
+ return ignorePaths;
77
+ }
78
+
79
+ /**
80
+ * Get the path to the ExpoConfigLoader file.
81
+ */
82
+ export function getExpoConfigLoaderPath() {
83
+ return path.join(__dirname, 'ExpoConfigLoader.js');
84
+ }
@@ -21,3 +21,42 @@ export async function getFileBasedHashSourceAsync(
21
21
  }
22
22
  return result;
23
23
  }
24
+
25
+ /**
26
+ * A version of `JSON.stringify` that keeps the keys sorted
27
+ */
28
+ export function stringifyJsonSorted(target: any, space?: string | number | undefined): string {
29
+ return JSON.stringify(target, (_, value) => sortJson(value), space);
30
+ }
31
+
32
+ function sortJson(json: any): any {
33
+ if (Array.isArray(json)) {
34
+ return json.sort((a, b) => {
35
+ // Sort array items by their stringified value.
36
+ // We don't need the array to be sorted in meaningful way, just to be sorted in deterministic.
37
+ // E.g. `[{ b: '2' }, {}, { a: '3' }, null]` -> `[null, { a : '3' }, { b: '2' }, {}]`
38
+ // This result is not a perfect solution, but it's good enough for our use case.
39
+ const stringifiedA = stringifyJsonSorted(a);
40
+ const stringifiedB = stringifyJsonSorted(b);
41
+ if (stringifiedA < stringifiedB) {
42
+ return -1;
43
+ } else if (stringifiedA > stringifiedB) {
44
+ return 1;
45
+ }
46
+ return 0;
47
+ });
48
+ }
49
+
50
+ if (json != null && typeof json === 'object') {
51
+ // Sort object items by keys
52
+ return Object.keys(json)
53
+ .sort()
54
+ .reduce((acc: any, key: string) => {
55
+ acc[key] = json[key];
56
+ return acc;
57
+ }, {});
58
+ }
59
+
60
+ // Return primitives
61
+ return json;
62
+ }
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import { vol } from 'memfs';
4
4
  import path from 'path';
5
5
 
6
- import { normalizeOptions } from '../../Options';
6
+ import { normalizeOptionsAsync } from '../../Options';
7
7
  import {
8
8
  getBareAndroidSourcesAsync,
9
9
  getBareIosSourcesAsync,
@@ -21,10 +21,10 @@ describe('getBareSourcesAsync', () => {
21
21
 
22
22
  it('should contain android and ios folders in bare react-native project', async () => {
23
23
  vol.fromJSON(require('./fixtures/BareReactNative70Project.json'));
24
- let sources = await getBareAndroidSourcesAsync('/app', normalizeOptions());
24
+ let sources = await getBareAndroidSourcesAsync('/app', await normalizeOptionsAsync('/app'));
25
25
  expect(sources).toContainEqual(expect.objectContaining({ filePath: 'android', type: 'dir' }));
26
26
 
27
- sources = await getBareIosSourcesAsync('/app', normalizeOptions());
27
+ sources = await getBareIosSourcesAsync('/app', await normalizeOptionsAsync('/app'));
28
28
  expect(sources).toContainEqual(expect.objectContaining({ filePath: 'ios', type: 'dir' }));
29
29
  });
30
30
  });
@@ -47,7 +47,10 @@ describe(getRncliAutolinkingSourcesAsync, () => {
47
47
  signal: null,
48
48
  output: [fixture, ''],
49
49
  });
50
- const sources = await getRncliAutolinkingSourcesAsync('/root/apps/demo', normalizeOptions());
50
+ const sources = await getRncliAutolinkingSourcesAsync(
51
+ '/root/apps/demo',
52
+ await normalizeOptionsAsync('/app')
53
+ );
51
54
  expect(sources).toContainEqual(
52
55
  expect.objectContaining({
53
56
  type: 'dir',
@@ -70,7 +73,10 @@ describe(getRncliAutolinkingSourcesAsync, () => {
70
73
  signal: null,
71
74
  output: [fixture, ''],
72
75
  });
73
- const sources = await getRncliAutolinkingSourcesAsync('/root/apps/demo', normalizeOptions());
76
+ const sources = await getRncliAutolinkingSourcesAsync(
77
+ '/root/apps/demo',
78
+ await normalizeOptionsAsync('/app')
79
+ );
74
80
  for (const source of sources) {
75
81
  if (source.type === 'dir' || source.type === 'file') {
76
82
  expect(source.filePath).not.toMatch(/^\/root/);