diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 224ce34c..012de768 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ on: jobs: build: # Named explicitly so that `experimental` stays out of the job name. - name: "build (${{ matrix.ruby }}, ${{ matrix.rails }}, ${{ matrix.ember }})" + name: "build (${{ matrix.ruby }}, ${{ matrix.rails }}, ${{ matrix.ember }}, ${{ matrix.package_manager }})" runs-on: "ubuntu-latest" # Jobs normally finish in about 3 minutes; a hung job once ran for @@ -28,23 +28,47 @@ jobs: # dummy app. `>= 6.8` uses the Vite-based blueprint, older # versions use the classic Broccoli-based build. ember: ["7.0.0"] + # `package_manager` installs the dummy app's NodeJS dependencies: + # `npm`, `yarn`, or `pnpm`. + package_manager: ["npm"] include: - ruby: "4.0" rails: "main" ember: "7.0.0" + package_manager: "npm" experimental: true # Classic (Broccoli-based) blueprint coverage - ruby: "3.4" rails: "8.1" ember: "5.12.0" + package_manager: "npm" - ruby: "3.4" rails: "8.1" ember: "6.7.2" + package_manager: "npm" + # Yarn and pnpm coverage, for both blueprints + - ruby: "3.4" + rails: "8.1" + ember: "7.0.0" + package_manager: "yarn" + - ruby: "3.4" + rails: "8.1" + ember: "5.12.0" + package_manager: "yarn" + - ruby: "3.4" + rails: "8.1" + ember: "7.0.0" + package_manager: "pnpm" + - ruby: "3.4" + rails: "8.1" + ember: "5.12.0" + package_manager: "pnpm" env: RAILS_ENV: "test" RAILS_VERSION: "${{ matrix.rails }}" EMBER_VERSION: "${{ matrix.ember }}" + PACKAGE_MANAGER: "${{ matrix.package_manager }}" steps: - uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v7.0.1 @@ -54,6 +78,16 @@ jobs: with: node-version: "22.x" + - name: "Install Yarn" + if: "matrix.package_manager == 'yarn'" + run: | + npm install -g yarn@1 + + - name: "Install pnpm" + if: "matrix.package_manager == 'pnpm'" + run: | + npm install -g pnpm@latest-10 + - name: "Install Ruby ${{ matrix.ruby }}" uses: "ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b" # v1.321.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d002f3af..fb5fdcaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ main ------ +* Add the `package_manager` option to install an application's NodeJS + dependencies with pnpm (`package_manager: :pnpm`), yarn, or npm. `pnpm_path` + implies pnpm the way `yarn_path` implies yarn. The package manager is read + through the new `EmberCli::App#package_manager` and `EmberCli::App#pnpm?` +* Deprecate `yarn: true` in favour of `package_manager: :yarn`. The shorthand + still selects yarn, with a deprecation warning, and will be removed in `1.0` +* Identify a project with a pnpm application to Heroku's NodeJS buildpack + from `rails generate ember:heroku`, with a root-level `pnpm-lock.yaml`, and + pin the package manager version in the generated `package.json` from the + `packageManager` the Ember applications declare, read through the new + `EmberCli::App#package_manager_spec` * Stop adding `rails_12factor` to the `Gemfile` from `rails generate ember:heroku`. Rails serves the twelve-factor behaviour the gem backported natively since `5.0`, and every Rails version this gem supports is `>= 5.2`. diff --git a/README.md b/README.md index be12a813..650fabba 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,10 @@ c.app :frontend, path: "~/projects/my-ember-app" - `silent` - this provides `--silent` option for Ember CLI commands to control verbosity of their output. -- `yarn` - enables the [yarn](https://github.com/yarnpkg/yarn) package manager when installing dependencies +- `package_manager` - the package manager that installs the application's + NodeJS dependencies: `:npm` (the default), `:yarn`, or `:pnpm`. Name the + executable with `npm_path`, `yarn_path`, or `pnpm_path` when it is not on + the `$PATH`. - `dev_server` - configures [Vite's development server](#vite-based-applications) for Vite-based applications in `development`. Pass `false` to opt out of it, @@ -388,6 +391,14 @@ writes to the build directory, and Rails serves the result. **NOTE** Run the generator each time you introduce additional EmberCLI applications into the project. +**Package manager** — the buildpack installs the package manager the *project +root's* lockfile names. The generator writes an empty `yarn.lock` or +`pnpm-lock.yaml` when an application is configured with yarn or pnpm as its +`package_manager`, and copies the `packageManager` field the Ember +applications declare into the generated `package.json` so that the buildpack +installs that version. Without a `packageManager` field the buildpack installs +the latest pnpm release. + **NodeJS version** — the buildpack reads `engines.node` from the *project root's* `package.json` (the file the generator writes), not from the Ember application's, and builds on the current LTS release when it names no version. @@ -411,8 +422,8 @@ A build-pack solution for this is discussed in [Issue #491][#491]. EmberCLI-Rails installs the Ember application's NodeJS dependencies during EmberCLI's compilation, triggered by the `assets:precompile` rake task. It runs -`npm install`, or `yarn install` for an application configured with the `yarn` -option. +`npm install`, `yarn install`, or `pnpm install`, as the application's +`package_manager` option names. The executables it runs are required to be defined in the deployment SSH session's `$PATH`. It is not sufficient to modify the session's `$PATH` in a diff --git a/bin/setup_ember b/bin/setup_ember index 25a8f808..3b4e0b1d 100755 --- a/bin/setup_ember +++ b/bin/setup_ember @@ -7,6 +7,36 @@ set -e # classic Broccoli-based build. EMBER_VERSION="${EMBER_VERSION:-7.0.0}" +# PACKAGE_MANAGER selects the package manager that installs the app's NodeJS +# dependencies: `npm` (the default), `yarn`, or `pnpm`. The dummy +# application's initializer reads the same variable, so that +# `ember-cli-rails` is configured with the package manager that populated +# `node_modules`. +PACKAGE_MANAGER="${PACKAGE_MANAGER:-npm}" + +case "$PACKAGE_MANAGER" in + npm|yarn|pnpm) + ;; + *) + echo "Unsupported PACKAGE_MANAGER '${PACKAGE_MANAGER}'; use npm, yarn, or pnpm" >&2 + exit 1 + ;; +esac + +add_dev_dependency() { + case "$PACKAGE_MANAGER" in + yarn) + yarn add --dev "$@" + ;; + pnpm) + pnpm add --save-dev "$@" + ;; + *) + npm install --save-dev "$@" + ;; + esac +} + setup_ember() { local target="${1-spec/dummy/my-app}" @@ -30,13 +60,13 @@ setup_ember() { if [ -f "$target/vite.config.mjs" ]; then # `ember-cli-rails-addon` is incompatible with the Vite-based # blueprint, and `ember-cli-rails` does not require it there. - echo '-- Install NPM dependencies' + echo "-- Install NodeJS dependencies with ${PACKAGE_MANAGER}" (cd $target && - npm install) + $PACKAGE_MANAGER install) else - echo '-- Install ember-cli-rails-addon' + echo "-- Install ember-cli-rails-addon with ${PACKAGE_MANAGER}" (cd $target && - npm install --save-dev ember-cli-rails-addon@rondale-sc/ember-cli-rails-addon) + add_dev_dependency ember-cli-rails-addon@rondale-sc/ember-cli-rails-addon) fi if [ -f "bower.json" ]; then diff --git a/lib/ember_cli.rb b/lib/ember_cli.rb index e8c8976e..dad100f8 100644 --- a/lib/ember_cli.rb +++ b/lib/ember_cli.rb @@ -1,4 +1,5 @@ require "fileutils" +require "active_support/deprecation" require "ember-cli-rails-assets" require "ember_cli/engine" require "ember_cli/configuration" @@ -16,6 +17,10 @@ def configuration Configuration.instance end + def deprecator + @deprecator ||= ActiveSupport::Deprecation.new("1.0", "ember-cli-rails") + end + def app(name) apps.fetch(name) do fail KeyError, "#{name.inspect} app is not defined" diff --git a/lib/ember_cli/app.rb b/lib/ember_cli/app.rb index 9745d06a..dab8aba0 100644 --- a/lib/ember_cli/app.rb +++ b/lib/ember_cli/app.rb @@ -93,22 +93,31 @@ def mountable? deploy.mountable? end + def package_manager + paths.package_manager + end + def yarn? paths.yarn? end + def pnpm? + paths.pnpm? + end + def bower? paths.bower_json.exist? end def node_engine - package_json = paths.package_json + package_json_value("engines", "node") + end - if package_json.exist? - JSON.parse(package_json.read).dig("engines", "node") - end - rescue JSON::ParserError - nil + # The `packageManager` field of the application's `package.json`, such as + # `"pnpm@10.0.0"`: the package manager version Corepack and Heroku's + # NodeJS buildpack install. + def package_manager_spec + package_json_value("packageManager") end def to_rack @@ -135,6 +144,16 @@ def dev_server? private + def package_json_value(*keys) + package_json = paths.package_json + + if package_json.exist? + JSON.parse(package_json.read).dig(*keys) + end + rescue JSON::ParserError + nil + end + def development? env.to_s == "development" end diff --git a/lib/ember_cli/engine.rb b/lib/ember_cli/engine.rb index 884e6946..c1c41c50 100644 --- a/lib/ember_cli/engine.rb +++ b/lib/ember_cli/engine.rb @@ -3,5 +3,13 @@ class Engine < Rails::Engine initializer "ember-cli-rails.setup" do require "ember_cli/route_helpers" end + + # Rails 7.1 and later manage the deprecators of engines with the + # application's own, so that `config.active_support.deprecation` applies. + initializer "ember-cli-rails.deprecator" do |app| + if app.respond_to?(:deprecators) + app.deprecators[:ember_cli_rails] = EmberCli.deprecator + end + end end end diff --git a/lib/ember_cli/path_set.rb b/lib/ember_cli/path_set.rb index e3936880..1a261754 100644 --- a/lib/ember_cli/path_set.rb +++ b/lib/ember_cli/path_set.rb @@ -2,6 +2,15 @@ module EmberCli class PathSet + PACKAGE_MANAGERS = %i[npm yarn pnpm].freeze + + # npm ships with NodeJS, so it has no installation instructions of its own + # to point at when its executable is missing. + INSTALL_INSTRUCTIONS = { + yarn: "https://yarnpkg.com/lang/en/docs/install/", + pnpm: "https://pnpm.io/installation", + }.freeze + def initialize(app:, rails_root:, ember_cli_root:, environment:) @app = app @rails_root = rails_root @@ -121,16 +130,13 @@ def npm def yarn if yarn? - @yarn ||= path_for_executable("yarn").tap do |yarn| - unless File.executable?(yarn.to_s) - fail DependencyError.new(<<-MSG.strip_heredoc) - EmberCLI has been configured to install NodeJS dependencies with Yarn, but the Yarn executable is unavailable. - - Install it by following the instructions at https://yarnpkg.com/lang/en/docs/install/ + @yarn ||= package_manager_executable(:yarn) + end + end - MSG - end - end + def pnpm + if pnpm? + @pnpm ||= package_manager_executable(:pnpm) end end @@ -138,8 +144,45 @@ def node_modules @node_modules ||= root.join("node_modules") end + # The package manager that installs the application's NodeJS + # dependencies: the one the `package_manager` option names, else the one + # whose executable a `yarn_path` or `pnpm_path` option names, else npm. + # The deprecated `yarn: true` still selects yarn, with a warning. + def package_manager + @package_manager ||= begin + requested = app_options[:package_manager] + + if requested.present? + requested.to_s.to_sym.tap do |name| + unless PACKAGE_MANAGERS.include?(name) + fail ArgumentError, + "Unsupported package manager #{requested.inspect} for " \ + "`#{app_name}`; use one of #{PACKAGE_MANAGERS.inspect}" + end + end + elsif app_options[:yarn].present? + EmberCli.deprecator.warn( + "The `yarn` option of the `#{app_name}` Ember application is " \ + "deprecated; configure it with `package_manager: :yarn` instead", + ) + + :yarn + elsif app_options[:yarn_path].present? + :yarn + elsif app_options[:pnpm_path].present? + :pnpm + else + :npm + end + end + end + def yarn? - app_options[:yarn].present? || app_options[:yarn_path].present? + package_manager == :yarn + end + + def pnpm? + package_manager == :pnpm end def tee @@ -169,11 +212,16 @@ def path_for_executable(command) end end - def package_manager - if yarn? - "yarn" - else - "npm" + def package_manager_executable(name) + path_for_executable(name.to_s).tap do |path| + unless File.executable?(path.to_s) + fail DependencyError.new(<<-MSG.strip_heredoc) + EmberCLI has been configured to install NodeJS dependencies with #{name}, but the #{name} executable is unavailable. + + Install it by following the instructions at #{INSTALL_INSTRUCTIONS.fetch(name)} + + MSG + end end end diff --git a/lib/ember_cli/shell.rb b/lib/ember_cli/shell.rb index fa6aef26..35092a26 100644 --- a/lib/ember_cli/shell.rb +++ b/lib/ember_cli/shell.rb @@ -69,8 +69,11 @@ def install clean_ember_dependencies! end - if paths.yarn + case paths.package_manager + when :yarn run! "#{paths.yarn} install" + when :pnpm + run! "#{paths.pnpm} install" else run! "#{paths.npm} prune && #{paths.npm} install" end diff --git a/lib/generators/ember/heroku/heroku_generator.rb b/lib/generators/ember/heroku/heroku_generator.rb index 9378096e..4cf1e864 100644 --- a/lib/generators/ember/heroku/heroku_generator.rb +++ b/lib/generators/ember/heroku/heroku_generator.rb @@ -14,26 +14,46 @@ def identify_as_yarn_project end end + def identify_as_pnpm_project + if EmberCli.any?(&:pnpm?) + template "pnpm-lock.yaml.erb", "pnpm-lock.yaml" + end + end + private def node_engine - return @node_engine if defined?(@node_engine) - - declared = apps.map(&:node_engine).compact.uniq - - @node_engine = - if declared.size > 1 - say_status( - :conflict, - "Ember applications declare different `engines.node` " \ - "(#{declared.join(", ")}); pin one in package.json by hand", - :red, - ) - - nil - else - declared.first - end + unless defined?(@node_engine) + @node_engine = shared_declaration("engines.node", &:node_engine) + end + + @node_engine + end + + def package_manager_spec + unless defined?(@package_manager_spec) + @package_manager_spec = shared_declaration("packageManager", &:package_manager_spec) + end + + @package_manager_spec + end + + # The value every Ember application declares for a `package.json` field, or nil when they declare different ones: + # the generated `package.json` can only carry one, and picking either would silently break the other application's build. + def shared_declaration(field) + declared = apps.map { |app| yield app }.compact.uniq + + if declared.size > 1 + say_status( + :conflict, + "Ember applications declare different `#{field}` (#{declared.join(", ")}); pin one in package.json by hand", + :red, + ) + + nil + else + declared.first + end end def cache_directories diff --git a/lib/generators/ember/heroku/templates/package.json.erb b/lib/generators/ember/heroku/templates/package.json.erb index f9845a0c..2a4a3cec 100644 --- a/lib/generators/ember/heroku/templates/package.json.erb +++ b/lib/generators/ember/heroku/templates/package.json.erb @@ -4,6 +4,9 @@ "bower": "*" }, <% end %> + <% if package_manager_spec %> + "packageManager": <%= package_manager_spec.to_json %>, + <% end %> <% if node_engine %> "engines": { "node": <%= node_engine.to_json %> diff --git a/lib/generators/ember/heroku/templates/pnpm-lock.yaml.erb b/lib/generators/ember/heroku/templates/pnpm-lock.yaml.erb new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/lib/generators/ember/heroku/templates/pnpm-lock.yaml.erb @@ -0,0 +1 @@ + diff --git a/spec/dummy/config/initializers/ember.rb b/spec/dummy/config/initializers/ember.rb index 41c2017c..8cdd6a0b 100644 --- a/spec/dummy/config/initializers/ember.rb +++ b/spec/dummy/config/initializers/ember.rb @@ -1,5 +1,9 @@ +# `bin/setup_ember` installs the applications' dependencies with the package +# manager PACKAGE_MANAGER names, so register the same one here. +package_manager = ENV.fetch("PACKAGE_MANAGER", "npm").to_sym + EmberCli.configure do |c| - c.app "my-app" + c.app "my-app", package_manager: package_manager # The same Ember application, served by Vite's development server. # @@ -15,6 +19,7 @@ # leaves the page blank. c.app "my-app-dev-server", path: "my-app-dev-server", + package_manager: package_manager, deploy: { test: EmberCli::Deploy::DevServer }, dev_server: { timeout: 120 } end diff --git a/spec/generators/ember/heroku/heroku_generator_spec.rb b/spec/generators/ember/heroku/heroku_generator_spec.rb index 0b07423f..f9b38051 100644 --- a/spec/generators/ember/heroku/heroku_generator_spec.rb +++ b/spec/generators/ember/heroku/heroku_generator_spec.rb @@ -8,7 +8,7 @@ context "without yarn enabled" do it "does not generate a root-level yarn.lock" do setup_destination - configure_application(yarn: false) + configure_application(package_manager: :npm) run_generator @@ -21,7 +21,7 @@ context "with yarn enabled" do it "generates a root-level yarn.lock" do setup_destination - configure_application(yarn: true) + configure_application(package_manager: :yarn) run_generator @@ -31,6 +31,32 @@ end end + context "without pnpm enabled" do + it "does not generate a root-level pnpm-lock.yaml" do + setup_destination + configure_application(package_manager: :npm) + + run_generator + + expect(destination_root).to have_structure { + no_file "pnpm-lock.yaml" + } + end + end + + context "with pnpm enabled" do + it "generates a root-level pnpm-lock.yaml" do + setup_destination + configure_application(package_manager: :pnpm) + + run_generator + + expect(destination_root).to have_structure { + file "pnpm-lock.yaml" + } + end + end + describe "Gemfile" do it "leaves the Gemfile untouched" do setup_destination @@ -112,22 +138,57 @@ def gemfile_contents expect(package_json.keys).not_to include("engines") end + end - def configure_applications(*attributes) - apps = attributes.map do |app_attributes| - instance_double( - EmberCli::App, - { - bower?: false, - cached_directories: [], - yarn?: false, - }.merge(app_attributes), - ) - end - - allow(EmberCli).to receive(:apps). - and_return(apps.map.with_index { |app, i| ["app-#{i}", app] }.to_h) + describe "packageManager" do + it "pins the package manager the Ember applications declare" do + setup_destination + configure_applications(package_manager_spec: "pnpm@10.0.0") + + run_generator + + expect(package_json.fetch("packageManager")).to eq("pnpm@10.0.0") end + + it "omits packageManager when no Ember application declares one" do + setup_destination + configure_applications(package_manager_spec: nil) + + run_generator + + expect(package_json.keys).not_to include("packageManager") + end + + it "omits packageManager when the Ember applications disagree" do + setup_destination + configure_applications( + { package_manager_spec: "pnpm@10.0.0" }, + { package_manager_spec: "pnpm@9.0.0" }, + ) + + run_generator + + expect(package_json.keys).not_to include("packageManager") + end + end + + def configure_applications(*attributes) + apps = attributes.map do |app_attributes| + instance_double( + EmberCli::App, + { + bower?: false, + cached_directories: [], + node_engine: nil, + package_manager_spec: nil, + pnpm?: false, + yarn?: false, + }.merge(app_attributes), + ) + end + + allow(EmberCli).to receive(:apps). + and_return(apps.map.with_index { |app, i| ["app-#{i}", app] }.to_h) end def depend_on_bower(bower_enabled) @@ -169,8 +230,13 @@ def package_json_contents end end + # Register only this application: the dummy project's own applications + # would otherwise take part in the generator's `EmberCli.any?` checks, with + # whatever package manager `bin/setup_ember` installed them with. def configure_application(**options) - EmberCli.configure { |c| c.app("my-app", **options) } + app = EmberCli::App.new("my-app", **options) + + allow(EmberCli).to receive(:apps).and_return("my-app" => app) end def setup_destination diff --git a/spec/lib/ember_cli/app_spec.rb b/spec/lib/ember_cli/app_spec.rb index 0ead0291..922d066e 100644 --- a/spec/lib/ember_cli/app_spec.rb +++ b/spec/lib/ember_cli/app_spec.rb @@ -34,11 +34,13 @@ end describe "#yarn?" do - context "when configured with yarn: true" do + context "when configured with the deprecated yarn: true" do it "returns true" do app = EmberCli::App.new("with-yarn", yarn: true) - expect(app.yarn?).to be true + yarn = EmberCli.deprecator.silence { app.yarn? } + + expect(yarn).to be true end end @@ -67,6 +69,34 @@ end end + describe "#package_manager" do + it "is npm by default" do + app = EmberCli::App.new("with-npm") + + expect(app.package_manager).to eq :npm + end + + it "is the package manager configured" do + app = EmberCli::App.new("with-pnpm", package_manager: :pnpm) + + expect(app.package_manager).to eq :pnpm + end + end + + describe "#pnpm?" do + it "returns true when configured with package_manager: :pnpm" do + app = EmberCli::App.new("with-pnpm", package_manager: :pnpm) + + expect(app.pnpm?).to be true + end + + it "returns false when configured with another package manager" do + app = EmberCli::App.new("with-yarn", package_manager: :yarn) + + expect(app.pnpm?).to be false + end + end + describe "#bower?" do context "when bower.json exists" do it "returns true" do @@ -129,6 +159,35 @@ def stub_package_json(contents) end end + describe "#package_manager_spec" do + it "reads packageManager from the application's package.json" do + stub_package_json('{"packageManager":"pnpm@10.0.0"}') + app = EmberCli::App.new("with package manager") + + expect(app.package_manager_spec).to eq "pnpm@10.0.0" + end + + it "returns nil when the package.json declares no packageManager" do + stub_package_json('{"name":"frontend"}') + app = EmberCli::App.new("without package manager") + + expect(app.package_manager_spec).to be_nil + end + + it "returns nil when the package.json is absent" do + stub_paths(package_json: double("Pathname", exist?: false)) + app = EmberCli::App.new("without package json") + + expect(app.package_manager_spec).to be_nil + end + + def stub_package_json(contents) + stub_paths( + package_json: double("Pathname", exist?: true, read: contents), + ) + end + end + describe "#compile" do it "exits with exit status of 0" do passed = EmberCli["my-app"].compile diff --git a/spec/lib/ember_cli/path_set_spec.rb b/spec/lib/ember_cli/path_set_spec.rb index 92d76ac6..7e737d84 100644 --- a/spec/lib/ember_cli/path_set_spec.rb +++ b/spec/lib/ember_cli/path_set_spec.rb @@ -212,9 +212,73 @@ end end + describe "#package_manager" do + it "is npm by default" do + path_set = build_path_set + + expect(path_set.package_manager).to eq :npm + end + + it "is the package manager requested" do + app = build_app(options: { package_manager: :pnpm }) + path_set = build_path_set(app: app) + + expect(path_set.package_manager).to eq :pnpm + end + + it "accepts the requested package manager as a String" do + app = build_app(options: { package_manager: "pnpm" }) + path_set = build_path_set(app: app) + + expect(path_set.package_manager).to eq :pnpm + end + + it "is yarn when the deprecated yarn shorthand is given, with a warning" do + app = build_app(options: { yarn: true }) + path_set = build_path_set(app: app) + + expect { path_set.package_manager }. + to output(/`yarn` option.*deprecated.*package_manager: :yarn/). + to_stderr + expect(path_set.package_manager).to eq :yarn + end + + it "warns about the yarn shorthand once" do + app = build_app(options: { yarn: true }) + path_set = build_path_set(app: app) + + expect(EmberCli.deprecator).to receive(:warn).once + + 2.times { path_set.package_manager } + end + + it "prefers the package manager requested over the yarn shorthand" do + app = build_app(options: { package_manager: :pnpm, yarn: true }) + path_set = build_path_set(app: app) + + expect { path_set.package_manager }.not_to output.to_stderr + expect(path_set.package_manager).to eq :pnpm + end + + it "is pnpm when only the pnpm executable is named" do + app = build_app(options: { pnpm_path: "/usr/bin/pnpm" }) + path_set = build_path_set(app: app) + + expect(path_set.package_manager).to eq :pnpm + end + + it "rejects an unsupported package manager" do + app = build_app(options: { package_manager: :bun }) + path_set = build_path_set(app: app) + + expect { path_set.package_manager }. + to raise_error(ArgumentError, /Unsupported package manager :bun/) + end + end + describe "#yarn?" do it "is true when yarn is requested" do - app = build_app(options: { yarn: true }) + app = build_app(options: { package_manager: :yarn }) path_set = build_path_set(app: app) expect(path_set).to be_yarn @@ -248,7 +312,7 @@ it "can be inferred from the $PATH" do fake_yarn = create_executable(ember_cli_root.join("yarn")) stub_which(yarn: fake_yarn.to_s) - app = build_app(options: { yarn: true }) + app = build_app(options: { package_manager: :yarn }) path_set = build_path_set(app: app) create_executable(fake_yarn) @@ -261,7 +325,7 @@ context "and yarn is requested" do it "raises a DependencyError" do stub_which(yarn: nil) - app = build_app(options: { yarn: true }) + app = build_app(options: { package_manager: :yarn }) path_set = build_path_set(app: app) expect { path_set.yarn }.to raise_error(EmberCli::DependencyError) @@ -281,6 +345,75 @@ end end + describe "#pnpm?" do + it "is true when pnpm is requested" do + app = build_app(options: { package_manager: :pnpm }) + path_set = build_path_set(app: app) + + expect(path_set).to be_pnpm + end + + it "is true when only the pnpm executable is named" do + app = build_app(options: { pnpm_path: "/usr/bin/pnpm" }) + path_set = build_path_set(app: app) + + expect(path_set).to be_pnpm + end + + it "is false when pnpm is neither requested nor named" do + path_set = build_path_set + + expect(path_set).not_to be_pnpm + end + end + + describe "#pnpm" do + it "can be overridden" do + fake_pnpm = create_executable(ember_cli_root.join("pnpm")) + app = build_app(options: { pnpm_path: fake_pnpm.to_s }) + path_set = build_path_set(app: app) + + pnpm = path_set.pnpm + + expect(pnpm).to eq(fake_pnpm).and(be_executable) + end + + it "can be inferred from the $PATH" do + fake_pnpm = create_executable(ember_cli_root.join("pnpm")) + stub_which(pnpm: fake_pnpm.to_s) + app = build_app(options: { package_manager: :pnpm }) + path_set = build_path_set(app: app) + + pnpm = path_set.pnpm + + expect(pnpm).to eq(fake_pnpm).and(be_executable) + end + + context "when the executable isn't installed on the system" do + context "and pnpm is requested" do + it "raises a DependencyError pointing at pnpm's instructions" do + stub_which(pnpm: nil) + app = build_app(options: { package_manager: :pnpm }) + path_set = build_path_set(app: app) + + expect { path_set.pnpm }. + to raise_error(EmberCli::DependencyError, %r{https://pnpm.io/}) + end + end + + context "and pnpm is not requested" do + it "returns nil" do + stub_which(pnpm: nil) + path_set = build_path_set + + pnpm = path_set.pnpm + + expect(pnpm).to be_nil + end + end + end + end + describe "#node_modules" do it "is a child of #root" do app = build_app(name: "foo") diff --git a/spec/lib/ember_cli/shell_spec.rb b/spec/lib/ember_cli/shell_spec.rb index 72a05599..260be265 100644 --- a/spec/lib/ember_cli/shell_spec.rb +++ b/spec/lib/ember_cli/shell_spec.rb @@ -1,3 +1,5 @@ +require "tmpdir" + require "ember_cli/shell" describe EmberCli::Shell do @@ -19,6 +21,79 @@ end end + describe "#install" do + it "prunes and installs with npm by default" do + shell = build_installing_shell(package_manager: :npm) + + shell.install + + expect(commands_run).to eq ["npm prune", "npm install"] + end + + it "installs with yarn when yarn is the package manager" do + shell = build_installing_shell(package_manager: :yarn) + + shell.install + + expect(commands_run).to eq ["yarn install"] + end + + it "installs with pnpm when pnpm is the package manager" do + shell = build_installing_shell(package_manager: :pnpm) + + shell.install + + expect(commands_run).to eq ["pnpm install"] + end + + # Each package manager is a script that records its command line, and + # `ember` is `true` so that the installed dependencies count as valid. + def build_installing_shell(package_manager:) + paths = double( + "EmberCli::PathSet", + package_manager: package_manager, + npm: fake_package_manager("npm"), + yarn: fake_package_manager("yarn"), + pnpm: fake_package_manager("pnpm"), + ember: "true", + gemfile: install_root.join("Gemfile"), + bower_json: install_root.join("bower.json"), + root: install_root, + log: Pathname.new(File::NULL), + ) + + EmberCli::Shell.new(paths: paths) + end + + def fake_package_manager(name) + install_root.join(name).tap do |script| + script.write(<<~SH) + #!/bin/sh + echo "#{name} $*" >> #{commands_log} + SH + script.chmod(0o755) + end + end + + def commands_run + commands_log.read.lines(chomp: true) + end + + def commands_log + install_root.join("commands.log") + end + + def install_root + @install_root ||= Pathname.new(Dir.mktmpdir("ember-cli-rails-install")) + end + + after do + if @install_root + @install_root.rmtree + end + end + end + # The `ember` executable is the seam: `Command#test` builds the command # line from it, so `true` and `false` stand in for a passing and a failing # `ember test` run.