diff --git a/src/index.ts b/src/index.ts index b726efc..d058e8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = { @@ -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)); @@ -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 { @@ -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) { diff --git a/src/project.ts b/src/project.ts index fa7ca31..6aa5185 100644 --- a/src/project.ts +++ b/src/project.ts @@ -23,12 +23,15 @@ 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"; @@ -36,6 +39,11 @@ export const ISOLATED_TEST_DIR = "mobile-tests"; // 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"; @@ -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 } }; @@ -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 diff --git a/test/project.test.ts b/test/project.test.ts index d3e6d74..c8ff8d2 100644 --- a/test/project.test.ts +++ b/test/project.test.ts @@ -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"; @@ -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", () => { @@ -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)", () => { @@ -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", () => { @@ -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" });