Skip to content
Closed
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
99 changes: 99 additions & 0 deletions __tests__/fixtures/packPackage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'child_process';

// Build outside the checkout, then test the files selected by npm rather than
// stale lib output or Jest's @rnmapbox/maps source alias.
export function packPackage(targets = ['module', 'typescript']) {
const projectRoot = path.resolve(__dirname, '../..');
const consumerDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'rnmapbox-package-'),
);
const packageDir = path.join(consumerDir, 'node_modules/@rnmapbox/maps');
const buildDir = path.join(consumerDir, 'build');
const env = { ...process.env };
delete env.npm_execpath;
env.npm_config_cache = path.join(consumerDir, 'npm-cache');

try {
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(buildDir);
for (const directory of ['src', 'plugin']) {
fs.cpSync(
path.join(projectRoot, directory),
path.join(buildDir, directory),
{
recursive: true,
},
);
}
// Compile the plugin from source too; a checked-in build must not mask a
// missing or broken published plugin.
fs.rmSync(path.join(buildDir, 'plugin/build'), {
recursive: true,
force: true,
});
for (const file of [
'package.json',
'app.plugin.js',
'.npmignore',
'tsconfig.json',
'tsconfig.build.json',
]) {
fs.copyFileSync(path.join(projectRoot, file), path.join(buildDir, file));
}
fs.symlinkSync(
path.join(projectRoot, 'node_modules'),
path.join(buildDir, 'node_modules'),
'junction',
);

execFileSync(
process.execPath,
[require.resolve('typescript/bin/tsc'), '--build', 'plugin'],
{ cwd: buildDir, env, timeout: 60000 },
);
const bob = require.resolve('react-native-builder-bob/bin/bob');
for (const target of targets) {
execFileSync(process.execPath, [bob, 'build', '--target', target], {
cwd: buildDir,
env,
timeout: 60000,
});
}
// npm 10 runs prepare even with --ignore-scripts. The real build targets
// already ran above; prevent a second build (and npx/network access) only
// in this disposable fixture. Keep exports and file selection unchanged.
const manifestPath = path.join(buildDir, 'package.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
delete manifest.scripts.prepare;
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
const packed = JSON.parse(
execFileSync(
'npm',
[
'pack',
'--ignore-scripts',
'--offline',
'--json',
'--pack-destination',
consumerDir,
],
{ cwd: buildDir, env, timeout: 30000, encoding: 'utf8' },
),
);
execFileSync('tar', [
'-xzf',
path.join(consumerDir, packed[0].filename),
'--strip-components=1',
'-C',
packageDir,
]);

return { consumerDir, packageDir, projectRoot };
} catch (error) {
fs.rmSync(consumerDir, { recursive: true, force: true });
throw error;
}
}
132 changes: 132 additions & 0 deletions __tests__/fixtures/packageResolution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
const fs = require('fs');
const path = require('path');
const { createRequire } = require('module');

const [consumerDir, projectRoot, mode, ...options] = process.argv.slice(2);
const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
const consumerRequire = createRequire(path.join(consumerDir, 'index.js'));
const packageDir = path.dirname(
consumerRequire.resolve('@rnmapbox/maps/package.json'),
);

function packageRelativePath(file) {
return path.relative(packageDir, file).split(path.sep).join('/');
}

if (mode === 'plugin') {
const [resolver, reference] = options;
const { resolveConfigPluginFunctionWithInfo } = projectRequire(
`${resolver}/build/utils/plugin-resolver`,
);
const { plugin, pluginFile, isPluginFile } =
resolveConfigPluginFunctionWithInfo(consumerDir, reference);
const config = plugin({ name: 'package-test', slug: 'package-test' });
process.stdout.write(
JSON.stringify({
pluginFile: packageRelativePath(pluginFile),
isPluginFile,
mods: Object.keys(config.mods).sort(),
}),
);
} else {
const [platform, sourceCondition] = options;
const { resolve } = projectRequire('metro-resolver');
const { parse } = projectRequire('@babel/parser');
const context = {
allowHaste: false,
assetExts: new Set(['png', 'css']),
customResolverOptions: {},
disableHierarchicalLookup: false,
isESMImport: true,
mainFields: ['react-native', 'browser', 'main'],
nodeModulesPaths: [],
preferNativePlatform: platform !== 'web',
sourceExts: ['js', 'jsx', 'ts', 'tsx'],
unstable_enablePackageExports: true,
unstable_conditionNames: sourceCondition === 'source' ? ['source'] : [],
unstable_conditionsByPlatform: {
ios: ['react-native'],
android: ['react-native'],
web: ['browser'],
},
unstable_logWarning(message) {
throw new Error(message);
},
resolveAsset() {
return null;
},
doesFileExist(file) {
return fs.existsSync(file) && fs.statSync(file).isFile();
},
fileSystemLookup(file) {
if (!fs.existsSync(file)) {
return { exists: false };
}
return {
exists: true,
type: fs.statSync(file).isDirectory() ? 'd' : 'f',
realPath: fs.realpathSync(file),
};
},
getPackage(file) {
return fs.existsSync(file)
? JSON.parse(fs.readFileSync(file, 'utf8'))
: null;
},
getPackageForModule(file) {
let directory =
fs.existsSync(file) && fs.statSync(file).isDirectory()
? file
: path.dirname(file);
while (directory !== path.dirname(directory)) {
const packageJson = context.getPackage(
path.join(directory, 'package.json'),
);
if (packageJson) {
return {
rootPath: directory,
packageJson,
packageRelativePath: path.relative(directory, file),
};
}
directory = path.dirname(directory);
}
return null;
},
};
function resolveFrom(originModulePath, specifier) {
return resolve({ ...context, originModulePath }, specifier, platform)
.filePath;
}
function dependency(file, specifier) {
const ast = parse(fs.readFileSync(file, 'utf8'), {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
});
const declaration = ast.program.body.find(
(node) =>
node.source?.value.replace(/(?:\/index)?\.js$/, '') === specifier,
);
if (!declaration) {
throw new Error(`Missing ${specifier} dependency in ${file}`);
}
return resolveFrom(file, declaration.source.value);
}
const entry = resolveFrom(
path.join(consumerDir, 'index.js'),
'@rnmapbox/maps',
);
const mapbox = dependency(entry, './Mapbox');
const implementation = dependency(
mapbox,
platform === 'web' ? './web' : './RNMBXModule',
);
const components = platform === 'web' ? implementation : mapbox;
const camera = dependency(components, './components/Camera');
const mapView = dependency(components, './components/MapView');
process.stdout.write(
JSON.stringify(
[entry, mapbox, implementation, camera, mapView].map(packageRelativePath),
),
);
}
87 changes: 87 additions & 0 deletions __tests__/packageResolution.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import fs from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';

import { packPackage } from './fixtures/packPackage';

describe('Packed package resolution', () => {
let consumerDir;
let projectRoot;

beforeAll(() => {
({ consumerDir, projectRoot } = packPackage(['module']));
// Only supply the declared Expo peer. Runtime React Native must never be
// imported while loading the config plugin in Node.
fs.symlinkSync(
path.join(projectRoot, 'node_modules/expo'),
path.join(consumerDir, 'node_modules/expo'),
'junction',
);
}, 60000);

afterAll(() => {
if (consumerDir) {
fs.rmSync(consumerDir, { recursive: true, force: true });
}
});

function probe(...args) {
// Jest maps @rnmapbox/maps to src and transforms ESM. Use unmodified Node
// and Metro so neither behavior hides a published-artifact regression.
return JSON.parse(
execFileSync(
process.execPath,
[
path.join(__dirname, 'fixtures/packageResolution.js'),
consumerDir,
projectRoot,
...args,
],
{ encoding: 'utf8', timeout: 10000 },
),
);
}

// Expo Tools bundles 4.1.5, whose nearest-package.json lookup differs from
// the SDK resolver. Exercise both real implementations against the archive.
it.each(['expo-config-plugins-legacy', '@expo/config-plugins'])(
'loads the package-name plugin using %s',
(resolver) => {
expect(probe('plugin', resolver, '@rnmapbox/maps')).toEqual({
pluginFile: 'app.plugin.js',
isPluginFile: true,
mods: ['android', 'ios'],
});
},
);

it('keeps the explicit app.plugin.js export working', () => {
expect(
probe('plugin', '@expo/config-plugins', '@rnmapbox/maps/app.plugin.js'),
).toEqual({
pluginFile: 'app.plugin.js',
isPluginFile: true,
mods: ['android', 'ios'],
});
});

it.each(
['ios', 'android', 'web'].flatMap((platform) =>
['compiled', 'source'].map((condition) => [platform, condition]),
),
)('resolves %s with the %s entry', (platform, condition) => {
const directory = condition === 'source' ? 'src' : 'lib/module';
const extension = condition === 'source' ? 'ts' : 'js';
const componentExtension = condition === 'source' ? 'tsx' : 'js';
const web = platform === 'web';
expect(probe('metro', platform, condition)).toEqual([
`${directory}/index.${extension}`,
`${directory}/Mapbox${web ? '' : '.native'}.${extension}`,
web
? `${directory}/web/index.${componentExtension}`
: `${directory}/RNMBXModule.${extension}`,
`${directory}/${web ? 'web/' : ''}components/Camera.${componentExtension}`,
`${directory}/${web ? 'web/' : ''}components/MapView.${componentExtension}`,
]);
});
});
Loading
Loading