diff --git a/.changeset/few-starfishes-give.md b/.changeset/few-starfishes-give.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/few-starfishes-give.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/cli/src/bundle/kit-config.ts b/packages/cli/src/bundle/kit-config.ts index 6123281b3..51737a87e 100644 --- a/packages/cli/src/bundle/kit-config.ts +++ b/packages/cli/src/bundle/kit-config.ts @@ -53,9 +53,9 @@ function getDefaultBundleParameters(platform: string) { */ export function getCliPlatformBundleConfigs( id?: string, - overridePlatform?: AllPlatforms + overridePlatform?: AllPlatforms, + kitConfig = getKitConfig() ): CliPlatformBundleConfig[] { - const kitConfig = getKitConfig(); const maybeBundleConfig = kitConfig ? getBundleConfig(kitConfig, id) : undefined; diff --git a/packages/cli/src/bundle/metro.ts b/packages/cli/src/bundle/metro.ts index be5ff6e0e..d42c492a3 100644 --- a/packages/cli/src/bundle/metro.ts +++ b/packages/cli/src/bundle/metro.ts @@ -2,6 +2,7 @@ import { info } from "@rnx-kit/console"; import type { BundleArgs as MetroBundleArgs } from "@rnx-kit/metro-service"; import { bundle } from "@rnx-kit/metro-service"; import type { ConfigT } from "metro-config"; +import * as nodefs from "node:fs"; import * as path from "node:path"; import { ensureDir } from "../helpers/filesystem"; import { customizeMetroConfig } from "../helpers/metro-config"; @@ -24,7 +25,8 @@ export async function metroBundle( bundleConfig: CliPlatformBundleConfig, dev: boolean, minify?: boolean, - output = bundle + output = bundle, + fs = nodefs ): Promise { info(`Bundling ${bundleConfig.platform}...`); @@ -49,12 +51,12 @@ export async function metroBundle( }; // ensure all output directories exist - ensureDir(path.dirname(metroBundleArgs.bundleOutput)); + ensureDir(path.dirname(metroBundleArgs.bundleOutput), fs); if (metroBundleArgs.sourcemapOutput) { - ensureDir(path.dirname(metroBundleArgs.sourcemapOutput)); + ensureDir(path.dirname(metroBundleArgs.sourcemapOutput), fs); } if (metroBundleArgs.assetsDest) { - ensureDir(metroBundleArgs.assetsDest); + ensureDir(metroBundleArgs.assetsDest, fs); } // create the bundle diff --git a/packages/cli/src/copy-assets.ts b/packages/cli/src/copy-assets.ts index 58de7c181..7a0c43fa0 100644 --- a/packages/cli/src/copy-assets.ts +++ b/packages/cli/src/copy-assets.ts @@ -11,7 +11,6 @@ import type { AllPlatforms } from "@rnx-kit/tools-react-native"; import { parsePlatform } from "@rnx-kit/tools-react-native"; import type { SpawnSyncOptions } from "child_process"; import { spawnSync } from "child_process"; -import * as fs from "fs"; import * as nodefs from "fs"; import * as os from "os"; import * as path from "path"; @@ -71,7 +70,7 @@ const defaultAndroidConfig: Required["android"]> = { kotlinVersion: "1.7.22", }; -function cloneFile(src: string, dest: string) { +function cloneFile(src: string, dest: string, fs = nodefs) { return fs.promises.copyFile(src, dest, fs.constants.COPYFILE_FICLONE); } @@ -86,7 +85,10 @@ function ensureOption(options: Options, opt: string, flag = opt) { } } -function findGradleProject(projectRoot: string): string | undefined { +function findGradleProject( + projectRoot: string, + fs = nodefs +): string | undefined { if (fs.existsSync(path.join(projectRoot, "android", "build.gradle"))) { return path.join(projectRoot, "android"); } @@ -106,28 +108,29 @@ function isAssetsConfig(config: unknown): config is AssetsConfig { return typeof config === "object" && config !== null && "getAssets" in config; } -export function versionOf(pkgName: string): string { - const packageDir = findPackageDependencyDir(pkgName); +export function versionOf(pkgName: string, fs = nodefs): string { + const packageDir = findPackageDependencyDir(pkgName, undefined, fs); if (!packageDir) { throw new Error(`Could not find module '${pkgName}'`); } - const { version } = readPackage(packageDir); + const { version } = readPackage(packageDir, fs); return version; } function getAndroidPaths( context: Context, packageName: string, - { targetName, version, output }: AndroidArchive + { targetName, version, output }: AndroidArchive, + fs = nodefs ) { - const projectRoot = findPackageDependencyDir(packageName); + const projectRoot = findPackageDependencyDir(packageName, undefined, fs); if (!projectRoot) { throw new Error(`Could not find module '${packageName}'`); } const gradleFriendlyName = targetName || gradleTargetName(packageName); - const aarVersion = version || versionOf(packageName); + const aarVersion = version || versionOf(packageName, fs); switch (packageName) { case "hermes-engine": @@ -139,7 +142,7 @@ function getAndroidPaths( destination: path.join( context.options.assetsDest, "aar", - `hermes-release-${versionOf(packageName)}.aar` + `hermes-release-${versionOf(packageName, fs)}.aar` ), }; @@ -157,7 +160,7 @@ function getAndroidPaths( }; default: { - const androidProject = findGradleProject(projectRoot); + const androidProject = findGradleProject(projectRoot, fs); return { targetName: gradleFriendlyName, version: aarVersion, @@ -193,14 +196,15 @@ function run(command: string, args: string[], options: SpawnSyncOptions) { export async function assembleAarBundle( context: Context, packageName: string, - { aar }: NativeAssets + { aar }: NativeAssets, + fs = nodefs ): Promise { if (!aar) { return; } const wrapper = os.platform() === "win32" ? "gradlew.bat" : "gradlew"; - const gradlew = findUp(wrapper); + const gradlew = findUp(wrapper, undefined, fs); if (!gradlew) { warn(`Skipped \`${packageName}\`: cannot find \`${wrapper}\``); return; @@ -209,7 +213,8 @@ export async function assembleAarBundle( const { targetName, version, androidProject, output } = getAndroidPaths( context, packageName, - aar + aar, + fs ); if (!androidProject || !output) { warn(`Skipped \`${packageName}\`: cannot find \`build.gradle\``); @@ -219,7 +224,7 @@ export async function assembleAarBundle( const { env: customEnv, dependencies, android } = aar; const env = { NODE_MODULES_PATH: path.join(process.cwd(), "node_modules"), - REACT_NATIVE_VERSION: versionOf("react-native"), + REACT_NATIVE_VERSION: versionOf("react-native", fs), ...process.env, ...customEnv, }; @@ -239,7 +244,8 @@ export async function assembleAarBundle( const { targetName, output, destination } = getAndroidPaths( context, dependencyName, - aar + aar, + fs ); if (output) { if (!fs.existsSync(output)) { @@ -336,7 +342,9 @@ export async function assembleAarBundle( run(gradlew, targets, { cwd: buildDir, stdio: "inherit", env }); } - await Promise.all(targetsToCopy.map(([src, dest]) => cloneFile(src, dest))); + await Promise.all( + targetsToCopy.map(([src, dest]) => cloneFile(src, dest, fs)) + ); } function copyFiles( @@ -372,10 +380,10 @@ export async function copyAssets( await Promise.all(tasks); } -export async function gatherConfigs({ - projectRoot, - manifest, -}: Context): Promise | undefined> { +export async function gatherConfigs( + { projectRoot, manifest }: Context, + fs = nodefs +): Promise | undefined> { const { dependencies, devDependencies } = manifest; const packages = [...keysOf(dependencies), ...keysOf(devDependencies)]; if (packages.length === 0) { @@ -472,11 +480,12 @@ export async function gatherConfigs({ */ export async function copyProjectAssets( options: Options, - { root: projectRoot, reactNativePath }: CLIConfig + { root: projectRoot, reactNativePath }: CLIConfig, + fs = nodefs ): Promise { const manifest = readPackage(projectRoot); const context = { projectRoot, manifest, options, reactNativePath }; - const assetConfigs = await gatherConfigs(context); + const assetConfigs = await gatherConfigs(context, fs); if (!assetConfigs) { return; } @@ -496,10 +505,10 @@ export async function copyProjectAssets( const assets = await getAssets(context); if (options.bundleAar && assets.aar) { info(`Assembling "${packageName}"`); - await assembleAarBundle(context, packageName, assets); + await assembleAarBundle(context, packageName, assets, fs); } else { info(`Copying assets for "${packageName}"`); - await copyAssets(context, packageName, assets); + await copyAssets(context, packageName, assets, fs); } } @@ -510,14 +519,15 @@ export async function copyProjectAssets( const { output, destination } = getAndroidPaths( context, dependencyName, - dummyAar + dummyAar, + fs ); if ( output && (!fs.existsSync(destination) || fs.statSync(destination).isDirectory()) ) { info(`Copying Android Archive of "${dependencyName}"`); - copyTasks.push(cloneFile(output, destination)); + copyTasks.push(cloneFile(output, destination, fs)); } } await Promise.all(copyTasks); @@ -531,7 +541,7 @@ export const rnxCopyAssetsCommand = { func: (_argv: string[], config: CLIConfig, options: Options) => { ensureOption(options, "platform"); ensureOption(options, "assetsDest", "assets-dest"); - return copyProjectAssets(options, config); + return copyProjectAssets(options, config, nodefs); }, options: [ { diff --git a/packages/cli/src/helpers/filesystem.ts b/packages/cli/src/helpers/filesystem.ts index 4cec1d7ed..34154d492 100644 --- a/packages/cli/src/helpers/filesystem.ts +++ b/packages/cli/src/helpers/filesystem.ts @@ -1,4 +1,4 @@ -import * as nodefs from "fs"; // Cannot use `node:fs` because of Jest mocks +import * as nodefs from "node:fs"; export function ensureDir(p: string, fs = nodefs): void { fs.mkdirSync(p, { recursive: true, mode: 0o755 }); diff --git a/packages/cli/test/__mocks__/child_process.js b/packages/cli/test/__mocks__/child_process.js deleted file mode 100644 index 6c1f50ff4..000000000 --- a/packages/cli/test/__mocks__/child_process.js +++ /dev/null @@ -1,5 +0,0 @@ -const child_process = jest.createMockFromModule("child_process"); - -child_process.spawnSync = () => ({ status: 0 }); - -module.exports = child_process; diff --git a/packages/cli/test/__mocks__/fs.js b/packages/cli/test/__mocks__/fs.js deleted file mode 100644 index 1ea738c36..000000000 --- a/packages/cli/test/__mocks__/fs.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("@rnx-kit/metro-plugin-duplicates-checker/test/__mocks__/fs.js"); diff --git a/packages/cli/test/bin/context.test.ts b/packages/cli/test/bin/context.test.ts index 88955980e..4bee62ab0 100644 --- a/packages/cli/test/bin/context.test.ts +++ b/packages/cli/test/bin/context.test.ts @@ -8,7 +8,7 @@ jest.mock("@rnx-kit/tools-react-native/context", () => ({ }, })); -describe("getCoreCommands()", () => { +describe("bin/context/getCoreCommands()", () => { it("strips `rnx-` prefix from all commands", () => { const coreCommands = getCoreCommands(); for (let i = 0; i < coreCommands.length; ++i) { @@ -24,7 +24,7 @@ describe("getCoreCommands()", () => { }); }); -describe("uniquify()", () => { +describe("bin/context/uniquify()", () => { function makeCommand(name: string, description: string): Command { return { name, description } as Command; } @@ -48,7 +48,7 @@ describe("uniquify()", () => { }); }); -describe("loadContext()", () => { +describe("bin/context/loadContext()", () => { afterAll(() => { jest.resetAllMocks(); }); diff --git a/packages/cli/test/bin/externalCommands.test.ts b/packages/cli/test/bin/externalCommands.test.ts index ae6977680..fc0454025 100644 --- a/packages/cli/test/bin/externalCommands.test.ts +++ b/packages/cli/test/bin/externalCommands.test.ts @@ -9,7 +9,7 @@ function mockContext(context: unknown = {}): Config { return context as Config; } -describe("findExternalCommands()", () => { +describe("bin/externalCommands/findExternalCommands()", () => { afterAll(() => { jest.resetAllMocks(); }); diff --git a/packages/cli/test/bundle.test.ts b/packages/cli/test/bundle.test.ts index 3a7799d79..101122e0a 100644 --- a/packages/cli/test/bundle.test.ts +++ b/packages/cli/test/bundle.test.ts @@ -1,7 +1,7 @@ import { rnxBundleCommand } from "../src/bundle"; import { asBoolean, asNumber, asStringArray } from "../src/helpers/parsers"; -describe("rnx-clean", () => { +describe("rnx-bundle", () => { it("correctly parses cli arguments", () => { for (const { name, parse } of rnxBundleCommand.options) { if (name.endsWith("[boolean]")) { diff --git a/packages/cli/test/bundle/__mocks__/@rnx-kit/config.js b/packages/cli/test/bundle/__mocks__/@rnx-kit/config.js deleted file mode 100644 index 466289cf1..000000000 --- a/packages/cli/test/bundle/__mocks__/@rnx-kit/config.js +++ /dev/null @@ -1,14 +0,0 @@ -const rnxKitConfig = jest.createMockFromModule("@rnx-kit/config"); -const actualKitConfig = jest.requireActual("@rnx-kit/config"); - -let kitConfig = undefined; - -rnxKitConfig.__setMockConfig = (config) => { - kitConfig = config; -}; - -rnxKitConfig.getKitConfig = () => kitConfig; -rnxKitConfig.getBundleConfig = actualKitConfig.getBundleConfig; -rnxKitConfig.getPlatformBundleConfig = actualKitConfig.getPlatformBundleConfig; - -module.exports = rnxKitConfig; diff --git a/packages/cli/test/bundle/__mocks__/@rnx-kit/console.js b/packages/cli/test/bundle/__mocks__/@rnx-kit/console.js deleted file mode 100644 index 2c5612f0c..000000000 --- a/packages/cli/test/bundle/__mocks__/@rnx-kit/console.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = jest.createMockFromModule("@rnx-kit/console"); diff --git a/packages/cli/test/bundle/__mocks__/@rnx-kit/metro-service.js b/packages/cli/test/bundle/__mocks__/@rnx-kit/metro-service.js deleted file mode 100644 index d583e0b1b..000000000 --- a/packages/cli/test/bundle/__mocks__/@rnx-kit/metro-service.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = jest.createMockFromModule("@rnx-kit/metro-service"); diff --git a/packages/cli/test/bundle/kit-config.test.ts b/packages/cli/test/bundle/kit-config.test.ts index 93ae7b1a3..05e54ae27 100644 --- a/packages/cli/test/bundle/kit-config.test.ts +++ b/packages/cli/test/bundle/kit-config.test.ts @@ -1,13 +1,13 @@ +import type { KitConfig } from "@rnx-kit/config"; import { getCliPlatformBundleConfigs, getTargetPlatforms, } from "../../src/bundle/kit-config"; -const rnxKitConfig = require("@rnx-kit/config"); - -describe("CLI > Bundle > Kit Config > getTargetPlatforms", () => { +describe("bundle/kit-config/getTargetPlatforms()", () => { test("returns the override platform", () => { const platforms = getTargetPlatforms("ios", ["android", "ios", "windows"]); + expect(Array.isArray(platforms)).toBe(true); expect(platforms.length).toBe(1); expect(platforms).toEqual(["ios"]); @@ -29,7 +29,7 @@ describe("CLI > Bundle > Kit Config > getTargetPlatforms", () => { }); }); -describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { +describe("bundle/kit-config/getCliPlatformBundleConfigs()", () => { const defaultConfig = { entryFile: "index.js", sourcemapUseAbsolutePath: false, @@ -65,12 +65,9 @@ describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { platform: "windows", }; - beforeEach(() => { - rnxKitConfig.__setMockConfig(undefined); - }); - test("returns defaults for iOS when package has no config", () => { const configs = getCliPlatformBundleConfigs(undefined, "ios"); + expect(Array.isArray(configs)).toBe(true); expect(configs.length).toBe(1); expect(configs[0]).toEqual(defaultConfigIOS); @@ -78,6 +75,7 @@ describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { test("returns defaults for MacOS when package has no config", () => { const configs = getCliPlatformBundleConfigs(undefined, "macos"); + expect(Array.isArray(configs)).toBe(true); expect(configs.length).toBe(1); expect(configs[0]).toEqual(defaultConfigMacOS); @@ -85,6 +83,7 @@ describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { test("returns defaults for Android when package has no config", () => { const configs = getCliPlatformBundleConfigs(undefined, "android"); + expect(Array.isArray(configs)).toBe(true); expect(configs.length).toBe(1); expect(configs[0]).toEqual(defaultConfigAndroid); @@ -92,12 +91,13 @@ describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { test("returns defaults for Windows when package has no config", () => { const configs = getCliPlatformBundleConfigs(undefined, "windows"); + expect(Array.isArray(configs)).toBe(true); expect(configs.length).toBe(1); expect(configs[0]).toEqual(defaultConfigWindows); }); - const testConfig = { + const testConfig: KitConfig = { bundle: { entryFile: "entry.js", typescriptValidation: false, @@ -107,8 +107,12 @@ describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { }; test("returns config with defaults for all target platforms", () => { - rnxKitConfig.__setMockConfig(testConfig); - const configs = getCliPlatformBundleConfigs(); + const configs = getCliPlatformBundleConfigs( + undefined, + undefined, + testConfig + ); + expect(Array.isArray(configs)).toBe(true); expect(configs.length).toBe(4); expect(configs[0]).toEqual({ ...defaultConfigIOS, ...testConfig.bundle }); @@ -123,7 +127,7 @@ describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { }); }); - const testMultiConfig = { + const testMultiConfig: KitConfig = { bundle: [ { id: "first", @@ -137,24 +141,32 @@ describe("CLI > Bundle > Kit Config > getCliPlatformBundleConfigs", () => { }; test("returns the first config when no id is given", () => { - rnxKitConfig.__setMockConfig(testMultiConfig); - const configs = getCliPlatformBundleConfigs(undefined, "ios"); + const configs = getCliPlatformBundleConfigs( + undefined, + "ios", + testMultiConfig + ); + expect(Array.isArray(configs)).toBe(true); expect(configs.length).toBe(1); expect(configs[0]).toEqual({ ...defaultConfigIOS, - ...testMultiConfig.bundle[0], + ...testMultiConfig.bundle?.[0], }); }); test("returns the selected config when an id is given", () => { - rnxKitConfig.__setMockConfig(testMultiConfig); - const configs = getCliPlatformBundleConfigs("second", "android"); + const configs = getCliPlatformBundleConfigs( + "second", + "android", + testMultiConfig + ); + expect(Array.isArray(configs)).toBe(true); expect(configs.length).toBe(1); expect(configs[0]).toEqual({ ...defaultConfigAndroid, - ...testMultiConfig.bundle[1], + ...testMultiConfig.bundle?.[1], }); }); }); diff --git a/packages/cli/test/bundle/metro.test.ts b/packages/cli/test/bundle/metro.test.ts index 1e7e7f685..4f57e25ad 100644 --- a/packages/cli/test/bundle/metro.test.ts +++ b/packages/cli/test/bundle/metro.test.ts @@ -1,13 +1,14 @@ +import { mockFS } from "@rnx-kit/tools-filesystem/mocks"; import { metroBundle } from "../../src/bundle/metro"; import type { CliPlatformBundleConfig } from "../../src/bundle/types"; -jest.mock("fs"); - -describe("CLI > Bundle > Metro > metroBundle", () => { +describe("bundle/metro/metroBundle()", () => { const { getDefaultConfig } = require("metro-config"); + const bundle = jest.fn(() => Promise.resolve()); + afterEach(() => { - jest.resetAllMocks(); + bundle.mockReset(); }); const bundleConfigNoPlugins: CliPlatformBundleConfig = { @@ -37,39 +38,65 @@ describe("CLI > Bundle > Metro > metroBundle", () => { it("does not use a custom serializer when all plugins are disabled", async () => { const metroConfig = await getDefaultConfig(); + expect(metroConfig.serializer.customSerializer).toBeFalsy(); - await metroBundle(metroConfig, bundleConfigNoPlugins, dev, minify); + + await metroBundle( + metroConfig, + bundleConfigNoPlugins, + dev, + minify, + bundle, + mockFS({}) + ); + expect(metroConfig.serializer.customSerializer).toBeFalsy(); }); it("uses a custom serializer when at least one plugin is enabled", async () => { const metroConfig = await getDefaultConfig(); + expect(metroConfig.serializer.customSerializer).toBeFalsy(); - await metroBundle(metroConfig, bundleConfig, dev, minify); + + await metroBundle( + metroConfig, + bundleConfig, + dev, + minify, + bundle, + mockFS({}) + ); + expect(metroConfig.serializer.customSerializer).toBeTruthy(); }); it("creates directories for the bundle, the source map, and assets", async () => { - await metroBundle(await getDefaultConfig(), bundleConfig, dev, minify); - - const fs = require("fs"); - expect(Object.keys(fs.__toJSON())).toEqual( - expect.arrayContaining([ - expect.stringContaining("/packages/cli/dist"), - expect.stringContaining("/packages/cli/map"), - expect.stringContaining("/packages/cli/src"), - ]) + const files = {}; + const metroConfig = await getDefaultConfig(); + await metroBundle( + metroConfig, + bundleConfig, + dev, + minify, + bundle, + mockFS(files) ); + + expect(Object.keys(files)).toEqual(["src", "map", "dist"]); }); it("invokes the Metro bundler using all input parameters", async () => { - const metroService = require("@rnx-kit/metro-service"); - const mockBundle = metroService.bundle; - - await metroBundle(await getDefaultConfig(), bundleConfig, dev, minify); + await metroBundle( + await getDefaultConfig(), + bundleConfig, + dev, + minify, + bundle, + mockFS({}) + ); - expect(mockBundle).toHaveBeenCalledTimes(1); - expect(mockBundle.mock.calls[0][0]).toEqual({ + expect(bundle).toHaveBeenCalledTimes(1); + expect(bundle.mock.calls[0][0]).toEqual({ ...bundleConfig, dev, minify, diff --git a/packages/cli/test/bundle/overrides.test.ts b/packages/cli/test/bundle/overrides.test.ts index e3b9471b3..1213409f7 100644 --- a/packages/cli/test/bundle/overrides.test.ts +++ b/packages/cli/test/bundle/overrides.test.ts @@ -4,7 +4,7 @@ import { } from "../../src/bundle/overrides"; import type { CliPlatformBundleConfig } from "../../src/bundle/types"; -describe("CLI > Bundle > Overrides > applyBundleConfigOverrides", () => { +describe("bundle/overrides/applyBundleConfigOverrides()", () => { const config: CliPlatformBundleConfig = { entryFile: "src/index.js", bundleOutput: "main.jsbundle", diff --git a/packages/cli/test/copy-assets/assembleAarBundle.test.ts b/packages/cli/test/copy-assets/assembleAarBundle.test.ts index da70f6be8..ab0c2bc53 100644 --- a/packages/cli/test/copy-assets/assembleAarBundle.test.ts +++ b/packages/cli/test/copy-assets/assembleAarBundle.test.ts @@ -1,15 +1,14 @@ +import { mockFS } from "@rnx-kit/tools-filesystem/mocks"; +import * as child_process from "child_process"; import * as path from "node:path"; import { assembleAarBundle } from "../../src/copy-assets"; jest.mock("child_process"); -jest.mock("fs"); jest.unmock("@rnx-kit/console"); -describe("assembleAarBundle", () => { - const fs = require("fs"); - +describe("copy-assets/assembleAarBundle()", () => { const consoleWarnSpy = jest.spyOn(global.console, "warn"); - const spawnSyncSpy = jest.spyOn(require("child_process"), "spawnSync"); + const spawnSyncSpy = jest.spyOn(child_process, "spawnSync"); const options = { platform: "android" as const, @@ -27,19 +26,38 @@ describe("assembleAarBundle", () => { reactNativePath: require.resolve("react-native"), }; + const cwd = process.cwd(); const dummyManifest = JSON.stringify({ version: "0.0.0-dev" }); - - function findFiles() { - return Object.entries(fs.__toJSON()); - } - - function mockFiles(files: Record = {}) { - fs.__setMockFiles(files); - } + const gradleWrapper = path.join( + cwd, + process.platform === "win32" ? "gradlew.bat" : "gradlew" + ); + const authDir = path.join( + cwd, + "node_modules", + "@rnx-kit", + "react-native-auth" + ); + const authBuildArtifact = path.join( + authDir, + "android", + "build", + "outputs", + "aar", + "rnx-kit_react-native-auth-release.aar" + ); + const authBuildGradle = path.join(authDir, "android", "build.gradle"); + const authManifest = path.join(authDir, "package.json"); + const rnManifest = path.join( + cwd, + "node_modules", + "react-native", + "package.json" + ); afterEach(() => { - mockFiles(); consoleWarnSpy.mockReset(); + spawnSyncSpy.mockReset(); }); afterAll(() => { @@ -47,85 +65,87 @@ describe("assembleAarBundle", () => { }); test("returns early if there is nothing to assemble", async () => { - mockFiles({ - gradlew: "", - "gradlew.bat": "", - }); + const files = { [gradleWrapper]: "" }; - await assembleAarBundle(context, context.manifest.name, {}); + await assembleAarBundle(context, context.manifest.name, {}, mockFS(files)); expect(consoleWarnSpy).not.toHaveBeenCalled(); - expect(findFiles()).toEqual([ - [expect.stringMatching(/[/\\]gradlew$/), ""], - [expect.stringMatching(/[/\\]gradlew.bat$/), ""], - ]); + expect(Object.entries(files)).toEqual([[gradleWrapper, ""]]); }); test("returns early if Gradle wrapper cannot be found", async () => { - await assembleAarBundle(context, context.manifest.name, { aar: {} }); + const files = {}; + + await assembleAarBundle( + context, + context.manifest.name, + { aar: {} }, + mockFS(files) + ); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.anything(), expect.stringMatching(/cannot find `gradlew(.bat)?`$/) ); expect(spawnSyncSpy).not.toHaveBeenCalled(); - expect(findFiles()).toEqual([]); + expect(Object.entries(files)).toEqual([]); }); test("throws if target package cannot be found", async () => { - mockFiles({ - gradlew: "", - "gradlew.bat": "", - }); + const files = { [gradleWrapper]: "" }; expect( - assembleAarBundle(context, context.manifest.name, { aar: {} }) + assembleAarBundle( + context, + context.manifest.name, + { aar: {} }, + mockFS(files) + ) ).rejects.toThrow(); - expect(findFiles()).toEqual([ - [expect.stringMatching(/[/\\]gradlew$/), ""], - [expect.stringMatching(/[/\\]gradlew.bat$/), ""], - ]); + expect(Object.entries(files)).toEqual([[gradleWrapper, ""]]); }); test("returns early if Gradle project cannot be found", async () => { - mockFiles({ - gradlew: "", - "gradlew.bat": "", - "node_modules/@rnx-kit/react-native-auth/package.json": dummyManifest, - }); + const files = { + [gradleWrapper]: "", + [authManifest]: dummyManifest, + }; - await assembleAarBundle(context, "@rnx-kit/react-native-auth", { aar: {} }); + await assembleAarBundle( + context, + "@rnx-kit/react-native-auth", + { aar: {} }, + mockFS(files) + ); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.anything(), expect.stringMatching(/cannot find `build.gradle`/) ); expect(spawnSyncSpy).not.toHaveBeenCalled(); - expect(findFiles()).toEqual([ - [expect.stringMatching(/[/\\]gradlew$/), ""], - [expect.stringMatching(/[/\\]gradlew.bat$/), ""], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]package.json$/ - ), - dummyManifest, - ], + expect(Object.entries(files)).toEqual([ + [gradleWrapper, ""], + [authManifest, dummyManifest], ]); }); test("generates Android project if necessary", async () => { - mockFiles({ - gradlew: "", - "gradlew.bat": "", - "node_modules/@rnx-kit/react-native-auth/android/build.gradle": - "build.gradle", - "node_modules/@rnx-kit/react-native-auth/android/build/outputs/aar/rnx-kit_react-native-auth-release.aar": - "rnx-kit_react-native-auth-release.aar", - "node_modules/@rnx-kit/react-native-auth/package.json": dummyManifest, - "node_modules/react-native/package.json": dummyManifest, - }); + child_process.spawnSync.mockReturnValue({ status: 0 }); + + const files = { + [gradleWrapper]: "", + [authBuildGradle]: path.basename(authBuildGradle), + [authBuildArtifact]: path.basename(authBuildArtifact), + [authManifest]: dummyManifest, + [rnManifest]: dummyManifest, + }; - await assembleAarBundle(context, "@rnx-kit/react-native-auth", { aar: {} }); + await assembleAarBundle( + context, + "@rnx-kit/react-native-auth", + { aar: {} }, + mockFS(files) + ); expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(spawnSyncSpy).toHaveBeenCalledWith( @@ -137,33 +157,12 @@ describe("assembleAarBundle", () => { ), }) ); - expect(findFiles()).toEqual([ - [expect.stringMatching(/[/\\]gradlew$/), ""], - [expect.stringMatching(/[/\\]gradlew.bat$/), ""], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]android[/\\]build.gradle$/ - ), - "build.gradle", - ], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]android[/\\]build[/\\]outputs[/\\]aar[/\\]rnx-kit_react-native-auth-release.aar$/ - ), - "rnx-kit_react-native-auth-release.aar", - ], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]package.json$/ - ), - dummyManifest, - ], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]react-native[/\\]package.json$/ - ), - dummyManifest, - ], + expect(Object.entries(files)).toEqual([ + [gradleWrapper, ""], + [authBuildGradle, path.basename(authBuildGradle)], + [authBuildArtifact, path.basename(authBuildArtifact)], + [authManifest, dummyManifest], + [rnManifest, dummyManifest], [ expect.stringMatching( /[/\\]node_modules[/\\].rnx-gradle-build[/\\]rnx-kit_react-native-auth[/\\]build.gradle$/ @@ -186,28 +185,32 @@ describe("assembleAarBundle", () => { ], [ expect.stringMatching( - /[/\\]dist[/\\]aar[/\\]rnx-kit_react-native-auth-0.0.0-dev.aar$/ + /dist[/\\]aar[/\\]rnx-kit_react-native-auth-0.0.0-dev.aar$/ ), - "rnx-kit_react-native-auth-release.aar", + path.basename(authBuildArtifact), ], ]); }); test("assembles Android archive using existing project", async () => { - mockFiles({ - gradlew: "", - "gradlew.bat": "", - "node_modules/@rnx-kit/react-native-auth/android/build.gradle": - "build.gradle", - "node_modules/@rnx-kit/react-native-auth/android/build/outputs/aar/rnx-kit_react-native-auth-release.aar": - "rnx-kit_react-native-auth-release.aar", - "node_modules/@rnx-kit/react-native-auth/android/settings.gradle": - "settings.gradle", - "node_modules/@rnx-kit/react-native-auth/package.json": dummyManifest, - "node_modules/react-native/package.json": dummyManifest, - }); + child_process.spawnSync.mockReturnValue({ status: 0 }); - await assembleAarBundle(context, "@rnx-kit/react-native-auth", { aar: {} }); + const authSettingsGradle = path.join(authDir, "android", "settings.gradle"); + const files = { + [gradleWrapper]: "", + [authBuildGradle]: path.basename(authBuildGradle), + [authBuildArtifact]: path.basename(authBuildArtifact), + [authSettingsGradle]: path.basename(authSettingsGradle), + [authManifest]: dummyManifest, + [rnManifest]: dummyManifest, + }; + + await assembleAarBundle( + context, + "@rnx-kit/react-native-auth", + { aar: {} }, + mockFS(files) + ); expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(spawnSyncSpy).toHaveBeenCalledWith( @@ -219,72 +222,50 @@ describe("assembleAarBundle", () => { ), }) ); - expect(findFiles()).toEqual([ - [expect.stringMatching(/[/\\]gradlew$/), ""], - [expect.stringMatching(/[/\\]gradlew.bat$/), ""], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]android[/\\]build.gradle$/ - ), - "build.gradle", - ], + expect(Object.entries(files)).toEqual([ + [gradleWrapper, ""], + [authBuildGradle, path.basename(authBuildGradle)], + [authBuildArtifact, path.basename(authBuildArtifact)], + [authSettingsGradle, path.basename(authSettingsGradle)], + [authManifest, dummyManifest], + [rnManifest, dummyManifest], [ expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]android[/\\]build[/\\]outputs[/\\]aar[/\\]rnx-kit_react-native-auth-release.aar$/ + /dist[/\\]aar[/\\]rnx-kit_react-native-auth-0.0.0-dev.aar$/ ), - "rnx-kit_react-native-auth-release.aar", - ], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]android[/\\]settings.gradle$/ - ), - "settings.gradle", - ], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]@rnx-kit[/\\]react-native-auth[/\\]package.json$/ - ), - dummyManifest, - ], - [ - expect.stringMatching( - /[/\\]node_modules[/\\]react-native[/\\]package.json$/ - ), - dummyManifest, - ], - [ - expect.stringMatching( - /[/\\]dist[/\\]aar[/\\]rnx-kit_react-native-auth-0.0.0-dev.aar$/ - ), - "rnx-kit_react-native-auth-release.aar", + path.basename(authBuildArtifact), ], ]); }); test("allows the generated Android project to be configured", async () => { - mockFiles({ - gradlew: "", - "gradlew.bat": "", - "node_modules/@rnx-kit/react-native-auth/android/build.gradle": - "build.gradle", - "node_modules/@rnx-kit/react-native-auth/android/build/outputs/aar/rnx-kit_react-native-auth-release.aar": - "rnx-kit_react-native-auth-release.aar", - "node_modules/@rnx-kit/react-native-auth/package.json": dummyManifest, - "node_modules/react-native/package.json": dummyManifest, - }); + child_process.spawnSync.mockReturnValue({ status: 0 }); + + const files = { + [gradleWrapper]: "", + [authBuildGradle]: path.basename(authBuildGradle), + [authBuildArtifact]: path.basename(authBuildArtifact), + [authManifest]: dummyManifest, + [rnManifest]: dummyManifest, + }; - await assembleAarBundle(context, "@rnx-kit/react-native-auth", { - aar: { - android: { - androidPluginVersion: "7.1.3", - compileSdkVersion: 31, - defaultConfig: { - minSdkVersion: 26, - targetSdkVersion: 30, + await assembleAarBundle( + context, + "@rnx-kit/react-native-auth", + { + aar: { + android: { + androidPluginVersion: "7.1.3", + compileSdkVersion: 31, + defaultConfig: { + minSdkVersion: 26, + targetSdkVersion: 30, + }, }, }, }, - }); + mockFS(files) + ); expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(spawnSyncSpy).toHaveBeenCalledWith( @@ -296,7 +277,7 @@ describe("assembleAarBundle", () => { ), }) ); - expect(findFiles()).toEqual( + expect(Object.entries(files)).toEqual( expect.arrayContaining([ [ expect.stringMatching( diff --git a/packages/cli/test/copy-assets/copyAssets.test.ts b/packages/cli/test/copy-assets/copyAssets.test.ts index f07b3aff4..13600a70e 100644 --- a/packages/cli/test/copy-assets/copyAssets.test.ts +++ b/packages/cli/test/copy-assets/copyAssets.test.ts @@ -19,7 +19,7 @@ const context = { reactNativePath: require.resolve("react-native"), }; -describe("copyAssets", () => { +describe("copy-assets/copyAssets()", () => { const mkdirOptions = JSON.stringify({ recursive: true, mode: 0o755 }); test("returns early if there is nothing to copy", async () => { @@ -98,13 +98,13 @@ describe("copyAssets", () => { }); }); -describe("gatherConfigs", () => { +describe("copy-assets/gatherConfigs()", () => { test("returns early if there is nothing to copy", async () => { expect(await gatherConfigs(context)).toBeUndefined(); }); }); -describe("versionOf", () => { +describe("copy-assets/versionOf()", () => { test("returns the version of specified package", () => { expect(versionOf("@rnx-kit/tools-node")).toMatch(/^\d+[.\d]+$/); }); diff --git a/packages/cli/test/helpers/filesystem.test.ts b/packages/cli/test/helpers/filesystem.test.ts index f343ee5f9..2b672c85e 100644 --- a/packages/cli/test/helpers/filesystem.test.ts +++ b/packages/cli/test/helpers/filesystem.test.ts @@ -1,6 +1,6 @@ import { ensureDir } from "../../src/helpers/filesystem"; -describe("ensureDir()", () => { +describe("helpers/filesystem/ensureDir()", () => { it("passes the correct options to `fs.mkdir`", () => { let options = {}; const fsMock = { diff --git a/packages/cli/test/helpers/metro-config.test.ts b/packages/cli/test/helpers/metro-config.test.ts index e41ec9b67..01903bf65 100644 --- a/packages/cli/test/helpers/metro-config.test.ts +++ b/packages/cli/test/helpers/metro-config.test.ts @@ -48,7 +48,7 @@ function toMock(module: unknown): ReturnType { return module as ReturnType; } -describe("cli/metro-config/customizeMetroConfig", () => { +describe("cli/metro-config/customizeMetroConfig()", () => { afterEach(() => { toMock(CyclicDependencies).mockClear(); toMock(DuplicateDependencies).mockClear(); diff --git a/packages/cli/test/helpers/parsers.test.ts b/packages/cli/test/helpers/parsers.test.ts index 9d670d46a..59978f36f 100644 --- a/packages/cli/test/helpers/parsers.test.ts +++ b/packages/cli/test/helpers/parsers.test.ts @@ -5,7 +5,7 @@ import { asTransformProfile, } from "../../src/helpers/parsers"; -describe("asBoolean()", () => { +describe("helpers/parsers/asBoolean()", () => { it("returns boolean for string", () => { expect(asBoolean("false")).toBe(false); expect(asBoolean("true")).toBe(true); @@ -24,7 +24,7 @@ describe("asBoolean()", () => { }); }); -describe("asNumber()", () => { +describe("helpers/parsers/asNumber()", () => { it("returns the numerical representation of number", () => { expect(asNumber("0")).toBe(0); expect(asNumber("1")).toBe(1); @@ -37,7 +37,7 @@ describe("asNumber()", () => { }); }); -describe("asStringArray()", () => { +describe("helpers/parsers/asStringArray()", () => { it("splits a string by ','", () => { expect(asStringArray("")).toStrictEqual([""]); expect(asStringArray(",")).toStrictEqual(["", ""]); @@ -46,7 +46,7 @@ describe("asStringArray()", () => { }); }); -describe("asTransformProfile()", () => { +describe("helpers/parsers/asTransformProfile()", () => { it("returns the profile if valid", () => { expect(asTransformProfile("hermes-stable")).toBe("hermes-stable"); expect(asTransformProfile("hermes-canary")).toBe("hermes-canary"); diff --git a/packages/tools-filesystem/src/mocks.ts b/packages/tools-filesystem/src/mocks.ts index cd033b76b..144d24c79 100644 --- a/packages/tools-filesystem/src/mocks.ts +++ b/packages/tools-filesystem/src/mocks.ts @@ -1,16 +1,29 @@ type NodeFS = typeof import("node:fs"); export function mockFS(files: Record): NodeFS { + const statSync = (p: string) => ({ + isDirectory: () => Object.keys(files[p]).some((f) => f.startsWith(p)), + isFile: () => p in files, + isSymbolicLink: () => false, + }); return { + constants: { + COPYFILE_FICLONE: 2, + }, existsSync: (p: string) => Boolean(files[p]), + lstatSync: statSync, mkdirSync: (p: string, options: unknown) => { files[p] = JSON.stringify(options); }, readFileSync: (p: string) => files[p], + statSync, writeFileSync: (p: string, data: string) => { files[p] = data; }, promises: { + copyFile: (source: string, destination: string) => { + files[destination] = files[source]; + }, cp: (source: string, destination: string) => { files[destination] = files[source]; }, diff --git a/packages/tools-node/src/package.ts b/packages/tools-node/src/package.ts index f6c619e88..54fbd1aa9 100644 --- a/packages/tools-node/src/package.ts +++ b/packages/tools-node/src/package.ts @@ -1,4 +1,4 @@ -import * as fs from "fs"; +import * as nodefs from "fs"; import * as path from "path"; import { findUp } from "./path"; @@ -95,7 +95,7 @@ function resolvePackagePath(pkgPath: string): string { * @param pkgPath Either a path directly to the target `package.json` file, or the directory containing it. * @returns Package manifest */ -export function readPackage(pkgPath: string): PackageManifest { +export function readPackage(pkgPath: string, fs = nodefs): PackageManifest { const pkgFile = resolvePackagePath(pkgPath); return JSON.parse(fs.readFileSync(pkgFile, "utf-8")); } @@ -110,7 +110,8 @@ export function readPackage(pkgPath: string): PackageManifest { export function writePackage( pkgPath: string, manifest: PackageManifest, - space = " " + space = " ", + fs = nodefs ): void { const pkgFile = resolvePackagePath(pkgPath); fs.writeFileSync( @@ -130,8 +131,11 @@ export function writePackage( * @param startDir Optional starting directory for the search. If not given, the current directory is used. * @returns Path to `package.json`, or `undefined` if not found. */ -export function findPackage(startDir?: string): string | undefined { - return findUp("package.json", { startDir }); +export function findPackage( + startDir?: string, + fs = nodefs +): string | undefined { + return findUp("package.json", { startDir }, fs); } /** @@ -144,8 +148,11 @@ export function findPackage(startDir?: string): string | undefined { * @param startDir Optional starting directory for the search. If not given, the current directory is used. * @returns Path to `package.json`, or `undefined` if not found. */ -export function findPackageDir(startDir?: string): string | undefined { - const manifest = findUp("package.json", { startDir }); +export function findPackageDir( + startDir?: string, + fs = nodefs +): string | undefined { + const manifest = findUp("package.json", { startDir }, fs); return manifest && path.dirname(manifest); } @@ -184,15 +191,20 @@ export type FindPackageDependencyOptions = { */ export function findPackageDependencyDir( ref: string | PackageRef, - options?: FindPackageDependencyOptions + options?: FindPackageDependencyOptions, + fs = nodefs ): string | undefined { const pkgName = typeof ref === "string" ? ref : path.join(ref.scope ?? "", ref.name); - const packageDir = findUp(path.join("node_modules", pkgName), { - startDir: options?.startDir, - type: "directory", - allowSymlinks: options?.allowSymlinks, - }); + const packageDir = findUp( + path.join("node_modules", pkgName), + { + startDir: options?.startDir, + type: "directory", + allowSymlinks: options?.allowSymlinks, + }, + fs + ); if (!packageDir || !options?.resolveSymlinks) { return packageDir; } diff --git a/packages/tools-node/src/path.ts b/packages/tools-node/src/path.ts index 616724287..6f46ad23b 100644 --- a/packages/tools-node/src/path.ts +++ b/packages/tools-node/src/path.ts @@ -1,4 +1,4 @@ -import * as fs from "fs"; +import * as nodefs from "fs"; import * as path from "path"; type FileType = "file" | "directory"; @@ -10,7 +10,11 @@ type FindUpOptions = { allowSymlinks?: boolean; }; -function exists(name: string, type: FileType, stat: fs.StatSyncFn): boolean { +function exists( + name: string, + type: FileType, + stat: nodefs.StatSyncFn +): boolean { try { const stats = stat(name, { throwIfNoEntry: false }); switch (type) { @@ -37,7 +41,8 @@ export function findUp( startDir = process.cwd(), stopAt, allowSymlinks = true, - }: FindUpOptions = {} + }: FindUpOptions = {}, + fs = nodefs ): string | undefined { const stat = allowSymlinks ? fs.statSync : fs.lstatSync; let directory = path.resolve(startDir); diff --git a/scripts/src/commands/test.js b/scripts/src/commands/test.js index 7248d7a62..4f9526486 100644 --- a/scripts/src/commands/test.js +++ b/scripts/src/commands/test.js @@ -4,9 +4,8 @@ import { execute, runScript } from "../process.js"; /** @type {import("../process.js").Command} */ export async function test(_args, rawArgs = []) { - const manifest = await fs.readFile(process.cwd() + "/package.json", { - encoding: "utf-8", - }); + const options = /** @type {const} */ ({ encoding: "utf-8" }); + const manifest = await fs.readFile(process.cwd() + "/package.json", options); if (manifest.includes('"jest"')) { await runScript("jest", "--passWithNoTests", ...rawArgs); } else {