Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Env } from '@expo/eas-build-job';
import path from 'path';
import resolveFrom from 'resolve-from';

import { createMockLogger } from '../../__tests__/utils/logger';
import { configureExpoTransitiveDependenciesNodePathAsync } from '../expoTransitiveDependenciesNodePath';

jest.mock('resolve-from', () => ({
__esModule: true,
default: Object.assign(jest.fn(), {
silent: jest.fn(),
}),
}));

const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
silent: jest.MockedFunction<typeof resolveFrom.silent>;
};

describe(configureExpoTransitiveDependenciesNodePathAsync, () => {
beforeEach(() => {
mockedResolveFrom.silent.mockReset();
});

it('extends NODE_PATH with Expo transitive dependency locations', async () => {
const projectDir = '/workingdir/build/apps/app';
const packagerDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/.pnpm/expo@52/node_modules/expo';
const expoNodeModulesDir = path.join(expoPackageDir, 'node_modules');
const env: Env = { NODE_PATH: '/custom/node_modules' };
const logger = createMockLogger();

mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(expoPackageDir, 'babel-preset-expo/package.json')]: path.join(
expoNodeModulesDir,
'babel-preset-expo',
'package.json'
),
[resolutionKey(expoPackageDir, 'expo-asset/package.json')]: path.join(
expoNodeModulesDir,
'expo-asset',
'package.json'
),
});

await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir,
env,
logger,
});

expect(env.NODE_PATH).toBe(['/custom/node_modules', expoNodeModulesDir].join(path.delimiter));
expect(logger.info).toHaveBeenCalledWith(
`Extending NODE_PATH with Expo dependency paths: ${expoNodeModulesDir}`
);
});

it('does not update NODE_PATH when expo is not installed', async () => {
const env: Env = { NODE_PATH: '/custom/node_modules' };
const logger = createMockLogger();

await configureExpoTransitiveDependenciesNodePathAsync({
projectDir: '/workingdir/build',
packagerDir: '/workingdir/build',
env,
logger,
});

expect(env.NODE_PATH).toBe('/custom/node_modules');
expect(logger.info).not.toHaveBeenCalled();
});

it('does not update NODE_PATH when transitive dependencies already resolve from the project', async () => {
const projectDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/expo';
const env: Env = {};
const logger = createMockLogger();

mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(projectDir, 'babel-preset-expo/package.json')]:
'/workingdir/build/node_modules/babel-preset-expo/package.json',
[resolutionKey(projectDir, 'expo-asset/package.json')]:
'/workingdir/build/node_modules/expo-asset/package.json',
});

await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir: projectDir,
env,
logger,
});

expect(env.NODE_PATH).toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
});

it('skips transitive dependencies that do not resolve from expo', async () => {
const projectDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/.pnpm/expo@52/node_modules/expo';
const expoNodeModulesDir = path.join(expoPackageDir, 'node_modules');
const env: Env = {};
const logger = createMockLogger();

mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(expoPackageDir, 'babel-preset-expo/package.json')]: path.join(
expoNodeModulesDir,
'babel-preset-expo',
'package.json'
),
});

await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir: projectDir,
env,
logger,
});

expect(env.NODE_PATH).toBe(expoNodeModulesDir);
});

it('does not duplicate existing NODE_PATH entries', async () => {
const projectDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/.pnpm/expo@52/node_modules/expo';
const expoNodeModulesDir = path.join(expoPackageDir, 'node_modules');
const env: Env = { NODE_PATH: expoNodeModulesDir };
const logger = createMockLogger();

mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(expoPackageDir, 'babel-preset-expo/package.json')]: path.join(
expoNodeModulesDir,
'babel-preset-expo',
'package.json'
),
[resolutionKey(expoPackageDir, 'expo-asset/package.json')]: path.join(
expoNodeModulesDir,
'expo-asset',
'package.json'
),
});

await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir: projectDir,
env,
logger,
});

expect(env.NODE_PATH).toBe(expoNodeModulesDir);
expect(logger.info).not.toHaveBeenCalled();
});
});

function mockResolveFrom(resolutions: Record<string, string>): void {
mockedResolveFrom.silent.mockImplementation((fromDir, moduleId) => {
return resolutions[resolutionKey(fromDir, moduleId)];
});
}

function resolutionKey(fromDir: string, moduleId: string): string {
return `${fromDir}:${moduleId}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Env } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import path from 'path';
import resolveFrom from 'resolve-from';

const EXPO_TRANSITIVE_DEPENDENCIES_TO_RESOLVE = ['babel-preset-expo', 'expo-asset'];

export async function configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir,
env,
logger,
}: {
projectDir: string;
packagerDir: string;
env: Env;
logger: bunyan;
}): Promise<void> {
const projectResolutionRoots = uniqueItems([projectDir, packagerDir]);
const expoPackageJsonPath = resolvePackageJson(projectResolutionRoots, 'expo');
if (!expoPackageJsonPath) {
return;
}

const expoResolutionRoots = await getPackageResolutionRootsAsync(expoPackageJsonPath);
const nodePathEntriesToAdd = new Set<string>();

for (const packageName of EXPO_TRANSITIVE_DEPENDENCIES_TO_RESOLVE) {
if (resolvePackageJson(projectResolutionRoots, packageName)) {
continue;
}

const packageJsonPath = resolvePackageJson(expoResolutionRoots, packageName);
if (!packageJsonPath) {
continue;
}

nodePathEntriesToAdd.add(getNodeModulesDirForPackage(packageJsonPath));
}

if (nodePathEntriesToAdd.size === 0) {
return;
}

const existingNodePathEntries = splitNodePath(env.NODE_PATH);
const addedNodePathEntries = [...nodePathEntriesToAdd].filter(
nodePathEntry => !existingNodePathEntries.includes(nodePathEntry)
);
if (addedNodePathEntries.length === 0) {
return;
}

env.NODE_PATH = [...existingNodePathEntries, ...addedNodePathEntries].join(path.delimiter);
logger.info(
`Extending NODE_PATH with Expo dependency paths: ${addedNodePathEntries.join(path.delimiter)}`
);
}

function resolvePackageJson(fromDirs: string[], packageName: string): string | null {
for (const fromDir of fromDirs) {
const packageJsonPath = resolveFrom.silent(fromDir, `${packageName}/package.json`);
if (packageJsonPath) {
return packageJsonPath;
}
}
return null;
}

async function getPackageResolutionRootsAsync(packageJsonPath: string): Promise<string[]> {
const packageDir = path.dirname(packageJsonPath);
try {
return uniqueItems([packageDir, await fs.realpath(packageDir)]);
} catch {
return [packageDir];
}
}

function getNodeModulesDirForPackage(packageJsonPath: string): string {
const packageDir = path.dirname(packageJsonPath);
return path.dirname(packageDir);
}

function splitNodePath(nodePath: string | undefined): string[] {
return nodePath?.split(path.delimiter).filter(Boolean) ?? [];
}

function uniqueItems<T>(items: T[]): T[] {
return [...new Set(items)];
}
13 changes: 12 additions & 1 deletion packages/build-tools/src/common/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import nullthrows from 'nullthrows';
import path from 'path';

import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from './easBuildInternal';
import { configureExpoTransitiveDependenciesNodePathAsync } from './expoTransitiveDependenciesNodePath';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import { prepareProjectSourcesAsync } from './projectSources';
import { BuildContext } from '../context';
Expand Down Expand Up @@ -86,6 +87,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});

await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
const packagerDir = resolvePackagerDir(ctx);
const expoVersion =
ctx.metadata?.sdkVersion ??
getPackageVersionFromPackageJson({
Expand All @@ -101,12 +103,19 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});

await runInstallDependenciesAsync(ctx, {
cwd: packagerDir,
useFrozenLockfile: shouldUseFrozenLockfile({
env: ctx.env,
sdkVersion: expoVersion,
reactNativeVersion,
}),
});
await configureExpoTransitiveDependenciesNodePathAsync({
projectDir: ctx.getReactNativeProjectDirectory(),
packagerDir,
env: ctx.env,
logger: ctx.logger,
});
});

await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
Expand Down Expand Up @@ -196,8 +205,10 @@ async function runExpoDoctor<TJob extends Job>(ctx: BuildContext<TJob>): Promise
async function runInstallDependenciesAsync<TJob extends Job>(
ctx: BuildContext<TJob>,
{
cwd,
useFrozenLockfile,
}: {
cwd: string;
useFrozenLockfile: boolean;
}
): Promise<void> {
Expand All @@ -218,7 +229,7 @@ async function runInstallDependenciesAsync<TJob extends Job>(
killTimeout.refresh();
}
},
cwd: resolvePackagerDir(ctx),
cwd,
useFrozenLockfile,
})
).spawnPromise;
Expand Down
Loading