├── .github ├── dependabot.yml └── workflows │ ├── gempush.yml │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin ├── driver ├── nodriver └── test ├── lib ├── generators │ └── driver │ │ ├── USAGE │ │ ├── driver_generator.rb │ │ └── templates │ │ ├── README.md.erb │ │ ├── initializer.rb.erb │ │ ├── module.rb.erb │ │ └── routes.rb.erb ├── rails_drivers.rb ├── rails_drivers │ ├── extensions.rb │ ├── files.rb │ ├── railtie.rb │ ├── routes.rb │ ├── setup.rb │ └── version.rb └── tasks │ └── rails_drivers_tasks.rake ├── rails_drivers.gemspec └── spec ├── bin ├── driver_spec.rb └── nodriver_spec.rb ├── drivers_spec.rb ├── extensions_spec.rb ├── generators └── driver_generator_spec.rb ├── meta_spec.rb ├── spec_helper.rb ├── support └── dummy_app_helpers.rb └── tasks └── rails_drivers_tasks_spec.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/gempush.yml: -------------------------------------------------------------------------------- 1 | name: Ruby Gem 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build + Publish 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@master 15 | - name: Set up Ruby 3.0 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: '3.0' 19 | 20 | - name: Publish to RubyGems 21 | run: | 22 | mkdir -p $HOME/.gem 23 | touch $HOME/.gem/credentials 24 | chmod 0600 $HOME/.gem/credentials 25 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 26 | gem build *.gemspec 27 | gem push *.gem 28 | env: 29 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 30 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Set up Ruby 3.0 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: '3.0' 14 | - name: Install sqlite3 15 | run: sudo apt-get install -y libsqlite3-dev 16 | - name: Build 17 | run: | 18 | gem install bundler 19 | bundle install --jobs 4 --retry 3 20 | - name: Rubocop 21 | run: bundle exec rubocop 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | Gemfile.lock 3 | log/*.log 4 | pkg/ 5 | spec/dummy-* 6 | spec/dummy_*/db/*.sqlite3 7 | spec/dummy_*/db/*.sqlite3-journal 8 | spec/dummy_*/log/*.log 9 | spec/dummy_*/node_modules/ 10 | spec/dummy_*/yarn-error.log 11 | spec/dummy_*/storage/ 12 | spec/dummy_*/tmp/ 13 | vendor/bundle 14 | spec/dummy/.npm 15 | spec/dummy/.config 16 | spec/dummy/.bash_history 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - spec/dummy_*/**/* 4 | - spec/dummy-*/**/* 5 | - vendor/**/* 6 | - node_modules/**/* 7 | TargetRubyVersion: 2.6 8 | SuggestExtensions: false 9 | NewCops: enable 10 | 11 | Style/BlockComments: 12 | Enabled: false 13 | 14 | Style/Documentation: 15 | Enabled: false 16 | 17 | Style/RescueModifier: 18 | Exclude: 19 | - spec/spec_helper.rb 20 | 21 | Style/NestedParenthesizedCalls: 22 | Exclude: 23 | - spec/**/* 24 | 25 | Style/ClassVars: 26 | Exclude: 27 | # rubocop doesn't seem to know what's going on here. 28 | - lib/rails_drivers/extensions.rb 29 | 30 | Metrics/BlockLength: 31 | Exclude: 32 | - spec/**/* 33 | - lib/tasks/*.rake 34 | - '*.gemspec' 35 | 36 | Style/MixinUsage: 37 | Exclude: 38 | - lib/tasks/*.rake 39 | 40 | Metrics/AbcSize: 41 | Exclude: 42 | - lib/generators/**/* 43 | 44 | Metrics/LineLength: 45 | Max: 130 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master 2 | 3 | ## 1.2.0 4 | 5 | * Added support for Rails 6.0 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | # Declare your gem's dependencies in rails_drivers.gemspec. 7 | # Bundler will treat runtime dependencies like base dependencies, and 8 | # development dependencies will be added by default to the :development group. 9 | gemspec 10 | 11 | # Declare any dependencies that are still in development here instead of in 12 | # your gemspec. These might include edge Rails or gems from your path or 13 | # Git. Remember to move these dependencies to your gemspec before releasing 14 | # your gem to rubygems.org. 15 | 16 | group :development, :test do 17 | gem 'bootsnap' 18 | gem 'factory_bot_rails', require: false 19 | gem 'irb' 20 | gem 'listen' 21 | gem 'pry-byebug' 22 | gem 'rubocop', '~> 1.10' 23 | end 24 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Nigel Baillie 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RailsDrivers 2 | 3 | ## What are these "drivers"? 4 | 5 | Each driver is like a mini Rails app that has full access to the main app. A driver has its own `app`, `config`, `spec`, and `db` folder. 6 | 7 | Technically speaking, "driver" is just a fancy name for code that live in a different app folder. The advantage of doing this is that it provides clear-cut separation of concerns. If we follow a couple of simple rules, we can actually test that separation: 8 | 9 | - Drivers should not touch other drivers 10 | - The main app should not touch drivers directly 11 | 12 | The "main app" refers to the files inside your `/app` directory. 13 | 14 | If your test suite is good enough (see [Testing for coupling](#testing-for-coupling)), you can test that these rules are adhered to by selectively adding and removing drivers before running your tests. 15 | 16 | ## Aren't these just engines? 17 | 18 | Very similar, yes. They use the same Rails facilities for adding new `app` paths, etc. 19 | 20 | But practically speaking, drivers come with less friction. They can be freely added and removed from your project without making changes to the main app. There's no need to mess around with gems, routes, or dummy apps. 21 | 22 | Another difference is that drivers have a different dependency direction from engines. Engines are depended on by the Rails app: 23 | ``` 24 | depends on 25 | (Rails App) --> (Engine) 26 | ``` 27 | A Rails app includes an engine as a plugin. The engine doesn't know how the Rails app works. For drivers, it's the other way around: 28 | ``` 29 | depends on 30 | (Driver) --> (Rails App) 31 | ``` 32 | The Rails app doesn't know how its drivers work. It simply acts as a platform for all drivers to be built on. This makes drivers a great way to develop independent features that all rely on the same set of core functionality. 33 | 34 | ## Usage 35 | 36 | Every folder inside `drivers` has its own `app`, `config`, `db`, and `spec` folders. They are effectively a part of the overall Rails app. 37 | 38 | ### Creating a new driver 39 | 40 | Run `rails g driver my_new_driver_name` to get a scaffold driver. 41 | 42 | ### Creating migrations for a driver 43 | 44 | `bundle exec driver my_driver_name generate migration blah etc_etc:string` 45 | 46 | The `driver` utility technically works with other generators and rake tasks, but is only guaranteed to work with migrations. 47 | The reason is that some generators have hard-coded path strings, rather than using the Rails path methods. 48 | 49 | ### Creating a rake task in a driver 50 | 51 | Every driver includes a `lib/tasks` directory where you can define rake tasks. Rake tasks defined in drivers are automatically loaded and namespaced. 52 | For example, 53 | 54 | ```ruby 55 | # drivers/my_driver/lib/tasks/my_namespace.rake 56 | namespace :my_namespace do 57 | task :task_name do 58 | end 59 | end 60 | ``` 61 | 62 | Can be executed using `rake driver:my_driver:my_namespace:task_name`. 63 | 64 | ### Extensions 65 | 66 | Sometimes you want to add a method to a core class, but that method will only be used by one driver. This can be achieved by adding files to your driver's `extensions` directory. 67 | 68 | ```ruby 69 | # app/models/product.rb 70 | # (doesn't have to be a model - can be anything) 71 | class Product < ApplicationRecord 72 | # When you include this, every driver's product_extension.rb is loaded and 73 | # included. Works correctly with autoloading during development. 74 | include RailsDrivers::Extensions 75 | end 76 | 77 | 78 | # drivers/my_driver/extensions/product_extension.rb 79 | module MyDriver 80 | module ProductExtension 81 | extend ActiveSupport::Concern 82 | 83 | def new_method 84 | 'Please only call me from code inside my_driver' 85 | end 86 | end 87 | end 88 | 89 | 90 | # Anywhere in my_driver (or elsewhere, but that's bad style) 91 | Product.new.new_method 92 | ``` 93 | 94 | For each Extension, the accompanying class simply `includes` it, so any methods you define will be available throughout the whole app. To make sure your drivers don't change the core behavior of the app, see [Testing for coupling](#testing-for-coupling). 95 | 96 | ### Testing for coupling 97 | 98 | Since drivers are merged into your main application just like engines, there's nothing stopping them from accessing other drivers, and there's nothing stopping your main application from accessing drivers. In order to ensure those things don't happen, we have a handful of rake tasks: 99 | 100 | 1. `rake driver:isolate[] # leaves you with only one driver` 101 | 2. `rake driver:clear # removes all drivers` 102 | 3. `rake driver:restore # restores all drivers` 103 | 104 | Suppose you have a driver called `store` and a driver called `admin`. You don't want `store` and `admin` to talk to each other. 105 | 106 | ```bash 107 | # Run specs with store driver only 108 | rake driver:isolate[store] 109 | rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb' 110 | rake driver:restore 111 | 112 | # Run specs with admin driver only 113 | rake driver:isolate[admin] 114 | rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb' 115 | rake driver:restore 116 | 117 | # Short-hand with 'driver' utility! 118 | bundle exec driver admin do rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb' 119 | # (can run with no drivers as well) 120 | bundle exec nodriver do rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb' 121 | 122 | # Or you can move the driver folders around manually 123 | mv drivers/admin tmp/drivers/admin 124 | bundle exec rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb' 125 | mv tmp/drivers/admin drivers/admin 126 | ``` 127 | 128 | This lets you to ensure that the store and admin function properly without each other. Note we're running all of the main app's specs twice. This is good because we also want to make sure the main app is not reaching into drivers. 129 | 130 | Of course there's nothing stopping you from using if-statements to detect whether a driver is present. It's up to you to determine what's a "safe" level of crossover. Generally, if you find yourself using a lot of those if-statements, you should consider rethinking which functionality belongs in a driver and which functionality belongs in your main app. On the other hand, the if-statements provide clear feature boundaries and can function as feature flags. Turning off a feature is as simple as removing a folder from `drivers`. 131 | 132 | ## Installation 133 | Add this line to your application's Gemfile: 134 | 135 | ### Install the gem 136 | 137 | ```ruby 138 | gem 'rails_drivers' 139 | ``` 140 | 141 | And then execute: 142 | ```bash 143 | $ bundle install 144 | ``` 145 | 146 | ### Update routes file 147 | 148 | Add these lines to your routes.rb: 149 | 150 | ```ruby 151 | # config/routes.rb in your main Rails app 152 | 153 | require 'rails_drivers/routes' 154 | 155 | # This can go before or after your application's route definitions 156 | RailsDrivers::Routes.load_driver_routes 157 | ``` 158 | 159 | This will tell your main Rails app to load the `routes.rb` files generated in each of your drivers. 160 | 161 | ### RSpec 162 | 163 | If you use RSpec with FactoryBot, add these lines to your `spec/rails_helper.rb` or `spec/spec_helper.rb`: 164 | 165 | ```ruby 166 | Dir[Rails.root.join("drivers/*/spec/support/*.rb")].each { |f| require f } 167 | 168 | RSpec.configure do |config| 169 | FactoryBot.definition_file_paths += Dir['drivers/*/spec/factories'] 170 | FactoryBot.reload 171 | 172 | Dir[Rails.root.join('drivers/*/spec')].each { |x| config.project_source_dirs << x } 173 | Dir[Rails.root.join('drivers/*/lib')].each { |x| config.project_source_dirs << x } 174 | Dir[Rails.root.join('drivers/*/app')].each { |x| config.project_source_dirs << x } 175 | end 176 | ``` 177 | 178 | ### Webpacker 179 | 180 | If you use Webpacker, take a look at this snippet. You'll want to add the code between the comments: 181 | 182 | ```javascript 183 | // config/webpack/environment.js 184 | const { environment } = require('@rails/webpacker') 185 | 186 | //// Begin driver code //// 187 | const { config } = require('@rails/webpacker') 188 | const { sync } = require('glob') 189 | const { basename, dirname, join, relative, resolve } = require('path') 190 | const extname = require('path-complete-extname') 191 | 192 | const getExtensionsGlob = () => { 193 | const { extensions } = config 194 | return extensions.length === 1 ? `**/*${extensions[0]}` : `**/*{${extensions.join(',')}}` 195 | } 196 | 197 | const addToEntryObject = (sourcePath) => { 198 | const glob = getExtensionsGlob() 199 | const rootPath = join(sourcePath, config.source_entry_path) 200 | const paths = sync(join(rootPath, glob)) 201 | paths.forEach((path) => { 202 | const namespace = relative(join(rootPath), dirname(path)) 203 | const name = join(namespace, basename(path, extname(path))) 204 | environment.entry.set(name, resolve(path)) 205 | }) 206 | } 207 | 208 | sync('drivers/*').forEach((driverPath) => { 209 | addToEntryObject(join(driverPath, config.source_path)); 210 | }) 211 | //// End driver code //// 212 | 213 | module.exports = environment 214 | ``` 215 | 216 | ## License 217 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 218 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'bundler/setup' 5 | rescue LoadError 6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 7 | end 8 | 9 | require 'rdoc/task' 10 | 11 | RDoc::Task.new(:rdoc) do |rdoc| 12 | rdoc.rdoc_dir = 'rdoc' 13 | rdoc.title = 'RailsDrivers' 14 | rdoc.options << '--line-numbers' 15 | rdoc.rdoc_files.include('README.md') 16 | rdoc.rdoc_files.include('lib/**/*.rb') 17 | end 18 | 19 | require 'bundler/gem_tasks' 20 | -------------------------------------------------------------------------------- /bin/driver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'rails_drivers/files' 5 | 6 | selected_driver = ARGV.shift 7 | 8 | if ARGV[0] == 'do' 9 | # 10 | # Run any command with only one driver present 11 | # 12 | ARGV.shift 13 | at_exit { RailsDrivers::Files.restore } 14 | 15 | if selected_driver == '_clear' 16 | RailsDrivers::Files.clear 17 | else 18 | RailsDrivers::Files.isolate selected_driver 19 | end 20 | 21 | Process.wait Process.spawn(*ARGV) 22 | exit Process.last_status.exitstatus 23 | else 24 | # 25 | # Run 'rails' command as if the driver was the rails app. 26 | # 27 | APP_PATH = File.expand_path('config/application') 28 | REPLACE_DEFAULT_PATH_WITH_DRIVER = selected_driver 29 | 30 | require_relative "#{Dir.pwd}/config/boot" 31 | 32 | possible_drivers = Dir['drivers/*'].map { |d| d.split('/').last } 33 | unless possible_drivers.include?(REPLACE_DEFAULT_PATH_WITH_DRIVER) 34 | puts "Unknown driver #{REPLACE_DEFAULT_PATH_WITH_DRIVER}. Must be one of [#{possible_drivers.join(', ')}]" 35 | exit 1 36 | end 37 | 38 | require 'rails/commands' 39 | end 40 | -------------------------------------------------------------------------------- /bin/nodriver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'rails_drivers/files' 5 | 6 | ARGV.shift if ARGV[0] == 'do' 7 | 8 | at_exit { RailsDrivers::Files.restore } 9 | RailsDrivers::Files.clear 10 | Process.wait Process.spawn(*ARGV) 11 | exit Process.last_status.exitstatus 12 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH << File.expand_path('../test', __dir__) 5 | 6 | require 'bundler/setup' 7 | require 'rails/plugin/test' 8 | -------------------------------------------------------------------------------- /lib/generators/driver/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generates the directory structure for a new driver. 3 | 4 | Example: 5 | rails generate driver new_feature 6 | 7 | This will create: 8 | drivers/new_feature 9 | drivers/new_feature/app 10 | drivers/new_feature/app/controllers/new_feature 11 | drivers/new_feature/app/models/new_feature 12 | drivers/new_feature/app/views/new_feature 13 | drivers/new_feature/config 14 | drivers/new_feature/extensions 15 | drivers/new_feature/spec 16 | drivers/new_feature/lib 17 | -------------------------------------------------------------------------------- /lib/generators/driver/driver_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DriverGenerator < Rails::Generators::NamedBase 4 | source_root File.expand_path('templates', __dir__) 5 | 6 | def create_driver_dir_structure 7 | create_file "drivers/#{file_name}/app/models/#{file_name}/.keep", '' 8 | create_file "drivers/#{file_name}/app/controllers/#{file_name}/.keep", '' 9 | create_file "drivers/#{file_name}/app/views/#{file_name}/.keep", '' 10 | create_file "drivers/#{file_name}/spec/.keep", '' 11 | create_file "drivers/#{file_name}/db/migrate/.keep", '' 12 | create_file "drivers/#{file_name}/lib/tasks/.keep", '' 13 | create_file "drivers/#{file_name}/extensions/.keep", '' 14 | 15 | create_templated_files 16 | end 17 | 18 | def create_templated_files 19 | template 'routes.rb.erb', "drivers/#{file_name}/config/routes.rb" 20 | template 'initializer.rb.erb', "drivers/#{file_name}/config/initializers/#{file_name}_feature.rb" 21 | template 'module.rb.erb', "drivers/#{file_name}/app/models/#{file_name}.rb" 22 | template 'README.md.erb', "drivers/#{file_name}/README.md" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/generators/driver/templates/README.md.erb: -------------------------------------------------------------------------------- 1 | ## <%= class_name %> README 2 | 3 | This README file should be used to explain the functionality of the driver. 4 | -------------------------------------------------------------------------------- /lib/generators/driver/templates/initializer.rb.erb: -------------------------------------------------------------------------------- 1 | # The core app (or other drivers) can check the presence of the 2 | # <%= class_name %> driver with the following code snippet 3 | # 4 | # do_something if RailsDrivers.loaded.include(:<%= file_name %>) 5 | # 6 | # use with caution! 7 | RailsDrivers.loaded << :<%= file_name %> 8 | -------------------------------------------------------------------------------- /lib/generators/driver/templates/module.rb.erb: -------------------------------------------------------------------------------- 1 | module <%= class_name %> 2 | def self.table_name_prefix 3 | '<%= plural_name.singularize %>_' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/driver/templates/routes.rb.erb: -------------------------------------------------------------------------------- 1 | <%= Rails.application.class.name %>.routes.draw do 2 | scope :<%= plural_name.singularize %> do 3 | # TODO 4 | # get '/my_path', to: '<%= file_name %>/my_controller' 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/rails_drivers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_drivers/version' 4 | require 'rails_drivers/setup' 5 | require 'rails_drivers/railtie' 6 | require 'rails_drivers/extensions' 7 | 8 | module RailsDrivers 9 | class << self 10 | def loaded 11 | @loaded ||= [] 12 | end 13 | 14 | def freeze! 15 | @loaded = @loaded&.freeze 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rails_drivers/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsDrivers 4 | module Extensions 5 | extend ActiveSupport::Concern 6 | 7 | # Including this module results in all available extension modules being 8 | # included. 9 | included do 10 | possible_extensions = Dir.glob( 11 | Rails.root.join( 12 | 'drivers', '*', 'extensions', 13 | "#{name.underscore}_extension.rb" 14 | ) 15 | ) 16 | 17 | # Every extension should be a module. Require all extensions. 18 | included_extensions = possible_extensions.map do |path| 19 | require_dependency path 20 | 21 | %r{drivers/(?[^/]+)/extensions} =~ path 22 | 23 | extension = "#{driver_name.classify}::#{name}Extension".constantize 24 | include extension 25 | extension 26 | end.freeze 27 | 28 | # Show a warning when an extension tries to overload a core method. 29 | singleton_class.prepend(Module.new do 30 | define_method :method_added do |method_name| 31 | included_extensions.each do |extension| 32 | next unless extension.instance_methods.include?(method_name) 33 | 34 | Rails.logger.warn "Driver extension method #{extension.name}##{method_name} " \ 35 | "is shadowed by #{name}##{method_name} and will likely " \ 36 | 'not do anything.' 37 | end 38 | 39 | super(method_name) 40 | end 41 | end) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rails_drivers/files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | 5 | module RailsDrivers 6 | module Files 7 | class Error < StandardError 8 | end 9 | 10 | module_function 11 | 12 | def isolate(driver) 13 | raise Error, 'No driver specified' if driver.nil? || driver == '' 14 | raise Error, "Driver #{driver.inspect} not found" unless File.exist?("drivers/#{driver}") 15 | 16 | FileUtils.mkdir_p 'tmp/drivers' 17 | Dir['drivers/*'].each do |driver_path| 18 | next if driver_path.include?("/#{driver}") 19 | 20 | FileUtils.mv driver_path, "tmp/#{driver_path}" 21 | end 22 | end 23 | 24 | def clear 25 | FileUtils.mkdir_p 'tmp/drivers' 26 | Dir['drivers/*'].each do |driver_path| 27 | FileUtils.mv driver_path, "tmp/#{driver_path}" 28 | end 29 | end 30 | 31 | def restore 32 | Dir['tmp/drivers/*'].each do |tmp_driver_path| 33 | driver = tmp_driver_path.split('/').last 34 | FileUtils.mv tmp_driver_path, "drivers/#{driver}" 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rails_drivers/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsDrivers 4 | class Railtie < ::Rails::Railtie 5 | include ::RailsDrivers::Setup 6 | 7 | rake_tasks do 8 | load File.expand_path("#{__dir__}/../tasks/rails_drivers_tasks.rake") 9 | 10 | # load drivers rake tasks 11 | Dir['drivers/*/lib/tasks/**/*.rake'].each do |driver_rake_file| 12 | %r{^drivers/(?\w+)/} =~ driver_rake_file 13 | 14 | namespace(:driver) do 15 | namespace(driver_name) do 16 | load driver_rake_file 17 | end 18 | end 19 | end 20 | end 21 | 22 | # Since the extensions directory exists for organizational 23 | # purposes and does not define modules with namespace `Extention` 24 | # we need to use Zeitwerk collapse function. 25 | # 26 | # see https://github.com/fxn/zeitwerk#collapsing-directories 27 | if Rails::VERSION::MAJOR >= 6 28 | initializer 'rails_drivers.autoloader.collapse' do 29 | Rails.autoloaders.each do |loader| 30 | loader.collapse('drivers/*/extensions') 31 | end 32 | end 33 | end 34 | 35 | config.before_configuration { setup_paths } 36 | config.after_initialize { RailsDrivers.freeze! } 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rails_drivers/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsDrivers 4 | class Routes 5 | def self.load_driver_routes 6 | return if defined?(REPLACE_DEFAULT_PATH_WITH_DRIVER) 7 | 8 | Dir[Rails.root.join('drivers/*')].each do |path| 9 | load "#{path}/config/routes.rb" if File.exist?("#{path}/config/routes.rb") 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rails_drivers/setup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsDrivers 4 | module Setup 5 | DRIVER_PATHS = %w[ 6 | app 7 | app/assets 8 | app/models 9 | app/views 10 | app/controllers 11 | app/mailers 12 | config/initializers 13 | db db/migrate 14 | lib 15 | ].freeze 16 | 17 | # 18 | # This allows Rails to find models, views, controllers, etc inside of drivers. 19 | # 20 | def setup_paths 21 | # This REPLACE_DEFAULT_PATH_WITH_DRIVER constant gets defined by bin/driver when we want 22 | # to run a command in the context of a driver instead of the main rails app. 23 | if defined?(REPLACE_DEFAULT_PATH_WITH_DRIVER) 24 | replace_rails_paths_with_driver(REPLACE_DEFAULT_PATH_WITH_DRIVER) 25 | else 26 | add_every_driver_to_rails_paths 27 | end 28 | end 29 | 30 | private 31 | 32 | def rails_config 33 | Rails.application.config 34 | end 35 | 36 | def replace_rails_paths_with_driver(driver_name) 37 | rails_config.autoload_paths << "#{rails_config.root}/drivers" 38 | 39 | DRIVER_PATHS.each do |path| 40 | rails_config.paths[path] = "drivers/#{driver_name}/#{path}" 41 | rails_config.autoload_paths += [ 42 | "#{rails_config.root}/drivers/#{driver_name}/lib" 43 | ] 44 | end 45 | end 46 | 47 | def add_every_driver_to_rails_paths 48 | rails_config.autoload_paths << "#{rails_config.root}/drivers" 49 | 50 | Dir['drivers/*'].each do |driver| 51 | DRIVER_PATHS.each do |path| 52 | rails_config.paths[path] << "#{driver}/#{path}" 53 | end 54 | 55 | # We want to autoload driver/*/lib folders 56 | rails_config.autoload_paths += [ 57 | "#{rails_config.root}/#{driver}/lib" 58 | ] 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/rails_drivers/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsDrivers 4 | VERSION = '1.2.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/rails_drivers_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :driver do 4 | desc 'Removes every driver but the one specified. Can be undone with driver:restore.' 5 | task :isolate, [:driver] do |_t, args| 6 | require 'rails_drivers/files' 7 | RailsDrivers::Files.isolate(args.driver) 8 | rescue RailsDrivers::Files::Error => e 9 | puts e.message 10 | end 11 | 12 | desc 'Removes all drivers. Can be undone with driver:restore.' 13 | task :clear do 14 | require 'rails_drivers/files' 15 | RailsDrivers::Files.clear 16 | end 17 | 18 | desc 'Undoes the effects of driver:isolate and driver:clear.' 19 | task :restore do 20 | require 'rails_drivers/files' 21 | RailsDrivers::Files.restore 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /rails_drivers.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path('lib', __dir__) 4 | 5 | # Maintain your gem's version: 6 | require 'rails_drivers/version' 7 | 8 | # Describe your gem and declare its dependencies: 9 | Gem::Specification.new do |spec| 10 | spec.name = 'rails_drivers' 11 | spec.version = RailsDrivers::VERSION 12 | spec.authors = ['Nigel Baillie'] 13 | spec.email = ['nbaillie@degica.com'] 14 | spec.homepage = 'https://github.com/degica/rails_drivers' 15 | spec.summary = 'De-coupled separation of concerns for Rails' 16 | spec.description = 'Like Rails Engines, but without the friction. ' \ 17 | "Your Rails app can't access them, and they can't access each other." 18 | spec.license = 'MIT' 19 | 20 | spec.files = Dir['{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.md'] 21 | spec.executables << 'driver' 22 | spec.executables << 'nodriver' 23 | 24 | spec.required_ruby_version = '>= 2.5' # rubocop:disable Gemspec/RequiredRubyVersion 25 | 26 | rails = case ENV.fetch('RAILS_VERSION', nil) 27 | when '5.2' 28 | '~> 5.2' 29 | when '6.0' 30 | '~> 6.0' 31 | else 32 | '>= 5.2' 33 | end 34 | 35 | spec.add_dependency 'rails', rails 36 | 37 | spec.metadata['rubygems_mfa_required'] = 'true' 38 | end 39 | -------------------------------------------------------------------------------- /spec/bin/driver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'bin/driver' do 6 | it 'can create a migration in a driver' do 7 | run_command('rails g driver driver_name') 8 | run_command('driver driver_name g migration create_tests value:integer') 9 | 10 | migrations = Dir[File.expand_path(File.join(dummy_app, 'drivers/driver_name/db/migrate/*'))] 11 | expect(migrations.size).to eq 1 12 | expect(migrations.first).to include 'create_tests' 13 | end 14 | 15 | it 'can run a command with only one driver present' do 16 | run_command('rails g driver one') 17 | run_command('rails g driver two') 18 | 19 | expect(run_command 'ls -t drivers').to eq "two\none\n" 20 | expect(run_command 'driver one do ls drivers').to eq "one\n" 21 | expect(run_command 'ls -t drivers').to eq "two\none\n" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/bin/nodriver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'bin/nodriver' do 6 | it 'can run a command without any drivers present' do 7 | run_command('rails g driver one') 8 | run_command('rails g driver two') 9 | 10 | expect(run_command 'ls -t drivers').to eq "two\none\n" 11 | expect(run_command 'nodriver do ls drivers').to be_empty 12 | expect(run_command 'ls -t drivers').to eq "two\none\n" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/drivers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'A Rails Driver' do 6 | let(:greetable_concern) do 7 | <<-RUBY 8 | module Greetable 9 | extend ActiveSupport::Concern 10 | def greet; 'hi'; end 11 | end 12 | RUBY 13 | end 14 | 15 | let(:product_includes_greetable_model) do 16 | <<-RUBY 17 | class Product < ApplicationRecord 18 | include Greetable 19 | end 20 | RUBY 21 | end 22 | 23 | let(:product_model) do 24 | <<-RUBY 25 | class Product < ApplicationRecord 26 | validates :name, presence: true 27 | end 28 | RUBY 29 | end 30 | 31 | let(:product_routes) do 32 | <<-RUBY 33 | Dummy::Application.routes.draw do 34 | get 'products', to: 'products#index' 35 | get 'products/new', to: 'products#new' 36 | end 37 | RUBY 38 | end 39 | 40 | let(:products_controller) do 41 | <<-RUBY 42 | class ProductsController < ApplicationController 43 | def index 44 | render inline: Product.pluck(:name).join("\\n") 45 | end 46 | 47 | def new 48 | end 49 | end 50 | RUBY 51 | end 52 | 53 | let(:products_pack) do 54 | <<-JAVASCRIPT 55 | console.log("Hello from products pack!"); 56 | JAVASCRIPT 57 | end 58 | 59 | let(:products_new_view) do 60 | <<-HTML_ERB 61 | <%= content_for(:head) do %> 62 | <%= javascript_pack_tag 'products' %> 63 | <% end %> 64 | 65 |
Pretend there's a product form here
66 | HTML_ERB 67 | end 68 | 69 | let(:test_mailer) do 70 | <<-RUBY 71 | class TestMailer < ApplicationMailer 72 | def some_message 73 | mail to: 'recipient@example.com', from: 'sender@example.com' 74 | end 75 | end 76 | RUBY 77 | end 78 | 79 | let(:test_mailer_html) do 80 | <<-HTML_ERB 81 |
This is the mailer view content in html
82 | HTML_ERB 83 | end 84 | 85 | let(:test_mailer_text) do 86 | <<-TEXT_ERB 87 | This is the mailer view content in text 88 | TEXT_ERB 89 | end 90 | 91 | before do 92 | run_command 'rails g migration create_products name:string' 93 | run_command 'rails db:migrate' 94 | end 95 | 96 | shared_examples 'an engine' do |model_dir, concern_dir| 97 | it "loads from #{model_dir} and #{concern_dir}" do 98 | create_file File.join(model_dir, 'product.rb'), product_includes_greetable_model 99 | create_file File.join(concern_dir, 'greetable.rb'), greetable_concern 100 | 101 | expect(run_ruby %(puts Product.create(name: 'success').name)).to eq "success\n" 102 | expect(run_ruby %(puts Product.last.greet)).to eq "hi\n" 103 | end 104 | end 105 | 106 | it_behaves_like 'an engine', 'app/models', 'app/models/concerns' 107 | it_behaves_like 'an engine', 'drivers/store/app/models', 'drivers/store/app/models/concerns' 108 | 109 | it_behaves_like 'an engine', 'app/controllers', 'app/controllers/concerns' 110 | it_behaves_like 'an engine', 'drivers/store/app/controllers', 'drivers/store/app/controllers/concerns' 111 | 112 | context 'with a controller in a driver' do 113 | before do 114 | create_file 'app/models/product.rb', product_model 115 | create_file 'drivers/store/app/controllers/products_controller.rb', products_controller 116 | create_file 'drivers/store/config/routes.rb', product_routes 117 | end 118 | 119 | it 'sets up routes' do 120 | expect(run_command 'rails routes').to include 'products' 121 | 122 | run_ruby %(Product.create(name: 'success').name) 123 | expect(http :get, '/products').to include 'success' 124 | end 125 | 126 | it 'renders webpacker packs in drivers' do 127 | create_file 'drivers/store/app/views/products/new.html.erb', products_new_view 128 | create_file 'drivers/store/app/javascript/packs/products.js', products_pack 129 | run_command 'bin/webpack' 130 | 131 | script_file = find_js_pack http(:get, '/products/new'), 'products' 132 | expect(http :get, script_file).to include 'Hello from products pack!' 133 | end 134 | end 135 | 136 | context 'with a mailer in a driver' do 137 | before do 138 | create_file 'drivers/something/app/mailers/test_mailer.rb', test_mailer 139 | create_file 'drivers/something/app/views/test_mailer/some_message.html.erb', test_mailer_html 140 | create_file 'drivers/something/app/views/test_mailer/some_message.text.erb', test_mailer_text 141 | end 142 | 143 | it 'can send mail properly' do 144 | delivery = run_ruby %(puts TestMailer.some_message.deliver_now) 145 | expect(delivery).to include 'This is the mailer view content in html' 146 | expect(delivery).to include 'This is the mailer view content in text' 147 | end 148 | end 149 | 150 | context 'with a model, spec, and factory all in a driver' do 151 | let(:product_model_spec) do 152 | <<-RUBY 153 | require 'spec_helper' 154 | 155 | RSpec.describe Product, type: :model do 156 | context 'factory in driver', :in_driver do 157 | it 'works' do 158 | expect(build :product).to be_a Product 159 | end 160 | end 161 | 162 | context 'factory out of driver', :not_in_driver do 163 | it 'works' do 164 | expect(build :funny_product).to be_a Product 165 | end 166 | end 167 | end 168 | RUBY 169 | end 170 | 171 | let(:product_factory) do 172 | <<-RUBY 173 | FactoryBot.define do 174 | factory :product do 175 | name { 'product' } 176 | end 177 | end 178 | RUBY 179 | end 180 | 181 | let(:funny_product_factory) do 182 | <<-RUBY 183 | FactoryBot.define do 184 | factory :funny_product, class: Product do 185 | name { 'hilarious' } 186 | end 187 | end 188 | RUBY 189 | end 190 | 191 | before do 192 | create_file 'drivers/store/app/models/product.rb', product_model 193 | create_file 'drivers/store/spec/models/product_spec.rb', product_model_spec 194 | create_file 'drivers/store/spec/factories/product.rb', product_factory 195 | create_file 'spec/factories/funny_product.rb', funny_product_factory 196 | end 197 | 198 | it 'properly loads the factory' do 199 | run_command 'rspec drivers/store/spec/models/product_spec.rb -t in_driver' 200 | end 201 | 202 | it 'still loads non-driver factories' do 203 | run_command 'rspec drivers/store/spec/models/product_spec.rb -t not_in_driver' 204 | end 205 | end 206 | 207 | context 'with a rake task in a driver' do 208 | def make_rake_task(namespace) 209 | <<-RUBY 210 | namespace :#{namespace} do 211 | desc 'A dummy rake task' 212 | task :run do 213 | # Do absolutely nothing! 214 | end 215 | end 216 | RUBY 217 | end 218 | 219 | before do 220 | create_file 'drivers/store/lib/tasks/dummy.rake', make_rake_task(:dummy) 221 | create_file 'drivers/store/lib/tasks/nested/dummy.rake', make_rake_task(:dummy_nested) 222 | end 223 | 224 | it 'properly loads the rake tasks' do 225 | expect { run_command 'rake driver:store:dummy:run' }.to_not raise_error 226 | expect { run_command 'rake driver:store:dummy_nested:run' }.to_not raise_error 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /spec/extensions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Rails Driver Extensions' do 6 | let(:product_model) do 7 | <<-RUBY 8 | class Product 9 | include RailsDrivers::Extensions 10 | 11 | def say_hello 12 | 'hello' 13 | end 14 | end 15 | RUBY 16 | end 17 | 18 | let(:product_extension) do 19 | <<-RUBY 20 | module Store 21 | module ProductExtension 22 | extend ActiveSupport::Concern 23 | 24 | def extension_method 25 | 'it worked!' 26 | end 27 | end 28 | end 29 | RUBY 30 | end 31 | 32 | let(:alt_product_extension) do 33 | <<-RUBY 34 | module Store 35 | module ProductExtension 36 | extend ActiveSupport::Concern 37 | 38 | def extension_method 39 | 'it worked! (v2)' 40 | end 41 | end 42 | end 43 | RUBY 44 | end 45 | 46 | let(:empty_product_extension) do 47 | <<-RUBY 48 | module Store 49 | module ProductExtension 50 | extend ActiveSupport::Concern 51 | end 52 | end 53 | RUBY 54 | end 55 | 56 | let(:name_clash_product_extension) do 57 | <<-RUBY 58 | module Store 59 | module ProductExtension 60 | extend ActiveSupport::Concern 61 | 62 | def say_hello 63 | 'whoa this should not happen' 64 | end 65 | end 66 | end 67 | RUBY 68 | end 69 | 70 | let(:other_driver_product_extension) do 71 | <<-RUBY 72 | module Admin 73 | module ProductExtension 74 | extend ActiveSupport::Concern 75 | 76 | def admin_method 77 | 'admin method result' 78 | end 79 | end 80 | end 81 | RUBY 82 | end 83 | 84 | before do 85 | create_file 'app/models/product.rb', product_model 86 | end 87 | 88 | context 'with no extension present' do 89 | specify 'the model still functions' do 90 | say_hello_result = run_ruby %(puts Product.new.say_hello) 91 | expect(say_hello_result).to eq "hello\n" 92 | 93 | extension_method_included = run_ruby %(puts Product.new.respond_to?(:extension_method)) 94 | expect(extension_method_included).to eq "false\n" 95 | end 96 | 97 | specify 'an extension can be added mid-session' do 98 | create_file 'tmp/product_extension.rb', product_extension 99 | 100 | script = %( 101 | # First, confirm the product already exists 102 | IO.write 'before.out', Product.new.respond_to?(:extension_method) 103 | 104 | # Write file mid-session 105 | FileUtils.mkdir_p 'drivers/store/extensions' 106 | FileUtils.cp 'tmp/product_extension.rb', 'drivers/store/extensions' 107 | 108 | # Reload and the plugin should show up 109 | reload! 110 | IO.write 'after.out', Product.new.extension_method 111 | ) 112 | 113 | run_command 'rails c', input: script 114 | 115 | before = read_file('before.out') 116 | after = read_file('after.out') 117 | 118 | expect(before).to eq 'false' 119 | expect(after).to eq 'it worked!' 120 | end 121 | end 122 | 123 | context 'with an extension present' do 124 | before do 125 | create_file 'drivers/store/extensions/product_extension.rb', product_extension 126 | end 127 | 128 | it 'is included by the model' do 129 | extension_method_exists = run_ruby %(puts Product.new.respond_to?(:extension_method)) 130 | expect(extension_method_exists).to eq "true\n" 131 | 132 | extension_method_output = run_ruby %(puts Product.new.extension_method) 133 | expect(extension_method_output).to eq "it worked!\n" 134 | end 135 | 136 | it 'persists across reloads' do 137 | create_file 'tmp/new_product_extension.rb', alt_product_extension 138 | 139 | script = %( 140 | IO.write 'before.out', Product.new.extension_method 141 | FileUtils.cp 'tmp/new_product_extension.rb', 'drivers/store/extensions/product_extension.rb' 142 | reload! 143 | IO.write 'after.out', Product.new.extension_method 144 | ) 145 | 146 | run_command 'rails c', input: script 147 | 148 | before = read_file('before.out') 149 | after = read_file('after.out') 150 | 151 | expect(before).to eq 'it worked!' 152 | expect(after).to eq 'it worked! (v2)' 153 | end 154 | 155 | it 'does not include removed methods across reloads' do 156 | create_file 'tmp/new_product_extension.rb', empty_product_extension 157 | 158 | script = %( 159 | IO.write 'before.out', Product.new.extension_method 160 | FileUtils.cp 'tmp/new_product_extension.rb', 'drivers/store/extensions/product_extension.rb' 161 | reload! 162 | begin 163 | Product.new.extension_method # This method should no longer be present! 164 | IO.write 'after.out', 'it did not work' 165 | rescue NoMethodError 166 | IO.write 'after.out', 'it worked' 167 | end 168 | ) 169 | 170 | run_command 'rails c', input: script 171 | 172 | before = read_file('before.out') 173 | after = read_file('after.out') 174 | 175 | expect(before).to eq 'it worked!' 176 | expect(after).to eq 'it worked' 177 | end 178 | end 179 | 180 | context 'with multiple extensions present' do 181 | before do 182 | create_file 'drivers/store/extensions/product_extension.rb', product_extension 183 | create_file 'drivers/admin/extensions/product_extension.rb', other_driver_product_extension 184 | end 185 | 186 | it 'includes both of them' do 187 | extension_method_output = run_ruby %(puts Product.new.extension_method) 188 | expect(extension_method_output).to eq "it worked!\n" 189 | 190 | extension_method_output = run_ruby %(puts Product.new.admin_method) 191 | expect(extension_method_output).to eq "admin method result\n" 192 | end 193 | end 194 | 195 | context 'when an extension shadows a method in the overridden class' do 196 | before do 197 | create_file 'drivers/store/extensions/product_extension.rb', name_clash_product_extension 198 | end 199 | 200 | it 'issues a warning' do 201 | output = run_command 'rails c', input: 'Product', capture_stderr: true 202 | 203 | expect(output).to include 'Driver extension method Store::ProductExtension#say_hello ' \ 204 | 'is shadowed by Product#say_hello and will likely not do anything.' 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /spec/generators/driver_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'rails g driver' do 6 | before :each do 7 | run_command 'rails g driver driver_name' 8 | end 9 | 10 | context 'creating files' do 11 | it 'creates the app directory' do 12 | expect(dummy_app).to have_file 'drivers/driver_name/app/' 13 | end 14 | 15 | it 'creates the models directory' do 16 | expect(dummy_app).to have_file 'drivers/driver_name/app/models/driver_name/' 17 | expect(dummy_app).to have_file 'drivers/driver_name/app/models/driver_name.rb' 18 | end 19 | 20 | it 'creates the controllers directory' do 21 | expect(dummy_app).to have_file 'drivers/driver_name/app/controllers/' 22 | expect(dummy_app).to have_file 'drivers/driver_name/app/controllers/driver_name/' 23 | end 24 | 25 | it 'creates the views directory' do 26 | expect(dummy_app).to have_file 'drivers/driver_name/app/views/' 27 | expect(dummy_app).to have_file 'drivers/driver_name/app/views/driver_name/' 28 | end 29 | 30 | it 'creates the routes' do 31 | expect(dummy_app).to have_file 'drivers/driver_name/config/routes.rb' 32 | end 33 | 34 | it 'creates the driver initializer' do 35 | expect(dummy_app).to have_file 'drivers/driver_name/config/initializers/driver_name_feature.rb' 36 | end 37 | 38 | it 'creates the tasks directory' do 39 | expect(dummy_app).to have_file 'drivers/driver_name/lib/tasks/.keep' 40 | end 41 | 42 | it 'creates the readme' do 43 | expect(dummy_app).to have_file 'drivers/driver_name/README.md' 44 | end 45 | end 46 | 47 | context 'the namespace' do 48 | it 'has the right table_name_prefix' do 49 | expect(run_ruby %(puts DriverName.table_name_prefix)).to eq "driver_name_\n" 50 | end 51 | end 52 | 53 | context 'the initializer' do 54 | it 'populates RailsDrivers.loaded' do 55 | expect(run_ruby %(puts RailsDrivers.loaded.inspect)).to eq "[:driver_name]\n" 56 | end 57 | end 58 | 59 | context 'the routes.rb' do 60 | it 'draws from the Rails application' do 61 | expect(read_file('drivers/driver_name/config/routes.rb')).to include "Dummy::Application.routes.draw do\n" 62 | end 63 | end 64 | 65 | context 'the readme' do 66 | it 'includes the driver name' do 67 | expect(read_file('drivers/driver_name/README.md')).to include 'DriverName' 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/meta_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'spec/support/dummy_app_helpers.rb' do 6 | context 'running commands' do 7 | it 'works' do 8 | expect(dummy_app).to_not have_file 'app/models/test.rb' 9 | run_command('rails g model Test name:string') 10 | expect(dummy_app).to have_file 'app/models/test.rb' 11 | end 12 | end 13 | 14 | context 'running ruby code' do 15 | it 'works' do 16 | expect(run_ruby('puts "whats up"')).to eq "whats up\n" 17 | end 18 | end 19 | 20 | context 'creating files' do 21 | it 'works' do 22 | create_file 'lib/test.rb', 'puts "hello"' 23 | expect(read_file('lib/test.rb')).to eq 'puts "hello"' 24 | end 25 | end 26 | 27 | context 'the dummy app' do 28 | it 'renders webpack assets' do 29 | run_command 'bin/webpack' 30 | html = http :get, '/' 31 | script_file = find_js_pack html, 'home' 32 | expect(http :get, script_file).to include 'Hello from home.js!' 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by the `rspec --init` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 18 | 19 | require 'securerandom' 20 | require 'pry' rescue LoadError 21 | 22 | SUPPORTED_RAILS_VERSION = %w[5.2 6.0].freeze 23 | unless SUPPORTED_RAILS_VERSION.include?(ENV['RAILS_VERSION']) 24 | raise 'You must target a Rails version by setting RAILS_VERSION to 5.2 or 6.0' 25 | end 26 | 27 | Dir["#{__dir__}/support/*.rb"].sort.each { |f| require f } 28 | 29 | RSpec.configure do |config| 30 | config.include DummyAppHelpers 31 | 32 | # rspec-expectations config goes here. You can use an alternate 33 | # assertion/expectation library such as wrong or the stdlib/minitest 34 | # assertions if you prefer. 35 | config.expect_with :rspec do |expectations| 36 | # This option will default to `true` in RSpec 4. It makes the `description` 37 | # and `failure_message` of custom matchers include text for helper methods 38 | # defined using `chain`, e.g.: 39 | # be_bigger_than(2).and_smaller_than(4).description 40 | # # => "be bigger than 2 and smaller than 4" 41 | # ...rather than: 42 | # # => "be bigger than 2" 43 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 44 | end 45 | 46 | # rspec-mocks config goes here. You can use an alternate test double 47 | # library (such as bogus or mocha) by changing the `mock_with` option here. 48 | config.mock_with :rspec do |mocks| 49 | # Prevents you from mocking or stubbing a method that does not exist on 50 | # a real object. This is generally recommended, and will default to 51 | # `true` in RSpec 4. 52 | mocks.verify_partial_doubles = true 53 | end 54 | 55 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 56 | # have no way to turn it off -- the option exists only for backwards 57 | # compatibility in RSpec 3). It causes shared context metadata to be 58 | # inherited by the metadata hash of host groups and examples, rather than 59 | # triggering implicit auto-inclusion in groups with matching metadata. 60 | config.shared_context_metadata_behavior = :apply_to_host_groups 61 | 62 | config.default_formatter = 'doc' if config.files_to_run.size <= 5 63 | 64 | config.before(:each) do 65 | setup_dummy_app 66 | end 67 | 68 | config.after(:each) do 69 | teardown_dummy_app 70 | end 71 | 72 | config.order = :random 73 | 74 | # The settings below are suggested to provide a good initial experience 75 | # with RSpec, but feel free to customize to your heart's content. 76 | =begin 77 | # This allows you to limit a spec run to individual examples or groups 78 | # you care about by tagging them with `:focus` metadata. When nothing 79 | # is tagged with `:focus`, all examples get run. RSpec also provides 80 | # aliases for `it`, `describe`, and `context` that include `:focus` 81 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 82 | config.filter_run_when_matching :focus 83 | 84 | # Allows RSpec to persist some state between runs in order to support 85 | # the `--only-failures` and `--next-failure` CLI options. We recommend 86 | # you configure your source control system to ignore this file. 87 | config.example_status_persistence_file_path = "spec/examples.txt" 88 | 89 | # Limits the available syntax to the non-monkey patched syntax that is 90 | # recommended. For more details, see: 91 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 92 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 93 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 94 | config.disable_monkey_patching! 95 | 96 | # This setting enables warnings. It's recommended, but in some cases may 97 | # be too noisy due to issues in dependencies. 98 | config.warnings = true 99 | 100 | # Many RSpec users commonly either run the entire suite or an individual 101 | # file, and it's useful to allow more verbose output when running an 102 | # individual spec file. 103 | if config.files_to_run.one? 104 | # Use the documentation formatter for detailed output, 105 | # unless a formatter has already been configured 106 | # (e.g. via a command-line flag). 107 | config.default_formatter = "doc" 108 | end 109 | 110 | # Print the 10 slowest examples and example groups at the 111 | # end of the spec run, to help surface which specs are running 112 | # particularly slow. 113 | config.profile_examples = 10 114 | 115 | # Run specs in random order to surface order dependencies. If you find an 116 | # order dependency and want to debug it, you can fix the order by providing 117 | # the seed, which is printed after each run. 118 | # --seed 1234 119 | config.order = :random 120 | 121 | # Seed global randomization in this process using the `--seed` CLI option. 122 | # Setting this allows you to use `--seed` to deterministically reproduce 123 | # test failures related to randomization by passing the same `--seed` value 124 | # as the one that triggered the failure. 125 | Kernel.srand config.seed 126 | =end 127 | end 128 | -------------------------------------------------------------------------------- /spec/support/dummy_app_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'open3' 5 | 6 | module DummyAppHelpers 7 | # 8 | # Custom matchers 9 | # 10 | 11 | def self.included(_class) 12 | RSpec::Matchers.define :have_file do |file_path| 13 | match do |dummy_app_path| 14 | File.exist? File.expand_path(File.join(dummy_app_path, file_path)) 15 | end 16 | end 17 | end 18 | 19 | # 20 | # Running commands 21 | # 22 | 23 | def wait_for_command(cmd, stdout, stderr, process, capture_stderr: false) 24 | std = truncate_lines(stdout) 25 | error = truncate_lines(stderr) 26 | code = process.value 27 | raise "Exited with code #{code}: #{cmd}\n#{error.join}\n#{std.join}" if code != 0 28 | 29 | result = [] 30 | result += error if capture_stderr 31 | result += std 32 | result.join 33 | end 34 | 35 | def run_command(cmd, input: nil, capture_stderr: false) 36 | raise 'No dummy app' if dummy_app.nil? 37 | 38 | stdin, stdout, stderr, process = Open3.popen3('sh', '-c', "bundle exec #{cmd}", chdir: dummy_app) 39 | stdin.write input if input 40 | stdin.close 41 | 42 | wait_for_command(cmd, stdout, stderr, process, capture_stderr: capture_stderr) 43 | ensure 44 | stdout&.close 45 | stderr&.close 46 | end 47 | 48 | def run_ruby(code) 49 | run_command("rails runner \"#{code.gsub('"', '\\\"')}\"") 50 | end 51 | 52 | # 53 | # HTTP requests 54 | # 55 | 56 | def http(method, path) 57 | run_ruby <<-RUBY 58 | include Rack::Test::Methods 59 | def app; Rails.application; end 60 | #{method} #{path.inspect} 61 | puts last_response.body 62 | RUBY 63 | end 64 | 65 | def find_js_pack(html, pack_name) 66 | match = %r{}.match(html) 67 | expect(match).to_not be_nil, -> { "Couldn't find a script tag for #{pack_name}-*.js in HTML:\n\n#{html}" } 68 | match[:script_file] 69 | end 70 | 71 | # 72 | # Reading and writing files 73 | # 74 | 75 | def create_file(file_name, contents) 76 | full_path = File.expand_path(File.join(dummy_app, file_name)) 77 | 78 | dir = full_path.split('/') 79 | dir.pop 80 | FileUtils.mkdir_p(dir.join('/')) 81 | 82 | File.write(full_path, contents) 83 | end 84 | 85 | def read_file(file_name) 86 | File.read(File.join(dummy_app, file_name)) 87 | end 88 | 89 | # 90 | # Filesystem 91 | # 92 | 93 | def dummy_app 94 | @dummy_app 95 | end 96 | 97 | def dummy_app_template 98 | File.expand_path File.join(__dir__, "../dummy_#{ENV.fetch('RAILS_VERSION', nil)}") 99 | end 100 | 101 | def setup_dummy_app 102 | random_string = SecureRandom.hex.chars.first(4).join 103 | @dummy_app = File.expand_path File.join(__dir__, "../dummy-#{random_string}") 104 | FileUtils.rm_r @dummy_app if File.exist?(@dummy_app) 105 | cp_r dummy_app_template, @dummy_app 106 | end 107 | 108 | def teardown_dummy_app 109 | return if @dummy_app.nil? 110 | 111 | FileUtils.rm_r @dummy_app 112 | @dummy_app = nil 113 | end 114 | 115 | # 116 | # Private 117 | # 118 | 119 | private 120 | 121 | def cp_r(src, dst) 122 | system "cp -r --reflink=auto #{src} #{dst}" 123 | end 124 | 125 | def truncate_lines(stream, limit: 200) 126 | lines = [] 127 | 128 | stream.each_line do |line| 129 | lines << line unless lines.size > limit 130 | end 131 | 132 | lines[-1] = '...' if lines.size > limit 133 | 134 | lines 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/tasks/rails_drivers_tasks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'tasks/rails_drivers_tasks.rake' do 6 | before do 7 | run_command 'rails g driver first' 8 | run_command 'rails g driver second' 9 | run_command 'rails g driver third' 10 | end 11 | 12 | describe 'rake driver:isolate' do 13 | context 'and a valid driver name is given' do 14 | it 'removes the other two drivers' do 15 | run_command 'rake driver:isolate[second]' 16 | expect(dummy_app).to_not have_file 'drivers/first' 17 | expect(dummy_app).to have_file 'drivers/second' 18 | expect(dummy_app).to_not have_file 'drivers/third' 19 | end 20 | end 21 | 22 | context 'and no driver is passed' do 23 | it 'complains' do 24 | expect(run_command('rake driver:isolate')).to include 'No driver specified' 25 | end 26 | end 27 | 28 | context 'and an invalid driver name is given' do 29 | it 'prints an error' do 30 | expect(run_command('rake driver:isolate[bad]')).to include 'Driver "bad" not found' 31 | end 32 | end 33 | end 34 | 35 | describe 'rake driver:clear' do 36 | it 'removes all drivers' do 37 | run_command 'rake driver:clear' 38 | expect(dummy_app).to_not have_file 'drivers/first' 39 | expect(dummy_app).to_not have_file 'drivers/second' 40 | expect(dummy_app).to_not have_file 'drivers/third' 41 | end 42 | end 43 | 44 | describe 'rake driver:restore' do 45 | it 'undoes the effects of driver:isolate' do 46 | run_command 'rake driver:isolate[second]' 47 | run_command 'rake driver:restore' 48 | expect(dummy_app).to have_file 'drivers/first' 49 | expect(dummy_app).to have_file 'drivers/second' 50 | expect(dummy_app).to have_file 'drivers/third' 51 | end 52 | end 53 | end 54 | --------------------------------------------------------------------------------