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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Scaffold a [Mobilewright](https://mobilewright.dev) test project in seconds.
npm init mobilewright@latest
```

Also works with Yarn (`yarn create mobilewright`) and pnpm (`pnpm create mobilewright`).
Also works with Yarn (`yarn create mobilewright`), pnpm (`pnpm create mobilewright`) and Bun (`bun create mobilewright`). The project's own package manager is detected from its lockfile, so dependencies are installed with the tool you already use.

The CLI walks you through setup and creates a ready-to-run project:

Expand Down Expand Up @@ -40,7 +40,7 @@ If something's missing, `npx mobilewright doctor` tells you exactly what to fix.
## Next steps

- [Mobilewright docs](https://mobilewright.dev/docs) — API reference and guides
- [mobile-use.com](https://mobile-use.com) — Run tests on real devices in the cloud
- [Mobile Next Cloud](https://mobilenext.ai/cloud?utm_source=github&utm_medium=readme&utm_campaign=create-mobilewright&utm_content=next-steps) — Run tests on real devices in the cloud

## License

Expand Down
24 changes: 16 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "path";
import prompts from "prompts";
import { execSync } from "child_process";
import { detectApps, DetectedApp, Platform } from "./detect";
import { createProjectCommand, detectPackageManager, isWorkspaceRoot, PackageManager, runCommand } from "./package-manager";
import {
chooseDefaultTestDir,
createConfigContent,
Expand Down Expand Up @@ -116,19 +117,20 @@ function writeJson(filePath: string, value: unknown): void {

// same order as create-playwright: package.json and install first, so a failed
// install leaves no half-scaffolded project behind
function installDependencies(targetDir: string, language: Language): void {
function installDependencies(targetDir: string, language: Language, packageManager: PackageManager): void {
const pkgPath = path.join(targetDir, "package.json");
const existing = readPackageJson(pkgPath);
// a blank package.json would make npm fail with EJSONPARSE
// a blank package.json would make the package manager fail on a parse error
if (existing === undefined || Object.keys(existing).length === 0) writeJson(pkgPath, createNewPackageJson(targetDir));

console.log("\nInstalling dependencies...\n");
for (const command of installCommands(planInstall(existing ?? {}, language, process.versions.node))) {
const plan = planInstall(existing ?? {}, language, process.versions.node);
for (const command of installCommands(plan, packageManager, isWorkspaceRoot(targetDir, existing?.workspaces))) {
console.log(`${command}\n`);
try {
execSync(command, { cwd: targetDir, stdio: "inherit" });
} catch {
console.error("\nFailed to install dependencies. No test files were created; fix the error above and run npm init mobilewright@latest again.");
console.error(`\nFailed to install dependencies. No test files were created; fix the error above and run ${createProjectCommand(packageManager)} again.`);
process.exit(1);
}
}
Expand Down Expand Up @@ -156,12 +158,17 @@ function writeProjectFiles(targetDir: string, answers: Answers): void {
writeJson(pkgPath, withTestScript(readPackageJson(pkgPath) ?? {}));
}

function printSuccess(runners: TestRunner[], testDir: string): void {
function printSuccess(runners: TestRunner[], testDir: string, packageManager: PackageManager): void {
console.log(`
Success! Created mobilewright project.

From this directory, you can run:
npx mobilewright test
${runCommand(packageManager, "test")}
Runs your tests. Needs a booted simulator/emulator or a connected device.
${runCommand(packageManager, "test --list")}
Lists the tests without running them.
${runCommand(packageManager, "doctor")}
Checks your setup.

Visit https://mobilewright.dev for more information.`);

Expand All @@ -184,12 +191,13 @@ async function main() {
const targetDir = process.cwd();
const existingPkg = readPackageJson(path.join(targetDir, "package.json")) ?? {};
const runners = detectOtherTestRunners(targetDir, existingPkg);
const packageManager = detectPackageManager(targetDir, existingPkg.packageManager);
const answers = await askQuestions(targetDir, detectApps(targetDir), chooseDefaultTestDir(targetDir, runners));

const validation = validateTestDir(targetDir, answers.testDir);
if (validation !== true) throw new UserFacingError(validation);

installDependencies(targetDir, answers.language);
installDependencies(targetDir, answers.language, packageManager);
writeProjectFiles(targetDir, answers);

const problem = findInstallProblem(targetDir);
Expand All @@ -198,7 +206,7 @@ async function main() {
process.exit(1);
}

printSuccess(runners, answers.testDir);
printSuccess(runners, answers.testDir, packageManager);
}

main().catch((error) => {
Expand Down
171 changes: 171 additions & 0 deletions src/package-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import fs from "fs";
import os from "os";
import path from "path";

export type PackageManager = "npm" | "pnpm" | "yarn" | "yarn-classic" | "bun";

type Lockfile = {
file: string;
packageManager: PackageManager;
};

const LOCKFILES: Lockfile[] = [
{ file: "pnpm-lock.yaml", packageManager: "pnpm" },
{ file: "bun.lock", packageManager: "bun" },
{ file: "bun.lockb", packageManager: "bun" },
{ file: "yarn.lock", packageManager: "yarn" },
{ file: "package-lock.json", packageManager: "npm" },
];

function isYarnClassic(targetDir: string, version: string | undefined): boolean {
// berry keeps its settings in .yarnrc.yml; yarn 1 has no such file
if (fs.existsSync(path.join(targetDir, ".yarnrc.yml"))) return false;
return version === undefined || version.startsWith("0.") || version.startsWith("1.");
}

/**
* The project directory and the workspace roots above it. Stops at the repository root and
* never leaves the home directory, so a stray lockfile in $HOME can't decide for a project.
*/
function ancestorDirs(dir: string, homeDir: string): string[] {
const dirs: string[] = [];
for (let current = dir; ; current = path.dirname(current)) {
dirs.push(current);
const parent = path.dirname(current);
if (fs.existsSync(path.join(current, ".git")) || current === homeDir || parent === current || parent === homeDir) {
return dirs;
}
}
}

function readPackageManagerField(dir: string): string | undefined {
try {
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")).packageManager;
} catch {
return undefined;
}
}

function resolveYarn(dir: string, version: string | undefined): PackageManager {
return isYarnClassic(dir, version) ? "yarn-classic" : "yarn";
}

/** A package inside a workspace has no lockfile of its own; the workspace root above it does. */
function fromProject(dir: string, packageManagerField: string | undefined): PackageManager | undefined {
const declared = (packageManagerField ?? readPackageManagerField(dir))?.match(/^(npm|pnpm|yarn|bun)@?(\S+)?/);
const declaredYarnVersion = declared?.[1] === "yarn" ? declared[2] : undefined;

const lockfile = LOCKFILES.find((candidate) => fs.existsSync(path.join(dir, candidate.file)));
if (lockfile) return lockfile.packageManager === "yarn" ? resolveYarn(dir, declaredYarnVersion) : lockfile.packageManager;

if (!declared) return undefined;
return declared[1] === "yarn" ? resolveYarn(dir, declaredYarnVersion) : (declared[1] as PackageManager);
}

function fromUserAgent(targetDir: string, userAgent: string): PackageManager | undefined {
const match = userAgent.match(/^(npm|pnpm|yarn|bun)\/(\S+)/);
if (!match) return undefined;
const [, name, version] = match;
if (name === "yarn") return isYarnClassic(targetDir, version) ? "yarn-classic" : "yarn";
return name as PackageManager;
}

/**
* The lockfile in the project wins over the tool that started us: `npm init mobilewright`
* inside a pnpm project should still install with pnpm.
*/
export function detectPackageManager(targetDir: string, packageManagerField?: string, userAgent = process.env.npm_config_user_agent, homeDir = os.homedir()): PackageManager {
for (const dir of ancestorDirs(targetDir, homeDir)) {
const detected = fromProject(dir, dir === targetDir ? packageManagerField : undefined);
if (detected) return detected;
}
return (userAgent ? fromUserAgent(targetDir, userAgent) : undefined) ?? "npm";
}

// pnpm workspaces live in pnpm-workspace.yaml, every other manager declares them in package.json
export function isWorkspaceRoot(targetDir: string, workspacesField: unknown): boolean {
return workspacesField !== undefined || fs.existsSync(path.join(targetDir, "pnpm-workspace.yaml"));
}

// yarn 1 and pnpm refuse to add a dependency in a workspace root without this flag
function workspaceFlag(packageManager: PackageManager, isWorkspaceRoot: boolean): string {
if (!isWorkspaceRoot) return "";
if (packageManager === "pnpm") return "-w ";
return packageManager === "yarn-classic" ? "-W " : "";
}

export function installDevCommand(packageManager: PackageManager, specs: string[], isWorkspaceRoot = false): string {
const quoted = specs.map((spec) => `"${spec}"`).join(" ");
const workspace = workspaceFlag(packageManager, isWorkspaceRoot);
switch (packageManager) {
case "pnpm":
return `pnpm add --save-dev ${workspace}${quoted}`;
case "yarn":
case "yarn-classic":
return `yarn add --dev ${workspace}${quoted}`;
case "bun":
return `bun add --development ${quoted}`;
case "npm":
// --include=dev: otherwise NODE_ENV=production silently skips devDependencies
return `npm install --save-dev --include=dev ${quoted}`;
}
}

export function installProdCommand(packageManager: PackageManager, specs: string[], isWorkspaceRoot = false): string {
const quoted = specs.map((spec) => `"${spec}"`).join(" ");
const workspace = workspaceFlag(packageManager, isWorkspaceRoot);
switch (packageManager) {
case "pnpm":
return `pnpm add ${workspace}${quoted}`;
case "yarn":
case "yarn-classic":
return `yarn add ${workspace}${quoted}`;
case "bun":
return `bun add ${quoted}`;
case "npm":
return `npm install --save-prod --include=dev ${quoted}`;
}
}

export function installAllCommand(packageManager: PackageManager): string {
switch (packageManager) {
case "pnpm":
return "pnpm install";
case "yarn":
case "yarn-classic":
return "yarn install";
case "bun":
return "bun install";
case "npm":
return "npm install --include=dev";
}
}

/** How the user runs the mobilewright binary that was just installed. */
export function runCommand(packageManager: PackageManager, args: string): string {
switch (packageManager) {
case "pnpm":
return `pnpm exec mobilewright ${args}`;
case "yarn":
case "yarn-classic":
return `yarn mobilewright ${args}`;
case "bun":
return `bunx mobilewright ${args}`;
case "npm":
return `npx mobilewright ${args}`;
}
}

export function createProjectCommand(packageManager: PackageManager): string {
switch (packageManager) {
case "pnpm":
return "pnpm create mobilewright";
case "yarn":
case "yarn-classic":
return "yarn create mobilewright";
case "bun":
return "bun create mobilewright";
case "npm":
return "npm init mobilewright@latest";
}
}
13 changes: 7 additions & 6 deletions src/project.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import type { Platform } from "./detect";
import { installAllCommand, installDevCommand, installProdCommand, PackageManager } from "./package-manager";

export type Language = "ts" | "js";

Expand All @@ -11,6 +12,8 @@ export type PackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
jest?: unknown;
workspaces?: unknown;
packageManager?: string;
[key: string]: unknown;
};

Expand Down Expand Up @@ -118,14 +121,12 @@ export function planInstall(pkg: PackageJson, language: Language, nodeVersion: s
};
}

export function installCommands({ dependencies, devDependencies }: InstallPlan): string[] {
// --include=dev: otherwise NODE_ENV=production silently skips devDependencies
const quoted = (specs: string[]) => specs.map((spec) => `"${spec}"`).join(" ");
export function installCommands({ dependencies, devDependencies }: InstallPlan, packageManager: PackageManager, isWorkspaceRoot = false): string[] {
const commands = [
...(devDependencies.length > 0 ? [`npm install --save-dev --include=dev ${quoted(devDependencies)}`] : []),
...(dependencies.length > 0 ? [`npm install --save-prod --include=dev ${quoted(dependencies)}`] : []),
...(devDependencies.length > 0 ? [installDevCommand(packageManager, devDependencies, isWorkspaceRoot)] : []),
...(dependencies.length > 0 ? [installProdCommand(packageManager, dependencies, isWorkspaceRoot)] : []),
];
return commands.length > 0 ? commands : ["npm install --include=dev"];
return commands.length > 0 ? commands : [installAllCommand(packageManager)];
}

// JSON.stringify gives a valid JS string literal for any user input
Expand Down
Loading
Loading