react-native-test-app
Version:
react-native-test-app provides a test app for all supported platforms as a package
311 lines (280 loc) • 8.39 kB
JavaScript
#!/usr/bin/env node
// @ts-check
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as https from "node:https";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import prompts from "prompts";
import * as colors from "yoctocolors";
import { configure, getDefaultPlatformPackageName } from "./configure.mjs";
import { npm as npmSync, readJSONFile } from "./helpers.js";
import { parseArgs } from "./parseargs.mjs";
/**
* @template T
* @param {() => T | null} fn
* @returns {() => T | null}
*/
function memo(fn) {
/** @type {T | null} */
let result;
return () => {
if (result === undefined) {
result = fn();
}
return result;
};
}
/**
* Invokes `npm`.
* @param {...string} args
*/
function npm(...args) {
const { error, stderr, stdout } = npmSync(...args);
if (!stdout) {
if (stderr) {
console.error(stderr);
}
throw error;
}
return stdout.trim();
}
/**
* Invokes `tar xf`.
* @param {string} archive
*/
function untar(archive) {
const args = ["xf", archive];
const options = { cwd: path.dirname(archive) };
const result = spawnSync("tar", args, options);
// If we run `tar` from Git Bash with a Windows path, it will fail with:
//
// tar: Cannot connect to C: resolve failed
//
// GNU Tar assumes archives with a colon in the file name are on another
// machine. See also
// https://www.gnu.org/software/tar/manual/html_section/file.html.
if (
process.platform === "win32" &&
result.stderr.toString().includes("tar: Cannot connect to")
) {
args.push("--force-local");
return spawnSync("tar", args, options);
}
return result;
}
/**
* Returns the installed `react-native` manifest, if present.
* @returns {string | null}
*/
const getInstalledReactNativeManifest = memo(() => {
const require = createRequire(import.meta.url);
const options = { paths: [process.cwd()] };
try {
return require.resolve("react-native/package.json", options);
} catch (_) {
return null;
}
});
/**
* Returns the installed `react-native` version, if present.
* @returns {string | null}
*/
const getInstalledVersion = memo(() => {
const manifestPath = getInstalledReactNativeManifest();
if (manifestPath) {
const { version } = readJSONFile(manifestPath);
if (typeof version === "string") {
return version;
}
}
return null;
});
/**
* Returns the desired `react-native` version.
*
* Checks the following in order:
*
* - Command line flag, e.g. `--version 0.70`
* - Currently installed `react-native` version
* - Latest version from npm
*
* @param {import("./types").Platform[]} platforms
* @returns {string}
*/
function getVersion(platforms) {
const index = process.argv.lastIndexOf("--version");
if (index >= 0) {
return process.argv[index + 1];
}
/** @type {(version: string, reason: string) => void} */
const logVersion = (version, reason) => {
const bVersionFlag = colors.bold("--version");
const bTarget = colors.bold(version);
console.log(
`Using ${bTarget} because ${reason} (use ${bVersionFlag} to specify another version)`
);
};
const version = getInstalledVersion();
if (version) {
logVersion(version, "the current project uses it");
return version;
}
console.log("No version was specified; fetching available versions...");
const maxSupportedVersion = platforms.reduce((result, p) => {
const pkgName = getDefaultPlatformPackageName(p);
if (!pkgName) {
return result;
}
const [major, minor] = npm("view", pkgName, "version").split(".");
const v = Number(major) * 100 + Number(minor);
return v < result ? v : result;
}, Number.MAX_VALUE);
const major = Math.trunc(maxSupportedVersion / 100);
const minor = maxSupportedVersion % 100;
const target = `^${major}.${minor}`;
logVersion(target, "it supports all specified platforms");
return target;
}
/**
* Returns the React Native version and path to the template.
* @param {import("./types").Platform[]} platforms
* @returns {Promise<[string] | [string, string]>}
*/
function getTemplate(platforms) {
return new Promise((resolve, reject) => {
const version = getVersion(platforms);
if (getInstalledVersion() === version) {
const rnManifest = getInstalledReactNativeManifest();
if (rnManifest) {
resolve([version, path.join(path.dirname(rnManifest), "template")]);
return;
}
}
// `npm view` may return an array if there are multiple versions matching
// `version`. If there is only one match, the return type is a string.
const tarballs = JSON.parse(
npm("view", "--json", `react-native@${version}`, "dist.tarball")
);
const url = Array.isArray(tarballs)
? tarballs[tarballs.length - 1]
: tarballs;
console.log(`Downloading ${path.basename(url)}...`);
https
.get(url, (res) => {
const tmpDir = path.join(os.tmpdir(), "react-native-test-app");
fs.mkdirSync(tmpDir, { recursive: true });
const dest = path.join(tmpDir, path.basename(url));
const file = fs.createWriteStream(dest);
res.pipe(file);
file.on("finish", () => {
file.close();
untar(dest);
const template = path.join(tmpDir, "package", "template");
resolve([version, template]);
});
})
.on("error", (err) => {
reject(err);
});
});
}
function main() {
return new Promise((resolve) => {
parseArgs(
"Initializes a new app project from template",
{
name: {
description: "Name of the app",
type: "string",
},
platform: {
description:
"Platform to configure; can be specified multiple times e.g., `-p android -p ios`",
type: "string",
multiple: true,
short: "p",
},
destination: {
description: "Destination path for the app",
type: "string",
},
version: {
description: "React Native version",
type: "string",
short: "v",
},
},
async (args) => {
prompts.override({
name: args.name,
platforms:
typeof args.platform === "string" ? [args.platform] : args.platform,
packagePath: args.destination,
});
/**
* @type {{
* name?: string;
* packagePath?: string;
* platforms?: import("./types").Platform[];
* }}
*/
const { name, packagePath, platforms } = await prompts([
{
type: "text",
name: "name",
message: "What is the name of your app?",
initial: "Example",
validate: Boolean,
},
{
type: "multiselect",
name: "platforms",
message: "Which platforms do you need test apps for?",
choices: [
{ title: "Android", value: "android", selected: true },
{ title: "iOS", value: "ios", selected: true },
{ title: "macOS", value: "macos", selected: true },
{
title: "visionOS (Experimental)",
value: "visionos",
selected: false,
},
{ title: "Windows", value: "windows", selected: true },
],
min: 1,
},
{
type: "text",
name: "packagePath",
message: "Where should we create the new project?",
initial: "example",
validate: Boolean,
},
]);
if (!name || !packagePath || !platforms) {
resolve(1);
return;
}
const [targetVersion, templatePath] = await getTemplate(platforms);
const result = configure({
name,
packagePath,
templatePath,
testAppPath: fileURLToPath(new URL("..", import.meta.url)),
targetVersion,
platforms,
flatten: true,
force: true,
init: true,
});
resolve(result);
}
);
});
}
main().then((result) => {
process.exitCode = result;
});