From a97f5b7e58567be0a4485fd7c0203343c5ebeadc Mon Sep 17 00:00:00 2001
From: tohosaku <tohosaku@users.osdn.me>
Date: Tue, 19 Apr 2022 10:19:54 +0000
Subject: [PATCH 1/5] Enable to use gem as Redmine plugin and theme

---
 .gitignore                                    |  1 +
 Gemfile                                       | 23 +++++-
 Gemfile.extension.example                     |  5 ++
 config/routes.rb                              | 10 ++-
 doc/RUNNING_TESTS                             |  7 ++
 lib/redmine/plugin.rb                         | 56 +++++++++++--
 lib/redmine/plugin_loader.rb                  | 81 +++++++++++++++----
 lib/redmine/themes.rb                         | 43 ++++++++--
 test/fixtures/gem_extensions/.gitignore       |  1 +
 test/fixtures/gem_extensions/Gemfile          | 12 +++
 test/fixtures/gem_extensions/baz/Gemfile      |  6 ++
 test/fixtures/gem_extensions/baz/baz.gemspec  | 22 +++++
 test/fixtures/gem_extensions/baz/init.rb      |  6 ++
 .../gem_extensions/baz/lib/baz_plugin.rb      |  5 ++
 test/fixtures/gem_extensions/foobar/Gemfile   |  6 ++
 .../gem_extensions/foobar/foobar.gemspec      | 20 +++++
 .../foobar/stylesheets/application.css        |  0
 test/fixtures/gem_extensions/quux/Gemfile     |  6 ++
 test/fixtures/gem_extensions/quux/init.rb     |  8 ++
 .../gem_extensions/quux/lib/quux_plugin.rb    |  5 ++
 .../fixtures/gem_extensions/quux/quux.gemspec | 25 ++++++
 .../invalid_plugins/qux_plugin/init.rb        |  5 ++
 test/unit/lib/redmine/plugin_test.rb          | 42 ++++++++++
 test/unit/lib/redmine/themes_test.rb          | 14 ++++
 24 files changed, 376 insertions(+), 33 deletions(-)
 create mode 100644 Gemfile.extension.example
 create mode 100644 test/fixtures/gem_extensions/.gitignore
 create mode 100644 test/fixtures/gem_extensions/Gemfile
 create mode 100644 test/fixtures/gem_extensions/baz/Gemfile
 create mode 100644 test/fixtures/gem_extensions/baz/baz.gemspec
 create mode 100644 test/fixtures/gem_extensions/baz/init.rb
 create mode 100644 test/fixtures/gem_extensions/baz/lib/baz_plugin.rb
 create mode 100644 test/fixtures/gem_extensions/foobar/Gemfile
 create mode 100644 test/fixtures/gem_extensions/foobar/foobar.gemspec
 create mode 100644 test/fixtures/gem_extensions/foobar/stylesheets/application.css
 create mode 100644 test/fixtures/gem_extensions/quux/Gemfile
 create mode 100644 test/fixtures/gem_extensions/quux/init.rb
 create mode 100644 test/fixtures/gem_extensions/quux/lib/quux_plugin.rb
 create mode 100644 test/fixtures/gem_extensions/quux/quux.gemspec
 create mode 100644 test/fixtures/invalid_plugins/qux_plugin/init.rb

diff --git a/.gitignore b/.gitignore
index 444173c3bc..0761e68f8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,7 @@
 /.bundle
 /Gemfile.lock
 /Gemfile.local
+/Gemfile.extension
 
 /node_modules
 yarn-error.log
diff --git a/Gemfile b/Gemfile
index 9cde32ec8a..17c517c96c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -130,6 +130,25 @@ if File.exist?(local_gemfile)
 end
 
 # Load plugins' Gemfiles
-Dir.glob File.expand_path("../plugins/*/{Gemfile,PluginGemfile}", __FILE__) do |file|
-  eval_gemfile file
+Dir.glob(File.expand_path("plugins/*/", __dir__)) do |entry|
+  next unless File.directory?(entry)
+
+  plugin_dir = File.expand_path(entry, __dir__)
+  spec = File.join plugin_dir, '*.gemspec'
+  files =
+    if Dir.exist? spec
+      Dir.glob(File.join(plugin_dir, "PluginGemfile"))
+    else
+      Dir.glob(File.join(plugin_dir, "{Gemfile,PluginGemfile}"))
+    end
+  files.each do |file|
+    eval_gemfile file
+  end
+end
+
+extension_gemfile = File.join(File.dirname(__FILE__), "Gemfile.extension")
+if File.exist?(extension_gemfile)
+  group :redmine_extension do
+    eval_gemfile extension_gemfile
+  end
 end
diff --git a/Gemfile.extension.example b/Gemfile.extension.example
new file mode 100644
index 0000000000..62e616f866
--- /dev/null
+++ b/Gemfile.extension.example
@@ -0,0 +1,5 @@
+# vim: set ft=ruby
+# Copy this file to Gemfile.extension and add any rubygem plugins
+
+# Example:
+#   gem 'redmine_sample_plugin', '~> 1.1.0'
diff --git a/config/routes.rb b/config/routes.rb
index 7a683c9389..feb431ed6c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -427,11 +427,13 @@ Rails.application.routes.draw do
   # Can be used by load balancers and uptime monitors to verify that the app is live.
   get "up" => "rails/health#show", :as => :rails_health_check
 
-  Redmine::Plugin.directory.glob("*/config/routes.rb").sort.each do |plugin_routes_path|
-    instance_eval(plugin_routes_path.read, plugin_routes_path.to_s)
+  Redmine::PluginLoader.directories.each do |plugin_path|
+    next unless plugin_path.routes
+
+    instance_eval plugin_path.routes.read, plugin_path.routes.to_s
   rescue SyntaxError, StandardError => e
-    plugin_name = plugin_routes_path.parent.parent.basename.to_s
-    puts "An error occurred while loading the routes definition of #{plugin_name} plugin (#{plugin_routes_path}): #{e.message}."
+    plugin_name = File.basename plugin_path.to_s
+    puts "An error occurred while loading the routes definition of #{plugin_name} plugin (#{plugin_path.routes}): #{e.message}."
     exit 1
   end
 end
diff --git a/doc/RUNNING_TESTS b/doc/RUNNING_TESTS
index 2af68cbb43..01140134cf 100644
--- a/doc/RUNNING_TESTS
+++ b/doc/RUNNING_TESTS
@@ -21,6 +21,13 @@ variable:
 Before running tests, you need to configure both development
 and test databases.
 
+To test if a gem plugin or gem theme is loaded, do the following
+
+`export BUNDLE_GEMFILE=./test/fixtures/gem_extensions/Gemfile`
+`bundle install`
+`bin/rails test test/unit/lib/redmine/plugin_test.rb`
+`bin/rails test test/unit/lib/redmine/theme_test.rb`
+
 Creating test repositories
 ==========================
 
diff --git a/lib/redmine/plugin.rb b/lib/redmine/plugin.rb
index 74ae4efafc..c6d475efb9 100644
--- a/lib/redmine/plugin.rb
+++ b/lib/redmine/plugin.rb
@@ -24,6 +24,9 @@ module Redmine
   # Exception raised when a plugin requirement is not met.
   class PluginRequirementError < StandardError; end
 
+  # Exception raised when plugin id and gemspec metadata is not met.
+  class InvalidPluginId < StandardError; end
+
   # Base class for Redmine plugins.
   # Plugins are registered using the <tt>register</tt> class method that acts as the public constructor.
   #
@@ -94,16 +97,17 @@ module Redmine
       p = new(id)
       p.instance_eval(&)
 
-      # Set a default name if it was not provided during registration
-      p.name(id.to_s.humanize) if p.name.nil?
-      # Set a default directory if it was not provided during registration
-      p.directory(File.join(self.directory, id.to_s)) if p.directory.nil?
+      # Set a default attributes if it was not provided during registration
+      p.set_default_attrs File.join(self.directory, id.to_s)
 
       unless File.directory?(p.directory)
         raise PluginNotFound, "Plugin not found. The directory for plugin #{p.id} should be #{p.directory}."
       end
 
-      p.path = PluginLoader.directories.find {|d| d.to_s == p.directory}
+      loc =  caller_locations(1, 1).first.absolute_path
+      if p.has_initializer? && (File.dirname(loc) != p.directory)
+        raise InvalidPluginId, "The location of init.rb is different from #{p.directory}. It called from #{loc}"
+      end
 
       # Adds plugin locales if any
       # YAML translation files should be found under <plugin>/config/locales/
@@ -135,6 +139,13 @@ module Redmine
         @used_partials[partial] = p.id
       end
 
+      # Load dependencies
+      if p.gem?
+        p.path.gemspec.dependencies.each do |d|
+          PluginLoader.dependencies << d.name
+        end
+      end
+
       registered_plugins[id] = p
     end
 
@@ -203,6 +214,41 @@ module Redmine
       self.id.to_s <=> plugin.id.to_s
     end
 
+    def set_default_attrs(default_dir)
+      dir = directory || default_dir
+      self.path = PluginLoader.find_path(plugin_id: id, plugin_dir: dir)
+
+      if gem?
+        spec = path.gemspec
+        name spec.summary if spec.summary
+        author spec.authors.join(",") if spec.authors
+        description spec.description if spec.description
+        version spec.version.to_s if spec.version
+        url spec.homepage if spec.homepage
+        author_url spec.metadata["author_url"] if spec.metadata["author_url"]
+      end
+
+      # Set a default name if it was not provided during registration
+      name(id.to_s.humanize) if name.nil?
+
+      # Set a default directory if it was not provided during registration
+      if directory.nil?
+        if path.present?
+          directory(path.to_s)
+        else
+          directory(default_dir)
+        end
+      end
+    end
+
+    def gem?
+      path && path.gemspec.present?
+    end
+
+    def has_initializer?
+      path.present? && path.has_initializer?
+    end
+
     # Sets a requirement on Redmine version
     # Raises a PluginRequirementError exception if the requirement is not met
     #
diff --git a/lib/redmine/plugin_loader.rb b/lib/redmine/plugin_loader.rb
index 9fc634a6dd..cfacf33ba0 100644
--- a/lib/redmine/plugin_loader.rb
+++ b/lib/redmine/plugin_loader.rb
@@ -19,12 +19,24 @@
 
 module Redmine
   class PluginPath
-    attr_reader :assets_dir, :initializer
+    attr_reader :assets_dir, :initializer, :gemspec
 
-    def initialize(dir)
+    def initialize(dir, gemspec = nil)
       @dir = dir
+      @gemspec = gemspec
       @assets_dir = File.join dir, 'assets'
       @initializer = File.join dir, 'init.rb'
+      add_autoload_paths
+    end
+
+    def add_autoload_paths
+      # Add the plugin directories to rails autoload paths
+      engine_cfg = Rails::Engine::Configuration.new(self.to_s)
+      engine_cfg.paths.add 'lib', eager_load: true
+      engine_cfg.all_eager_load_paths.each do |dir|
+        Rails.autoloaders.main.push_dir dir
+        Rails.application.config.watchable_dirs[dir] = [:rb]
+      end
     end
 
     def run_initializer
@@ -42,9 +54,19 @@ module Redmine
     def has_initializer?
       File.file?(@initializer)
     end
+
+    def routes
+      file = Pathname.new(@dir).join('config/routes.rb')
+      if file.exist?
+        file
+      else
+        nil
+      end
+    end
   end
 
   class PluginLoader
+    class PluginIdDuplicated < StandardError; end
     # Absolute path to the directory where plugins are located
     cattr_accessor :directory
     self.directory = Rails.root.join Rails.application.config.redmine_plugins_directory
@@ -55,10 +77,10 @@ module Redmine
 
     def self.load
       setup
-      add_autoload_paths
 
       Rails.application.config.to_prepare do
         PluginLoader.directories.each(&:run_initializer)
+        PluginLoader.require_dependencies
 
         Redmine::Hook.call_hook :after_plugins_loaded
       end
@@ -67,23 +89,52 @@ module Redmine
     def self.setup
       @plugin_directories = []
 
-      Dir.glob(File.join(directory, '*')).each do |directory|
-        next unless File.directory?(directory)
+      Dir.glob(File.join(directory, '*')).each do |dir|
+        next unless File.directory?(dir)
 
-        @plugin_directories << PluginPath.new(directory)
+        @plugin_directories << PluginPath.new(dir)
       end
-    end
 
-    def self.add_autoload_paths
-      directories.each do |directory|
-        # Add the plugin directories to rails autoload paths
-        engine_cfg = Rails::Engine::Configuration.new(directory.to_s)
-        engine_cfg.paths.add 'lib', eager_load: true
-        engine_cfg.all_eager_load_paths.each do |dir|
-          Rails.autoloaders.main.push_dir dir
-          Rails.application.config.watchable_dirs[dir] = [:rb]
+      # If there are plugins under plugins/, do not register a gem with the same name.
+      plugin_specs.each do |spec|
+        dir = File.join(directory, spec.name)
+        if File.directory?(dir)
+          warn "WARN: \"#{spec.name}\" plugin installed as gems also exist in the \"#{dir}\" directory; use the ones in \"#{dir}\"."
+          next
         end
+        @plugin_directories << PluginPath.new(spec.full_gem_path, spec)
+      end
+    end
+
+    def self.plugin_specs
+      specs = Bundler.definition
+                     .specs_for([:redmine_extension])
+                     .to_a
+                     .select{|s| s.name != 'bundler' && !s.metadata['redmine_plugin_id'].nil?}
+      duplicates = specs.group_by{|s| s.metadata['redmine_plugin_id']}.reject{|k, v| v.one?}.keys
+      raise PluginIdDuplicated.new("#{duplicates.join(',')} Duplicate plugin id") unless duplicates.empty?
+
+      specs
+    end
+
+    cattr_accessor :dependencies
+    self.dependencies = []
+
+    def self.require_dependencies
+      # Load dependencies. If the dependency is a redmine plugin, do not load it
+      # (it should already be initialized)
+      deps = dependencies - plugin_specs.map(&:name)
+      deps.uniq.each do |d|
+        require d
+      end
+    end
+
+    def self.find_path(plugin_id:, plugin_dir:)
+      path = directories.find {|d| d.gemspec.present? && d.gemspec.metadata['redmine_plugin_id'] == plugin_id.to_s }
+      if path.nil?
+        path = directories.find {|d| d.to_s == plugin_dir}
       end
+      path
     end
 
     def self.directories
diff --git a/lib/redmine/themes.rb b/lib/redmine/themes.rb
index 80e846550c..71a29c7175 100644
--- a/lib/redmine/themes.rb
+++ b/lib/redmine/themes.rb
@@ -19,6 +19,8 @@
 
 module Redmine
   module Themes
+    class ThemeIdDuplicated < StandardError; end
+
     # Return an array of installed themes
     def self.themes
       @@installed_themes ||= scan_themes
@@ -43,14 +45,15 @@ module Redmine
 
     # Class used to represent a theme
     class Theme
-      attr_reader :path, :name, :dir
+      attr_reader :path, :name, :dir, :gemspec
 
-      def initialize(path)
+      def initialize(path, gemspec = nil)
         @path = path
         @dir = File.basename(path)
         @name = @dir.humanize
         @stylesheets = nil
         @javascripts = nil
+        @gemspec = gemspec
       end
 
       # Directory name used as the theme id
@@ -148,12 +151,38 @@ module Redmine
     end
 
     def self.scan_themes
-      dirs = Dir.glob(["#{Rails.root}/app/assets/themes/*", "#{Rails.root}/themes/*"]).select do |f|
-        # A theme should at least override application.css
-        File.directory?(f) && File.exist?("#{f}/stylesheets/application.css")
+      theme_dirs = ['app/assets/themes', 'themes']
+      themes = theme_dirs.map do |theme_dir|
+        Rails.root.join(theme_dir).children.select{|dir| valid_theme?(dir)}.map{|dir| Theme.new(dir)}
       end
-      dirs.collect {|dir| Theme.new(dir)}.sort
+      directories = themes.flatten
+
+      # If there are theme under the public dir, do not register a gem with the same name.
+      gemspecs.each do |spec|
+        if dir = theme_dirs.find{|theme_dir| Rails.root.join(theme_dir, spec.name).directory?}
+          warn "WARN: \"#{spec.name}\" theme installed as gems also exist in the \"#{dir}\" directory; the theme in \"#{dir}\" is used."
+          next
+        end
+        directories << Theme.new(spec.full_gem_path, spec)
+      end
+      directories.sort
+    end
+
+    def self.gemspecs
+      specs = Bundler.definition
+                     .specs_for([:redmine_extension])
+                     .to_a
+                     .select{|s| s.name != 'bundler' && !s.metadata['redmine_theme_id'].nil? && valid_theme?(s.full_gem_path)}
+      duplicates = specs.group_by{|s| s.metadata['redmine_theme_id']}.reject{|k, v| v.one?}.keys
+      raise ThemeIdDuplicated.new("#{duplicates.join(',')} Duplicate theme id") unless duplicates.empty?
+
+      specs
+    end
+
+    def self.valid_theme?(dir)
+      # A theme should at least override application.css
+      File.directory?(dir) && File.exist?("#{dir}/stylesheets/application.css")
     end
-    private_class_method :scan_themes
+    private_class_method :scan_themes, :gemspecs, :valid_theme?
   end
 end
diff --git a/test/fixtures/gem_extensions/.gitignore b/test/fixtures/gem_extensions/.gitignore
new file mode 100644
index 0000000000..b844b143d2
--- /dev/null
+++ b/test/fixtures/gem_extensions/.gitignore
@@ -0,0 +1 @@
+Gemfile.lock
diff --git a/test/fixtures/gem_extensions/Gemfile b/test/fixtures/gem_extensions/Gemfile
new file mode 100644
index 0000000000..6fca8f2e25
--- /dev/null
+++ b/test/fixtures/gem_extensions/Gemfile
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+original_gemfile = File.join(File.dirname(__FILE__), "../../../Gemfile")
+
+eval_gemfile original_gemfile
+
+gem 'quux', path: './quux', require: false
+
+group :redmine_extension do
+  gem 'baz', path: './baz'
+  gem 'foobar', path: './foobar'
+end
diff --git a/test/fixtures/gem_extensions/baz/Gemfile b/test/fixtures/gem_extensions/baz/Gemfile
new file mode 100644
index 0000000000..449cd644cc
--- /dev/null
+++ b/test/fixtures/gem_extensions/baz/Gemfile
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Specify your gem's dependencies in baz_plugin.gemspec
+gemspec
diff --git a/test/fixtures/gem_extensions/baz/baz.gemspec b/test/fixtures/gem_extensions/baz/baz.gemspec
new file mode 100644
index 0000000000..325a8f8850
--- /dev/null
+++ b/test/fixtures/gem_extensions/baz/baz.gemspec
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+Gem::Specification.new do |spec|
+  spec.name = "baz"
+  spec.version = "0.0.1"
+  spec.authors = ['johndoe', 'janedoe']
+  spec.email = ['johndoe@example.org']
+
+  spec.summary = "Baz Plugin"
+  spec.description = "This is a gemified plugin for Redmine"
+  spec.homepage = "https://example.org/plugins/baz"
+
+  spec.required_ruby_version = ">= 3.2.0"
+
+  spec.metadata['allowed_push_host'] = "https://example.org"
+
+  spec.metadata['redmine_plugin_id'] = "baz_plugin"
+  spec.metadata['rubygems_mfa_required'] = "true"
+  spec.files = Dir["{app,lib,config,assets,db}/**/*", "init.rb", "Gemfile", "README.rdoc"]
+
+  spec.add_dependency 'quux'
+end
diff --git a/test/fixtures/gem_extensions/baz/init.rb b/test/fixtures/gem_extensions/baz/init.rb
new file mode 100644
index 0000000000..e5fe733606
--- /dev/null
+++ b/test/fixtures/gem_extensions/baz/init.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+Redmine::Plugin.register :baz_plugin do
+  name "This name should be overwritten with gemspec 'summary'"
+  author_url "https://example.org/this_url_should_not_be_overwritten_with_gemspec"
+end
diff --git a/test/fixtures/gem_extensions/baz/lib/baz_plugin.rb b/test/fixtures/gem_extensions/baz/lib/baz_plugin.rb
new file mode 100644
index 0000000000..1aed06ee2a
--- /dev/null
+++ b/test/fixtures/gem_extensions/baz/lib/baz_plugin.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+module BazPlugin
+  class Error < StandardError; end
+end
diff --git a/test/fixtures/gem_extensions/foobar/Gemfile b/test/fixtures/gem_extensions/foobar/Gemfile
new file mode 100644
index 0000000000..449cd644cc
--- /dev/null
+++ b/test/fixtures/gem_extensions/foobar/Gemfile
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Specify your gem's dependencies in baz_plugin.gemspec
+gemspec
diff --git a/test/fixtures/gem_extensions/foobar/foobar.gemspec b/test/fixtures/gem_extensions/foobar/foobar.gemspec
new file mode 100644
index 0000000000..522221ed15
--- /dev/null
+++ b/test/fixtures/gem_extensions/foobar/foobar.gemspec
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+Gem::Specification.new do |spec|
+  spec.name = "foobar"
+  spec.version = "0.0.1"
+  spec.authors = ['johndoe', 'janedoe']
+  spec.email = ['johndoe@example.org']
+
+  spec.summary = "Foobar theme"
+  spec.description = "This is a gemified theme for Redmine"
+  spec.homepage = "https://example.org/themes/foobar"
+
+  spec.required_ruby_version = ">= 3.2.0"
+
+  spec.metadata['allowed_push_host'] = "https://example.org"
+
+  spec.metadata['redmine_theme_id'] = "foobar_theme"
+  spec.metadata['rubygems_mfa_required'] = "true"
+  spec.files = Dir["{app,lib,config,assets,db}/**/*", "Gemfile"]
+end
diff --git a/test/fixtures/gem_extensions/foobar/stylesheets/application.css b/test/fixtures/gem_extensions/foobar/stylesheets/application.css
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/test/fixtures/gem_extensions/quux/Gemfile b/test/fixtures/gem_extensions/quux/Gemfile
new file mode 100644
index 0000000000..c593232dab
--- /dev/null
+++ b/test/fixtures/gem_extensions/quux/Gemfile
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Specify your gem's dependencies in quux.gemspec
+gemspec
diff --git a/test/fixtures/gem_extensions/quux/init.rb b/test/fixtures/gem_extensions/quux/init.rb
new file mode 100644
index 0000000000..576825effc
--- /dev/null
+++ b/test/fixtures/gem_extensions/quux/init.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+Redmine::Plugin.register :quux_plugin do
+  # For gemmed plugins, the attributes of the plugin are described in the gemspec.
+  # The correspondence between plugin attributes and gemspec is as follows
+  name "This name should be overwritten with gemspec 'summary'"
+  author_url "https://example.org/this_url_should_not_be_overwritten_with_gemspec"
+end
diff --git a/test/fixtures/gem_extensions/quux/lib/quux_plugin.rb b/test/fixtures/gem_extensions/quux/lib/quux_plugin.rb
new file mode 100644
index 0000000000..de8fdf2e2d
--- /dev/null
+++ b/test/fixtures/gem_extensions/quux/lib/quux_plugin.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+module QuuxPlugin
+  class Error < StandardError; end
+end
diff --git a/test/fixtures/gem_extensions/quux/quux.gemspec b/test/fixtures/gem_extensions/quux/quux.gemspec
new file mode 100644
index 0000000000..c6e4a7a521
--- /dev/null
+++ b/test/fixtures/gem_extensions/quux/quux.gemspec
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+Gem::Specification.new do |spec|
+  # Do not use constants or variables from the gem's own code in this block, as is normally
+  # done with gems. (e.g. Foo::VERSION)
+  # Specify the version of redmine or dependencies between plugins in the init.rb file.
+
+  spec.name = "quux"
+  spec.version = "0.0.1"
+  spec.authors = ["johndoe"]
+  spec.email = ["johndoe@example.org"]
+
+  spec.summary = "Quux plugin"
+  spec.description = "This is a plugin for Redmine"
+  spec.homepage = "https://example.org"
+  spec.required_ruby_version = ">= 3.2.0"
+
+  spec.metadata["author_url"] = spec.homepage
+  spec.files = Dir["{lib}/**/*", "init.rb", "Gemfile"]
+
+  # DO NOT DELETE this attribute
+  spec.metadata["redmine_plugin_id"] = "quux_plugin"
+
+  spec.metadata['rubygems_mfa_required'] = 'true'
+end
diff --git a/test/fixtures/invalid_plugins/qux_plugin/init.rb b/test/fixtures/invalid_plugins/qux_plugin/init.rb
new file mode 100644
index 0000000000..f9f5d6980f
--- /dev/null
+++ b/test/fixtures/invalid_plugins/qux_plugin/init.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+Redmine::Plugin.register :baz_plugin do
+  name "This name should be overwritten with gemspec 'summary'"
+end
diff --git a/test/unit/lib/redmine/plugin_test.rb b/test/unit/lib/redmine/plugin_test.rb
index e6945c4d8a..9eea9f3d7b 100644
--- a/test/unit/lib/redmine/plugin_test.rb
+++ b/test/unit/lib/redmine/plugin_test.rb
@@ -37,6 +37,10 @@ class Redmine::PluginTest < ActiveSupport::TestCase
     @klass.clear
   end
 
+  def gemfile_for_test?
+    File.expand_path(ENV['BUNDLE_GEMFILE']) == Rails.root.join('test/fixtures/gem_extensions/Gemfile').expand_path.to_s
+  end
+
   def test_register
     @klass.register :foo_plugin do
       name 'Foo plugin'
@@ -70,6 +74,44 @@ class Redmine::PluginTest < ActiveSupport::TestCase
     end
   end
 
+  def test_gemified_plugin
+    skip unless gemfile_for_test?
+    path = Redmine::PluginLoader.find_path(plugin_id: "baz_plugin", plugin_dir: nil)
+    path.run_initializer
+    plugin = @klass.find('baz_plugin')
+    assert_equal :baz_plugin, plugin.id
+    assert_equal 'Baz Plugin', plugin.name
+    assert_equal 'https://example.org/plugins/baz', plugin.url
+    assert_equal 'johndoe,janedoe', plugin.author
+    assert_equal 'https://example.org/this_url_should_not_be_overwritten_with_gemspec', plugin.author_url
+    assert_equal 'This is a gemified plugin for Redmine', plugin.description
+    assert_equal '0.0.1', plugin.version
+  end
+
+  def test_gemified_plugin_depended_by_other_plugin
+    skip unless gemfile_for_test?
+    path = Redmine::PluginLoader.find_path(plugin_id: "quux_plugin", plugin_dir: nil)
+    path.run_initializer
+    plugin = @klass.find('quux_plugin')
+    assert_equal :quux_plugin, plugin.id
+  end
+
+  def test_invalid_gemified_plugin
+    skip unless gemfile_for_test?
+
+    @klass.directory = Rails.root.join('test/fixtures/invalid_plugins')
+    @klass.clear
+    Redmine::PluginLoader.directory = @klass.directory
+    Redmine::PluginLoader.setup
+
+    plugin_dir = File.join(@klass.directory, 'qux_plugin')
+    path = Redmine::PluginLoader.find_path(plugin_id: nil, plugin_dir: plugin_dir)
+    e = assert_raises Redmine::InvalidPluginId do
+      path.run_initializer
+    end
+    assert_equal "The location of init.rb is different from #{Rails.root.join('test/fixtures/gem_extensions/baz')}. It called from #{path.initializer}", e.message
+  end
+
   def test_register_should_raise_error_if_plugin_directory_does_not_exist
     e = assert_raises Redmine::PluginNotFound do
       @klass.register(:bar_plugin) {}
diff --git a/test/unit/lib/redmine/themes_test.rb b/test/unit/lib/redmine/themes_test.rb
index 139dded65e..b8189bfdce 100644
--- a/test/unit/lib/redmine/themes_test.rb
+++ b/test/unit/lib/redmine/themes_test.rb
@@ -20,6 +20,20 @@
 require_relative '../../../test_helper'
 
 class Redmine::ThemesTest < ActiveSupport::TestCase
+  def gemfile_for_test?
+    File.expand_path(ENV['BUNDLE_GEMFILE']) == Rails.root.join('test/fixtures/gem_extensions/Gemfile').expand_path.to_s
+  end
+
+  def test_gemified_theme
+    skip unless gemfile_for_test?
+    themes = Redmine::Themes.themes
+    theme = themes.find {|t| t.id == 'foobar'}
+    gemspec = theme.gemspec
+    assert_equal 'Foobar', theme.name
+    assert_equal 'foobar', gemspec.name
+    assert_equal '0.0.1', gemspec.version.to_s
+  end
+
   def test_themes
     themes = Redmine::Themes.themes
     assert_kind_of Array, themes
-- 
2.39.5

