From f0128328673b85fc99e48d61052f6819e342a988 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:30:15 +0200 Subject: [PATCH] test(cli): make implicit mocks explicit This makes it easier to migrate to Node test runner in the future. We cannot migrate today because we still need module mocking, which will become available in Node 22. --- .changeset/few-starfishes-give.md | 2 + packages/cli/src/bundle/kit-config.ts | 4 +- packages/cli/src/bundle/metro.ts | 10 +- packages/cli/src/copy-assets.ts | 66 ++-- packages/cli/src/helpers/filesystem.ts | 2 +- packages/cli/test/__mocks__/child_process.js | 5 - packages/cli/test/__mocks__/fs.js | 1 - packages/cli/test/bin/context.test.ts | 6 +- .../cli/test/bin/externalCommands.test.ts | 2 +- packages/cli/test/bundle.test.ts | 2 +- .../test/bundle/__mocks__/@rnx-kit/config.js | 14 - .../test/bundle/__mocks__/@rnx-kit/console.js | 1 - .../__mocks__/@rnx-kit/metro-service.js | 1 - packages/cli/test/bundle/kit-config.test.ts | 48 +-- packages/cli/test/bundle/metro.test.ts | 69 ++-- packages/cli/test/bundle/overrides.test.ts | 2 +- .../copy-assets/assembleAarBundle.test.ts | 295 ++++++++---------- .../cli/test/copy-assets/copyAssets.test.ts | 6 +- packages/cli/test/helpers/filesystem.test.ts | 2 +- .../cli/test/helpers/metro-config.test.ts | 2 +- packages/cli/test/helpers/parsers.test.ts | 8 +- packages/tools-filesystem/src/mocks.ts | 13 + packages/tools-node/src/package.ts | 38 ++- packages/tools-node/src/path.ts | 11 +- scripts/src/commands/test.js | 5 +- 25 files changed, 328 insertions(+), 287 deletions(-) create mode 100644 .changeset/few-starfishes-give.md delete mode 100644 packages/cli/test/__mocks__/child_process.js delete mode 100644 packages/cli/test/__mocks__/fs.js delete mode 100644 packages/cli/test/bundle/__mocks__/@rnx-kit/config.js delete mode 100644 packages/cli/test/bundle/__mocks__/@rnx-kit/console.js delete mode 100644 packages/cli/test/bundle/__mocks__/@rnx-kit/metro-service.js 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 {