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
55 changes: 34 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ import {
describeRunnerExclusion,
detectOtherTestRunners,
findInstallProblem,
installCommands,
isSupportedNodeVersion,
Language,
MINIMUM_NODE_VERSION,
normalizeTestDir,
patchGitignore,
planInstall,
readPackageJson,
TestRunner,
updatePackageJson,
UserFacingError,
validateTestDir,
withTestScript,
} from "./project";

type Answers = {
Expand Down Expand Up @@ -108,13 +110,32 @@ function writeFileIfMissing(filePath: string, content: string): void {
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, content);
}

function writeJson(filePath: string, value: unknown): void {
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n");
}

// 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 {
const pkgPath = path.join(targetDir, "package.json");
const existing = readPackageJson(pkgPath);
// a blank package.json would make npm fail with EJSONPARSE
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))) {
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.");
process.exit(1);
}
}
}

function writeProjectFiles(targetDir: string, answers: Answers): void {
const { language, testDir } = answers;
const validation = validateTestDir(targetDir, testDir);
if (validation !== true) throw new UserFacingError(validation);
const pkgPath = path.join(targetDir, "package.json");
const pkg = readPackageJson(pkgPath) ?? createNewPackageJson(targetDir);
fs.writeFileSync(pkgPath, JSON.stringify(updatePackageJson(pkg, language, process.versions.node), null, 2) + "\n");

fs.writeFileSync(path.join(targetDir, `mobilewright.config.${language}`), createConfigContent(answers));

Expand All @@ -129,21 +150,10 @@ function writeProjectFiles(targetDir: string, answers: Answers): void {
const gitignorePath = path.join(targetDir, ".gitignore");
const gitignore = patchGitignore(fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf-8") : undefined);
if (gitignore !== undefined) fs.writeFileSync(gitignorePath, gitignore);
}

function runNpmInstall(targetDir: string): void {
console.log("\nInstalling dependencies...\n");

try {
// --include=dev: otherwise NODE_ENV=production silently skips every devDependency
execSync("npm install --include=dev", {
cwd: targetDir,
stdio: "inherit",
});
} catch {
console.error("Failed to install dependencies.");
process.exit(1);
}
// re-read: npm rewrote package.json while installing
const pkgPath = path.join(targetDir, "package.json");
writeJson(pkgPath, withTestScript(readPackageJson(pkgPath) ?? {}));
}

function printSuccess(runners: TestRunner[], testDir: string): void {
Expand Down Expand Up @@ -176,8 +186,11 @@ async function main() {
const runners = detectOtherTestRunners(targetDir, existingPkg);
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);
writeProjectFiles(targetDir, answers);
runNpmInstall(targetDir);

const problem = findInstallProblem(targetDir);
if (problem) {
Expand Down
74 changes: 38 additions & 36 deletions src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,27 @@ export type ProjectFiles = {

export type TestRunner = "playwright" | "jest" | "vitest";

export type InstallPlan = {
dependencies: string[];
devDependencies: string[];
};

export class UserFacingError extends Error {}

// mobilewright's own engines field; also the first 22.x with unflagged require(esm)
export const MINIMUM_NODE_VERSION = "22.12.0";
export const MOBILEWRIGHT_VERSION = "0.0.58";
export const TYPESCRIPT_VERSION = "^5.9.3";
export const DEFAULT_TEST_DIR = "tests";
export const ISOLATED_TEST_DIR = "mobile-tests";

// odd releases don't always get their own @types/node major (there is no @types/node 23)
// ponytail: static list, add new majors as they are published; newer node falls back to the newest known
const PUBLISHED_TYPES_NODE_MAJORS = [22, 24, 25, 26];

const MOBILEWRIGHT_PACKAGES = ["mobilewright", "@mobilewright/test"];
// only a semver range or a dist-tag comes from the registry; anything with ":", "/" or "@"
// (workspace:, file:, git urls, git@host:repo, owner/repo, ../path, npm: aliases) is a custom build, never replace it
const REGISTRY_SPEC = /^[\w.^~<>=*|\s-]*$/;

const NPM_PLACEHOLDER_TEST_SCRIPT = "no test specified";
const MOBILEWRIGHT_TEST_SCRIPT = "mobilewright test";

Expand Down Expand Up @@ -75,38 +83,11 @@ export function createNewPackageJson(targetDir: string): PackageJson {
};
}

function parseVersion(spec: string): number[] | undefined {
const match = spec.match(/^[\^~>=v\s]*(\d+)\.(\d+)\.(\d+)/);
return match ? match.slice(1, 4).map(Number) : undefined;
}

function isOlderVersion(existingSpec: string, version: string): boolean {
const existing = parseVersion(existingSpec);
const wanted = parseVersion(version);
// non-semver specs (latest, file:, workspace:, git urls) are a deliberate user choice
if (!existing || !wanted) return false;
for (let i = 0; i < 3; i++) {
if (existing[i] !== wanted[i]) return existing[i] < wanted[i];
}
return false;
}

export function addDevDependency(pkg: PackageJson, name: string, version: string): PackageJson {
const field = pkg.dependencies?.[name] !== undefined ? "dependencies" : "devDependencies";
const existing = pkg[field]?.[name];
if (existing !== undefined && !isOlderVersion(existing, version)) return pkg;
return { ...pkg, [field]: { ...(pkg[field] ?? {}), [name]: version } };
}

function hasDependency(pkg: PackageJson, name: string): boolean {
return pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined;
}

function addDevDependencyIfMissing(pkg: PackageJson, name: string, version: string): PackageJson {
return hasDependency(pkg, name) ? pkg : addDevDependency(pkg, name, version);
}

function withTestScript(pkg: PackageJson): PackageJson {
export function withTestScript(pkg: PackageJson): PackageJson {
const current = pkg.scripts?.test;
if (current !== undefined && !current.includes(NPM_PLACEHOLDER_TEST_SCRIPT)) return pkg;
return { ...pkg, scripts: { ...(pkg.scripts ?? {}), test: MOBILEWRIGHT_TEST_SCRIPT } };
Expand All @@ -118,12 +99,33 @@ export function typesNodeRange(nodeVersion: string): string {
return `^${candidates.length > 0 ? Math.max(...candidates) : PUBLISHED_TYPES_NODE_MAJORS[0]}`;
}

export function updatePackageJson(pkg: PackageJson, language: Language, nodeVersion: string): PackageJson {
const withMobilewright = addDevDependency(addDevDependency(pkg, "@mobilewright/test", MOBILEWRIGHT_VERSION), "mobilewright", MOBILEWRIGHT_VERSION);
const withTypes = language === "ts"
? addDevDependencyIfMissing(addDevDependencyIfMissing(withMobilewright, "@types/node", typesNodeRange(nodeVersion)), "typescript", TYPESCRIPT_VERSION)
: withMobilewright;
return withTestScript(withTypes);
// like create-playwright, nothing is pinned here. @latest is explicit because a bare name
// keeps whatever range package.json already has (e.g. ^0.0.45 would never be upgraded)
export function planInstall(pkg: PackageJson, language: Language, nodeVersion: string): InstallPlan {
const isRegistrySpec = (spec: string | undefined) => spec === undefined || REGISTRY_SPEC.test(spec);
const mobilewright = MOBILEWRIGHT_PACKAGES.filter((name) => isRegistrySpec(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]));
const latest = (names: string[]) => names.map((name) => `${name}@latest`);
const typescriptPackages = language === "ts"
? [
...(hasDependency(pkg, "@types/node") ? [] : [`@types/node@${typesNodeRange(nodeVersion)}`]),
...(hasDependency(pkg, "typescript") ? [] : ["typescript"]),
]
: [];
return {
// a package the project already lists under dependencies stays there
dependencies: latest(mobilewright.filter((name) => pkg.dependencies?.[name] !== undefined)),
devDependencies: [...latest(mobilewright.filter((name) => pkg.dependencies?.[name] === undefined)), ...typescriptPackages],
};
}

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(" ");
const commands = [
...(devDependencies.length > 0 ? [`npm install --save-dev --include=dev ${quoted(devDependencies)}`] : []),
...(dependencies.length > 0 ? [`npm install --save-prod --include=dev ${quoted(dependencies)}`] : []),
];
return commands.length > 0 ? commands : ["npm install --include=dev"];
}

// JSON.stringify gives a valid JS string literal for any user input
Expand Down
98 changes: 57 additions & 41 deletions test/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ import {
findInstallProblem,
isSupportedNodeVersion,
ISOLATED_TEST_DIR,
MOBILEWRIGHT_VERSION,
installCommands,
normalizeTestDir,
PackageJson,
patchGitignore,
readPackageJson,
planInstall,
typesNodeRange,
updatePackageJson,
UserFacingError,
validateTestDir,
withTestScript,
} from "../src/project";
import { createTempDir, installFakePackage, writeFile } from "./helpers";

Expand All @@ -31,16 +32,18 @@ function assertRejectedWithMessage(pkgPath: string, message: RegExp): void {
assert.throws(() => readPackageJson(pkgPath), (error: unknown) => error instanceof UserFacingError && message.test(error.message));
}

function scaffoldedPackageJson(pkg: PackageJson): PackageJson {
return updatePackageJson(pkg, "ts", "24.21.0");
const ANY_MOBILEWRIGHT_VERSION = "0.0.59";

function typescriptInstallFor(pkg: PackageJson) {
return planInstall(pkg, "ts", "24.21.0");
}

function installMobilewrightWithOnePlaywright(projectDir: string): void {
installFakePackage(projectDir, { name: "playwright", version: "1.63.0" });
installFakePackage(projectDir, { name: "@playwright/test", version: "1.63.0" });
// like the real packages, "exports" hides ./package.json from require.resolve
installFakePackage(projectDir, { name: "mobilewright", version: MOBILEWRIGHT_VERSION, exports: { ".": "./dist/index.js" } });
installFakePackage(projectDir, { name: "@mobilewright/test", version: MOBILEWRIGHT_VERSION, exports: { ".": "./dist/index.js" } });
installFakePackage(projectDir, { name: "mobilewright", version: ANY_MOBILEWRIGHT_VERSION, exports: { ".": "./dist/index.js" } });
installFakePackage(projectDir, { name: "@mobilewright/test", version: ANY_MOBILEWRIGHT_VERSION, exports: { ".": "./dist/index.js" } });
}

test("node versions older than 22.12 are rejected before any prompt", () => {
Expand Down Expand Up @@ -80,47 +83,54 @@ test("new package.json gets a valid name and is private", () => {
});

test("new package.json gets a test script", () => {
assert.equal(scaffoldedPackageJson(createNewPackageJson("/work/app")).scripts?.test, "mobilewright test");
assert.equal(withTestScript(createNewPackageJson("/work/app")).scripts?.test, "mobilewright test");
});

test("npm's placeholder test script is replaced, a real one is kept", () => {
const placeholder = { scripts: { test: 'echo "Error: no test specified" && exit 1' } };
assert.equal(scaffoldedPackageJson(placeholder).scripts?.test, "mobilewright test");
assert.equal(scaffoldedPackageJson({ scripts: { test: "jest" } }).scripts?.test, "jest");
assert.equal(withTestScript(placeholder).scripts?.test, "mobilewright test");
assert.equal(withTestScript({ scripts: { test: "jest" } }).scripts?.test, "jest");
});

test("dependencies: a newer mobilewright is never downgraded", () => {
const result = scaffoldedPackageJson({ devDependencies: { mobilewright: "99.0.0", "@mobilewright/test": "^99.1.0" } });
assert.equal(result.devDependencies?.mobilewright, "99.0.0");
assert.equal(result.devDependencies?.["@mobilewright/test"], "^99.1.0");
test("install: mobilewright is installed @latest, never a pinned version", () => {
assert.deepEqual(planInstall({}, "js", "24.21.0"), { dependencies: [], devDependencies: ["mobilewright@latest", "@mobilewright/test@latest"] });
});

test("dependencies: an older mobilewright is upgraded", () => {
assert.equal(scaffoldedPackageJson({ devDependencies: { mobilewright: "^0.0.45" } }).devDependencies?.mobilewright, MOBILEWRIGHT_VERSION);
test("install: TypeScript projects also get typescript and @types/node for the running node", () => {
assert.deepEqual(typescriptInstallFor({}).devDependencies, ["mobilewright@latest", "@mobilewright/test@latest", "@types/node@^24", "typescript"]);
});

test("dependencies: non-semver specs like workspace:* or latest are left alone", () => {
const result = scaffoldedPackageJson({ devDependencies: { mobilewright: "workspace:*", "@mobilewright/test": "latest" } });
assert.equal(result.devDependencies?.mobilewright, "workspace:*");
assert.equal(result.devDependencies?.["@mobilewright/test"], "latest");
test("install: an older mobilewright in devDependencies is upgraded to the latest", () => {
assert.deepEqual(planInstall({ devDependencies: { mobilewright: "^0.0.45" } }, "js", "24.21.0").devDependencies, ["mobilewright@latest", "@mobilewright/test@latest"]);
});

test("dependencies: a package already in dependencies is not duplicated into devDependencies", () => {
const result = scaffoldedPackageJson({ dependencies: { mobilewright: "0.0.45" } });
assert.equal(result.dependencies?.mobilewright, MOBILEWRIGHT_VERSION);
assert.equal(result.devDependencies?.mobilewright, undefined);
test("install: a package already in dependencies stays in dependencies", () => {
const plan = planInstall({ dependencies: { mobilewright: "0.0.45" } }, "js", "24.21.0");
assert.deepEqual(plan, { dependencies: ["mobilewright@latest"], devDependencies: ["@mobilewright/test@latest"] });
});

test("dependencies: existing @types/node and typescript ranges are kept", () => {
const result = scaffoldedPackageJson({ devDependencies: { "@types/node": "^22.0.0", typescript: "^4.9.5" } });
assert.equal(result.devDependencies?.["@types/node"], "^22.0.0");
assert.equal(result.devDependencies?.typescript, "^4.9.5");
test("install: local, git and aliased specs are never replaced", () => {
const customBuilds = [
"workspace:*", "file:../mobilewright/packages/test", "link:../mobilewright", "portal:../mobilewright",
"git+ssh://git@github.com/mobile-next/mobilewright.git", "git@github.com:mobile-next/mobilewright.git",
"github:mobile-next/mobilewright", "gitlab:mobile-next/mobilewright", "bitbucket:mobile-next/mobilewright",
"gist:11081aaa281", "mobile-next/mobilewright#main", "../mobilewright", "https://example.com/mobilewright.tgz",
"npm:mobilewright-fork@1.0.0",
];
for (const spec of customBuilds) {
assert.deepEqual(planInstall({ devDependencies: { mobilewright: spec } }, "js", "24.21.0").devDependencies, ["@mobilewright/test@latest"], spec);
}
});

test("dependencies: TypeScript projects get typescript and @types/node matching the running node major", () => {
const result = scaffoldedPackageJson({});
assert.equal(result.devDependencies?.["@types/node"], "^24");
assert.ok(result.devDependencies?.typescript);
test("install: registry versions and tags are upgraded to the latest", () => {
for (const spec of ["", "latest", "next", "*", "0.0.45", "^0.0.45", "~1.2.3", ">=1.0.0 <2.0.0", "1.x || 2.x"]) {
assert.deepEqual(planInstall({ devDependencies: { mobilewright: spec } }, "js", "24.21.0").devDependencies, ["mobilewright@latest", "@mobilewright/test@latest"], spec);
}
});

test("install: existing @types/node and typescript are kept", () => {
const plan = typescriptInstallFor({ devDependencies: { "@types/node": "^22.0.0", typescript: "^4.9.5" } });
assert.deepEqual(plan.devDependencies, ["mobilewright@latest", "@mobilewright/test@latest"]);
});

test("dependencies: @types/node uses a published major (there is no @types/node 23)", () => {
Expand All @@ -131,16 +141,22 @@ test("dependencies: @types/node uses a published major (there is no @types/node
assert.equal(typesNodeRange("v27.0.0"), "^26");
});

test("dependencies: JavaScript projects don't get TypeScript packages", () => {
const result = updatePackageJson({}, "js", "24.21.0");
assert.equal(result.devDependencies?.typescript, undefined);
assert.equal(result.devDependencies?.["@types/node"], undefined);
test("install: commands quote every spec and never use a pinned mobilewright version", () => {
assert.deepEqual(installCommands({ dependencies: ["mobilewright"], devDependencies: ["@mobilewright/test", "@types/node@^24"] }), [
'npm install --save-dev --include=dev "@mobilewright/test" "@types/node@^24"',
'npm install --save-prod --include=dev "mobilewright"',
]);
});

test("install: nothing to add still runs npm install so existing dependencies are present", () => {
assert.deepEqual(installCommands({ dependencies: [], devDependencies: [] }), ["npm install --include=dev"]);
});

test("dependencies: the input package.json object is not mutated", () => {
const pkg: PackageJson = { devDependencies: { other: "1.0.0" } };
scaffoldedPackageJson(pkg);
assert.deepEqual(pkg, { devDependencies: { other: "1.0.0" } });
test("install: the input package.json object is not mutated", () => {
const pkg: PackageJson = { devDependencies: { other: "1.0.0" }, scripts: { test: "echo no test specified" } };
planInstall(pkg, "ts", "24.21.0");
withTestScript(pkg);
assert.deepEqual(pkg, { devDependencies: { other: "1.0.0" }, scripts: { test: "echo no test specified" } });
});

test("config: always sets the platform", () => {
Expand Down Expand Up @@ -224,9 +240,9 @@ test("install check: reports two Playwright copies caused by the project's own p
const dir = createTempDir();
installFakePackage(dir, { name: "playwright", version: "1.45.3" });
installFakePackage(dir, { name: "@playwright/test", version: "1.45.3" });
const mobilewright = installFakePackage(dir, { name: "mobilewright", version: MOBILEWRIGHT_VERSION });
const mobilewright = installFakePackage(dir, { name: "mobilewright", version: ANY_MOBILEWRIGHT_VERSION });
installFakePackage(mobilewright, { name: "playwright", version: "1.63.0" });
const mobilewrightTest = installFakePackage(dir, { name: "@mobilewright/test", version: MOBILEWRIGHT_VERSION });
const mobilewrightTest = installFakePackage(dir, { name: "@mobilewright/test", version: ANY_MOBILEWRIGHT_VERSION });
const nestedPlaywrightTest = installFakePackage(mobilewrightTest, { name: "@playwright/test", version: "1.63.0" });
installFakePackage(nestedPlaywrightTest, { name: "playwright", version: "1.63.0" });

Expand Down