├── .gitignore ├── .rspec ├── .travis.yml ├── Appraisals ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── copycopter_client.gemspec ├── features ├── rails.feature ├── step_definitions │ ├── copycopter_server_steps.rb │ └── rails_steps.rb └── support │ ├── env.rb │ └── rails_server.rb ├── gemfiles ├── 2.3.gemfile ├── 2.3.gemfile.lock ├── 3.0.gemfile ├── 3.0.gemfile.lock ├── 3.1.gemfile └── 3.1.gemfile.lock ├── init.rb ├── lib ├── copycopter_client.rb ├── copycopter_client │ ├── cache.rb │ ├── client.rb │ ├── configuration.rb │ ├── errors.rb │ ├── i18n_backend.rb │ ├── poller.rb │ ├── prefixed_logger.rb │ ├── process_guard.rb │ ├── rails.rb │ ├── railtie.rb │ ├── request_sync.rb │ └── version.rb └── tasks │ └── copycopter_client_tasks.rake ├── spec ├── copycopter_client │ ├── cache_spec.rb │ ├── client_spec.rb │ ├── configuration_spec.rb │ ├── i18n_backend_spec.rb │ ├── poller_spec.rb │ ├── prefixed_logger_spec.rb │ ├── process_guard_spec.rb │ └── request_sync_spec.rb ├── copycopter_client_spec.rb ├── spec_helper.rb └── support │ ├── client_spec_helpers.rb │ ├── defines_constants.rb │ ├── fake_client.rb │ ├── fake_copycopter_app.rb │ ├── fake_html_safe_string.rb │ ├── fake_logger.rb │ ├── fake_passenger.rb │ ├── fake_resque_job.rb │ ├── fake_unicorn.rb │ ├── middleware_stack.rb │ └── writing_cache.rb └── tmp └── projects.json /.gitignore: -------------------------------------------------------------------------------- 1 | log/* 2 | tmp/**/* 3 | tmp/* 4 | coverage/* 5 | rdoc/ 6 | tags 7 | 8 | db/schema.rb 9 | db/*.sqlite3 10 | public/system 11 | 12 | *.swp 13 | *.DS_Store 14 | .yardoc 15 | doc 16 | pkg 17 | *.gem 18 | .bundle 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.8.7 3 | - 1.9.2 4 | - 1.9.3 5 | 6 | gemfile: 7 | - gemfiles/2.3.gemfile 8 | - gemfiles/3.0.gemfile 9 | - gemfiles/3.1.gemfile 10 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise '2.3' do 2 | gem 'rails', '2.3.14' 3 | end 4 | 5 | appraise '3.0' do 6 | gem 'rails', '3.0.3' 7 | end 8 | 9 | appraise '3.1' do 10 | gem 'rails', '3.1.0' 11 | gem 'jquery-rails' 12 | gem 'uglifier' 13 | gem 'sass-rails' 14 | gem 'coffee-rails' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | copycopter_client (2.0.0) 5 | i18n (>= 0.5.0) 6 | json 7 | 8 | GEM 9 | remote: http://rubygems.org/ 10 | specs: 11 | actionmailer (3.1.0) 12 | actionpack (= 3.1.0) 13 | mail (~> 2.3.0) 14 | actionpack (3.1.0) 15 | activemodel (= 3.1.0) 16 | activesupport (= 3.1.0) 17 | builder (~> 3.0.0) 18 | erubis (~> 2.7.0) 19 | i18n (~> 0.6) 20 | rack (~> 1.3.2) 21 | rack-cache (~> 1.0.3) 22 | rack-mount (~> 0.8.2) 23 | rack-test (~> 0.6.1) 24 | sprockets (~> 2.0.0) 25 | activemodel (3.1.0) 26 | activesupport (= 3.1.0) 27 | bcrypt-ruby (~> 3.0.0) 28 | builder (~> 3.0.0) 29 | i18n (~> 0.6) 30 | activerecord (3.1.0) 31 | activemodel (= 3.1.0) 32 | activesupport (= 3.1.0) 33 | arel (~> 2.2.1) 34 | tzinfo (~> 0.3.29) 35 | activeresource (3.1.0) 36 | activemodel (= 3.1.0) 37 | activesupport (= 3.1.0) 38 | activesupport (3.1.0) 39 | multi_json (~> 1.0) 40 | addressable (2.2.6) 41 | appraisal (0.4.0) 42 | bundler 43 | rake 44 | arel (2.2.1) 45 | aruba (0.3.7) 46 | childprocess (>= 0.1.9) 47 | cucumber (>= 0.10.5) 48 | rspec (>= 2.6.0) 49 | bcrypt-ruby (3.0.0) 50 | bourne (1.0) 51 | mocha (= 0.9.8) 52 | builder (3.0.0) 53 | childprocess (0.2.2) 54 | ffi (~> 1.0.6) 55 | crack (0.1.8) 56 | cucumber (0.10.7) 57 | builder (>= 2.1.2) 58 | diff-lcs (>= 1.1.2) 59 | gherkin (~> 2.4.0) 60 | json (>= 1.4.6) 61 | term-ansicolor (>= 1.0.5) 62 | daemons (1.1.4) 63 | diff-lcs (1.1.3) 64 | erubis (2.7.0) 65 | eventmachine (0.12.10) 66 | ffi (1.0.9) 67 | gherkin (2.4.18) 68 | json (>= 1.4.6) 69 | hike (1.2.1) 70 | i18n (0.6.0) 71 | json (1.5.4) 72 | mail (2.3.0) 73 | i18n (>= 0.4.0) 74 | mime-types (~> 1.16) 75 | treetop (~> 1.4.8) 76 | mime-types (1.16) 77 | mocha (0.9.8) 78 | rake 79 | multi_json (1.0.3) 80 | polyglot (0.3.2) 81 | rack (1.3.2) 82 | rack-cache (1.0.3) 83 | rack (>= 0.4) 84 | rack-mount (0.8.3) 85 | rack (>= 1.0.0) 86 | rack-ssl (1.3.2) 87 | rack 88 | rack-test (0.6.1) 89 | rack (>= 1.0) 90 | rails (3.1.0) 91 | actionmailer (= 3.1.0) 92 | actionpack (= 3.1.0) 93 | activerecord (= 3.1.0) 94 | activeresource (= 3.1.0) 95 | activesupport (= 3.1.0) 96 | bundler (~> 1.0) 97 | railties (= 3.1.0) 98 | railties (3.1.0) 99 | actionpack (= 3.1.0) 100 | activesupport (= 3.1.0) 101 | rack-ssl (~> 1.3.2) 102 | rake (>= 0.8.7) 103 | rdoc (~> 3.4) 104 | thor (~> 0.14.6) 105 | rake (0.9.2) 106 | rdoc (3.9.4) 107 | rspec (2.6.0) 108 | rspec-core (~> 2.6.0) 109 | rspec-expectations (~> 2.6.0) 110 | rspec-mocks (~> 2.6.0) 111 | rspec-core (2.6.4) 112 | rspec-expectations (2.6.0) 113 | diff-lcs (~> 1.1.2) 114 | rspec-mocks (2.6.0) 115 | sham_rack (1.3.3) 116 | rack 117 | sinatra (1.2.6) 118 | rack (~> 1.1) 119 | tilt (>= 1.2.2, < 2.0) 120 | sprockets (2.0.0) 121 | hike (~> 1.2) 122 | rack (~> 1.0) 123 | tilt (~> 1.1, != 1.3.0) 124 | sqlite3 (1.3.4) 125 | sqlite3-ruby (1.3.3) 126 | sqlite3 (>= 1.3.3) 127 | term-ansicolor (1.0.6) 128 | thin (1.2.11) 129 | daemons (>= 1.0.9) 130 | eventmachine (>= 0.12.6) 131 | rack (>= 1.0.0) 132 | thor (0.14.6) 133 | tilt (1.3.3) 134 | treetop (1.4.10) 135 | polyglot 136 | polyglot (>= 0.3.1) 137 | tzinfo (0.3.29) 138 | webmock (1.7.6) 139 | addressable (~> 2.2, > 2.2.5) 140 | crack (>= 0.1.7) 141 | yard (0.7.2) 142 | 143 | PLATFORMS 144 | ruby 145 | 146 | DEPENDENCIES 147 | appraisal (~> 0.4) 148 | aruba (~> 0.3.2) 149 | bourne 150 | copycopter_client! 151 | cucumber (~> 0.10.0) 152 | i18n 153 | rails (~> 3.1.0) 154 | rake (= 0.9.2) 155 | rspec (~> 2.3) 156 | sham_rack 157 | sinatra 158 | sqlite3-ruby 159 | thin 160 | webmock 161 | yard 162 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2012 thoughtbot, inc. 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 | Copycopter Client 2 | ================= 3 | 4 | [![Build Status](https://secure.travis-ci.org/copycopter/copycopter-ruby-client.png?branch=master)](http://travis-ci.org/copycopter/copycopter-ruby-client) 5 | 6 | This is the Ruby on Rails client for 7 | [Copycopter](https://github.com/copycopter/copycopter-server). 8 | 9 | It uses I18n to access copy and translations from a Copycopter project. 10 | 11 | Installation 12 | ------------ 13 | 14 | In your `Gemfile`: 15 | 16 | gem 'copycopter_client' 17 | 18 | Run: 19 | 20 | bundle install 21 | 22 | In your `config/initializers/copycopter.rb`: 23 | 24 | CopycopterClient.configure do |config| 25 | config.api_key = 'YOUR API KEY HERE' 26 | config.host = 'your-copycopter-server.herokuapp.com' 27 | end 28 | 29 | The API key is on the project page. See `CopycopterClient::Configuration` for 30 | all configuration options. 31 | 32 | Usage 33 | ----- 34 | 35 | Access blurbs by using `I18n.translate`. It is aliased as `translate` and `t` 36 | inside Rails controllers and views. 37 | 38 | # controller 39 | def index 40 | flash[:success] = t('users.create.success', :default => 'User created') 41 | end 42 | 43 | # view 44 | <%= t '.welcome', :default => 'Why hello there' %> 45 | 46 | # model, rake task, etc. 47 | I18n.translate 'system.tasks_complete', :default => 'Tasks complete' 48 | 49 | # Interpolation 50 | I18n.translate 'mailer.welcome', :default => 'Welcome, %{name}!', 51 | :name => @user.name 52 | 53 | Using a prefixed dot (ex: '.welcome') only works in views. Use the full key in 54 | controllers and other places. 55 | 56 | [I18n docs](http://rdoc.info/github/svenfuchs/i18n/master/file/README.textile). 57 | 58 | Deploys 59 | ------- 60 | 61 | Blurbs start out as draft copy and aren't displayed in production until 62 | published. To publish all draft copy when deploying, use the rake task: 63 | 64 | rake copycopter:deploy 65 | 66 | Exporting 67 | --------- 68 | 69 | Blurbs are cached in-memory while your Rails application is running. To export 70 | all cached blurbs to a yml file for offline access, use the rake task: 71 | 72 | rake copycopter:export 73 | 74 | The exported yaml will be located at `config/locales/copycopter.yml`. 75 | 76 | Contributing 77 | ------------ 78 | 79 | See the [style guide](https://github.com/copycopter/style-guide). 80 | 81 | Credits 82 | ------- 83 | 84 | ![thoughtbot](http://thoughtbot.com/images/tm/logo.png) 85 | 86 | Copycopter Client was created by [thoughtbot, inc](http://thoughtbot.com) 87 | 88 | It is maintained by the fine folks at [Crowdtap](http://crowdtap.com) and 89 | [Iora Health](http://iorahealth.com). 90 | 91 | License 92 | ------- 93 | 94 | Copycopter Client is free software, and may be redistributed under the terms 95 | specified in the MIT-LICENSE file. 96 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'appraisal' 3 | require 'cucumber/rake/task' 4 | require 'rspec/core/rake_task' 5 | require 'yard' 6 | 7 | desc 'Default: run the specs and features.' 8 | task :default => :spec do 9 | system "rake -s appraisal cucumber;" 10 | end 11 | 12 | desc 'Test the copycopter_client plugin.' 13 | RSpec::Core::RakeTask.new do |t| 14 | t.rspec_opts = ['--color', "--format progress"] 15 | t.pattern = 'spec/copycopter_client/**/*_spec.rb' 16 | end 17 | 18 | desc "Run cucumber features" 19 | Cucumber::Rake::Task.new do |t| 20 | t.cucumber_opts = [ 21 | '--tags', '~@wip', 22 | '--format', (ENV['CUCUMBER_FORMAT'] || 'progress') 23 | ] 24 | end 25 | 26 | YARD::Rake::YardocTask.new do |t| 27 | t.files = ['lib/**/*.rb'] 28 | end 29 | -------------------------------------------------------------------------------- /copycopter_client.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $LOAD_PATH.push File.expand_path('../lib', __FILE__) 3 | require 'copycopter_client/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.add_dependency 'i18n', '>= 0.5.0' 7 | s.add_dependency 'json' 8 | s.add_development_dependency 'appraisal', '~> 0.4' 9 | s.add_development_dependency 'aruba', '~> 0.3.2' 10 | s.add_development_dependency 'bourne' 11 | s.add_development_dependency 'cucumber', '~> 0.10.0' 12 | s.add_development_dependency 'i18n' 13 | s.add_development_dependency 'rails', '~> 3.1.0' 14 | s.add_development_dependency 'rake', '0.9.2' 15 | s.add_development_dependency 'rspec', '~> 2.3' 16 | s.add_development_dependency 'sham_rack' 17 | s.add_development_dependency 'sinatra' 18 | s.add_development_dependency 'sqlite3-ruby' 19 | s.add_development_dependency 'thin' 20 | s.add_development_dependency 'webmock' 21 | s.add_development_dependency 'yard' 22 | s.authors = ['thoughtbot'] 23 | s.email = 'support@thoughtbot.com' 24 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 25 | s.files = `git ls-files`.split("\n") 26 | s.homepage = 'http://github.com/copycopter/copycopter-ruby-client' 27 | s.name = 'copycopter_client' 28 | s.platform = Gem::Platform::RUBY 29 | s.require_paths = ['lib'] 30 | s.summary = 'Client for the Copycopter copy management service' 31 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 32 | s.version = CopycopterClient::VERSION 33 | end 34 | -------------------------------------------------------------------------------- /features/rails.feature: -------------------------------------------------------------------------------- 1 | @disable-bundler 2 | Feature: Using copycopter in a rails app 3 | 4 | Background: 5 | Given I have a copycopter project with an api key of "abc123" 6 | When I generate a rails application 7 | And I configure the copycopter client with api key "abc123" 8 | 9 | Scenario: copycopter in the controller 10 | Given the "abc123" project has the following blurbs: 11 | | key | draft content | 12 | | en.users.index.controller-test | This is a test | 13 | When I write to "app/controllers/users_controller.rb" with: 14 | """ 15 | class UsersController < ActionController::Base 16 | def index 17 | @text = t("users.index.controller-test", :default => "default") 18 | end 19 | end 20 | """ 21 | When I route the "users" resource 22 | And I write to "app/views/users/index.html.erb" with: 23 | """ 24 | <%= @text %> 25 | """ 26 | When I start the application 27 | And I wait for changes to be synchronized 28 | Then the copycopter client version and environment should have been logged 29 | Then the log should contain "Downloaded translations" 30 | When I visit /users/ 31 | Then the response should contain "This is a test" 32 | And the log should not contain "DEPRECATION WARNING" 33 | 34 | Scenario: copycopter in the view 35 | Given the "abc123" project has the following blurbs: 36 | | key | draft content | 37 | | en.users.index.view-test | This is a test | 38 | When I write to "app/controllers/users_controller.rb" with: 39 | """ 40 | class UsersController < ActionController::Base 41 | def index 42 | render 43 | end 44 | end 45 | """ 46 | When I route the "users" resource 47 | And I write to "app/views/users/index.html.erb" with: 48 | """ 49 | <%= t(".view-test", :default => "default") %> 50 | """ 51 | When I start the application 52 | And I wait for changes to be synchronized 53 | And I visit /users/ 54 | Then the response should contain "This is a test" 55 | 56 | Scenario: copycopter detects updates to copy 57 | Given the "abc123" project has the following blurbs: 58 | | key | draft content | 59 | | en.users.index.controller-test | Old content | 60 | When I write to "app/controllers/users_controller.rb" with: 61 | """ 62 | class UsersController < ActionController::Base 63 | def index 64 | @text = t("users.index.controller-test", :default => "default") 65 | end 66 | end 67 | """ 68 | When I route the "users" resource 69 | And I write to "app/views/users/index.html.erb" with: 70 | """ 71 | <%= @text %> 72 | """ 73 | When I start the application 74 | And I visit /users/ 75 | Then the response should contain "Old content" 76 | When the the following blurbs are updated in the "abc123" project: 77 | | key | draft content | 78 | | en.users.index.controller-test | New content | 79 | And I visit /users/ 80 | Then the response should contain "New content" 81 | 82 | Scenario: missing key 83 | When I write to "app/controllers/users_controller.rb" with: 84 | """ 85 | class UsersController < ActionController::Base 86 | def index 87 | render :action => "index" 88 | end 89 | end 90 | """ 91 | When I route the "users" resource 92 | And I write to "app/views/users/index.html.erb" with: 93 | """ 94 | <%= t(".404", :default => "not found") %> 95 | """ 96 | When I start the application 97 | And I visit /users/ 98 | Then the response should contain "not found" 99 | And the "abc123" project should have the following blurbs: 100 | | key | draft content | 101 | | en.users.index.404 | not found | 102 | And the log should contain "Uploaded missing translations" 103 | 104 | Scenario: copycopter in production 105 | Given the "abc123" project has the following blurbs: 106 | | key | published content | draft content | 107 | | en.users.index.controller-test | This is a test | Extra extra | 108 | | en.users.index.unpublished-test | | Extra extra | 109 | When I write to "app/controllers/users_controller.rb" with: 110 | """ 111 | class UsersController < ActionController::Base 112 | def index 113 | @text = t("users.index.controller-test", :default => "default") 114 | @unpublished = t("users.index.unpublished-test", :default => "Unpublished") 115 | t("users.index.unknown-test", :default => "Unknown") 116 | end 117 | end 118 | """ 119 | When I route the "users" resource 120 | And I write to "app/views/users/index.html.erb" with: 121 | """ 122 | <%= @text %> 123 | <%= @unpublished %> 124 | """ 125 | When I start the application in the "production" environment 126 | And I wait for changes to be synchronized 127 | And I visit /users/ 128 | Then the response should contain "This is a test" 129 | And the response should contain "Unpublished" 130 | And I wait for changes to be synchronized 131 | And the "abc123" project should have the following blurbs: 132 | | key | draft content | 133 | | en.users.index.unknown-test | Unknown | 134 | 135 | Scenario: configure a bad api key 136 | When I configure the copycopter client with api key "bogus" 137 | And I start the application 138 | And I wait for changes to be synchronized 139 | Then the log should contain "Invalid API key: bogus" 140 | 141 | Scenario: deploy 142 | Given the "abc123" project has the following blurbs: 143 | | key | draft content | published content | 144 | | test.one | expected one | unexpected one | 145 | | test.two | expected two | unexpected two | 146 | When I successfully rake "copycopter:deploy" 147 | And the output should contain "Successfully marked all blurbs as published" 148 | Then the "abc123" project should have the following blurbs: 149 | | key | draft content | published content | 150 | | test.one | expected one | expected one | 151 | | test.two | expected two | expected two | 152 | 153 | Scenario: fallback on the simple I18n backend 154 | When I write to "config/locales/en.yml" with: 155 | """ 156 | en: 157 | test: 158 | key: Hello, %{name} 159 | """ 160 | When I write to "app/controllers/users_controller.rb" with: 161 | """ 162 | class UsersController < ActionController::Base 163 | def index 164 | render :text => t("test.key", :name => 'Joe') 165 | end 166 | end 167 | """ 168 | When I route the "users" resource 169 | And I start the application 170 | And I visit /users/ 171 | Then the response should contain "Hello, Joe" 172 | When I wait for changes to be synchronized 173 | Then the "abc123" project should have the following blurbs: 174 | | key | draft content | 175 | | en.test.key | Hello, %{name} | 176 | 177 | Scenario: preserve localization keys 178 | When I write to "app/controllers/users_controller.rb" with: 179 | """ 180 | class UsersController < ActionController::Base 181 | def index 182 | render 183 | end 184 | end 185 | """ 186 | When I route the "users" resource 187 | And I write to "app/views/users/index.html.erb" with: 188 | """ 189 | <%= number_to_currency(2.5) %> 190 | """ 191 | When I start the application 192 | And I visit /users/ 193 | Then the response should contain "$2.50" 194 | When I wait for changes to be synchronized 195 | Then the "abc123" project should not have the "en.number.format" blurb 196 | 197 | Scenario: view validation errors 198 | When I write to "app/models/user.rb" with: 199 | """ 200 | class User < ActiveRecord::Base 201 | validates_presence_of :name 202 | end 203 | """ 204 | When I write to "db/migrate/1_create_users.rb" with: 205 | """ 206 | class CreateUsers < ActiveRecord::Migration 207 | def self.up 208 | create_table :users do |t| 209 | t.string :name 210 | end 211 | end 212 | end 213 | """ 214 | When I write to "app/controllers/users_controller.rb" with: 215 | """ 216 | class UsersController < ActionController::Base 217 | def index 218 | @user = User.new 219 | @user.valid? 220 | render 221 | end 222 | end 223 | """ 224 | When I route the "users" resource 225 | And I write to "app/views/users/index.html.erb" with: 226 | """ 227 | <%= @user.errors.full_messages.first %> 228 | """ 229 | When I successfully rake "db:migrate" 230 | And I configure the copycopter client to use published data 231 | And I start the application 232 | And I visit /users/ 233 | Then the response should contain "Name can't be blank" 234 | When I wait for changes to be synchronized 235 | Then the "abc123" project should have the following error blurbs: 236 | | key | draft content | 237 | | user.attributes.name.blank | can't be blank | 238 | 239 | Scenario: ensure keys are synced with short lived processes 240 | When I configure the copycopter client to have a polling delay of 86400 seconds 241 | And I start the application 242 | And I run a short lived process that sets the key "threaded.key" to "all your base" 243 | Then the "abc123" project should have the following blurbs: 244 | | key | draft content | 245 | | en.threaded.key | all your base | 246 | 247 | Scenario: support pluralization 248 | Given I write to "app/controllers/users_controller.rb" with: 249 | """ 250 | class UsersController < ActionController::Base 251 | def index 252 | render 253 | end 254 | end 255 | """ 256 | And I route the "users" resource 257 | And I write to "app/views/users/index.html.erb" with: 258 | """ 259 | <%= time_ago_in_words(1.hour.ago) %> ago 260 | <%= time_ago_in_words(2.hours.ago) %> ago 261 | """ 262 | When I start the application 263 | And I visit /users/ 264 | Then the response should contain "1 hour ago" 265 | And the response should contain "2 hours ago" 266 | When I wait for changes to be synchronized 267 | Then the "abc123" project should have the following blurbs: 268 | | key | draft content | 269 | | en.datetime.distance_in_words.about_x_hours.one | about 1 hour | 270 | | en.datetime.distance_in_words.about_x_hours.other | about %{count} hours | 271 | 272 | -------------------------------------------------------------------------------- /features/step_definitions/copycopter_server_steps.rb: -------------------------------------------------------------------------------- 1 | require File.join(PROJECT_ROOT, "spec", "support", "fake_copycopter_app") 2 | 3 | Given /^I have a copycopter project with an api key of "([^"]*)"$/ do |api_key| 4 | FakeCopycopterApp.add_project api_key 5 | end 6 | 7 | Given /^the "([^"]*)" project has the following blurbs:$/ do |api_key, table| 8 | project = FakeCopycopterApp.project(api_key) 9 | table.hashes.each do |blurb_hash| 10 | key = blurb_hash['key'] 11 | data = { 'draft' => { key => blurb_hash['draft content'] }, 12 | 'published' => { key => blurb_hash['published content'] } } 13 | project.update(data) 14 | end 15 | end 16 | 17 | When /^the the following blurbs are updated in the "([^"]*)" project:$/ do |api_key, table| 18 | Given %{the "#{api_key}" project has the following blurbs:}, table 19 | end 20 | 21 | Then /^the "([^"]*)" project should have the following blurbs:$/ do |api_key, table| 22 | project = FakeCopycopterApp.project(api_key) 23 | table.hashes.each do |blurb_hash| 24 | key = blurb_hash['key'] 25 | 26 | if blurb_hash['draft content'] 27 | unless project.draft[key] == blurb_hash['draft content'] 28 | raise "Expected #{blurb_hash['draft content']} for #{key} but got #{project.draft[key]}\nExisting keys: #{project.draft.inspect}" 29 | end 30 | end 31 | 32 | if blurb_hash['published content'] 33 | unless project.published[key] == blurb_hash['published content'] 34 | raise "Expected #{blurb_hash['published content']} for #{key} but got #{project.published[key]}\nExisting keys: #{project.published.inspect}" 35 | end 36 | end 37 | end 38 | end 39 | 40 | Then /^the "([^"]*)" project should have the following error blurbs:$/ do |api_key, table| 41 | prefix = 'en.activerecord.errors.models' 42 | 43 | rows = table.hashes.map do |error_blurb| 44 | "| #{prefix}.#{error_blurb['key']} | #{error_blurb['draft content']} |" 45 | end 46 | 47 | steps %{ 48 | Then the "#{api_key}" project should have the following blurbs: 49 | | key | draft content | 50 | #{rows.join("\n")} 51 | } 52 | end 53 | 54 | Then /^the "([^"]*)" project should not have the "([^"]*)" blurb$/ do |api_key, blurb_key| 55 | project = FakeCopycopterApp.project(api_key) 56 | project.draft[blurb_key].should be_nil 57 | end 58 | 59 | When /^I wait for changes to be synchronized$/ do 60 | sleep(3) 61 | end 62 | 63 | FakeCopycopterApp.start 64 | After { FakeCopycopterApp.reset } 65 | 66 | -------------------------------------------------------------------------------- /features/step_definitions/rails_steps.rb: -------------------------------------------------------------------------------- 1 | When "I generate a rails application" do 2 | if Rails::VERSION::MAJOR == 3 3 | subcommand = 'new' 4 | if Rails::VERSION::MINOR == 0 5 | options = '' 6 | else 7 | options = '--skip-bundle' 8 | end 9 | else 10 | subcommand = '' 11 | options = '' 12 | end 13 | 14 | run_simple("rails _#{Rails::VERSION::STRING}_ #{subcommand} testapp #{options}") 15 | cd("testapp") 16 | 17 | if Rails::VERSION::MAJOR == 3 18 | append_to_file("Gemfile", <<-GEMS) 19 | gem "thin" 20 | gem "sham_rack" 21 | gem "sinatra" 22 | gem "json" 23 | GEMS 24 | run_simple("bundle install --local") 25 | 26 | When %{I remove lines containing "rjs" from "config/environments/development.rb"} 27 | end 28 | end 29 | 30 | When /^I configure the copycopter client with api key "([^"]*)"$/ do |api_key| 31 | write_file("config/initializers/copycopter.rb", <<-RUBY) 32 | CopycopterClient.configure do |config| 33 | config.api_key = "#{api_key}" 34 | config.polling_delay = 1 35 | config.host = 'localhost' 36 | config.secure = false 37 | config.port = #{FakeCopycopterApp.port} 38 | end 39 | RUBY 40 | 41 | if Rails::VERSION::MAJOR == 3 42 | append_to_file("Gemfile", <<-GEMS) 43 | gem "copycopter_client", :path => "../../.." 44 | GEMS 45 | else 46 | in_current_dir { FileUtils.rm_f("vendor/plugins/copycopter") } 47 | run_simple("ln -s #{PROJECT_ROOT} vendor/plugins/copycopter") 48 | end 49 | end 50 | 51 | When "I start the application" do 52 | in_current_dir do 53 | RailsServer.start(ENV['RAILS_PORT'], @announce_stderr) 54 | end 55 | end 56 | 57 | When /^I start the application in the "([^"]+)" environment$/ do |environment| 58 | in_current_dir do 59 | old_environment = ENV['RAILS_ENV'] 60 | begin 61 | ENV['RAILS_ENV'] = environment 62 | RailsServer.start(ENV['RAILS_PORT'], @announce_stderr) 63 | ensure 64 | ENV['RAILS_ENV'] = old_environment 65 | end 66 | end 67 | end 68 | 69 | When /^I visit (\/.*)$/ do |path| 70 | @last_response = RailsServer.get(path) 71 | end 72 | 73 | When /^I configure the copycopter client to use published data$/ do 74 | in_current_dir do 75 | config_path = "config/initializers/copycopter.rb" 76 | contents = IO.read(config_path) 77 | contents.sub!("end", " config.development_environments = []\nend") 78 | File.open(config_path, "w") { |file| file.write(contents) } 79 | end 80 | end 81 | 82 | When /^I configure the copycopter client to have a polling delay of (\d+) seconds$/ do |polling_delay| 83 | in_current_dir do 84 | config_path = "config/initializers/copycopter.rb" 85 | contents = IO.read(config_path) 86 | contents.sub!(/config.polling_delay = .+/, "config.polling_delay = #{polling_delay}") 87 | File.open(config_path, "w") { |file| file.write(contents) } 88 | end 89 | end 90 | 91 | Then /^the copycopter client version and environment should have been logged$/ do 92 | client_version = CopycopterClient::VERSION 93 | environment_info = "[Ruby: #{RUBY_VERSION}]" 94 | environment_info << " [Rails: #{Rails::VERSION::STRING}]" 95 | environment_info << " [Env: development]" 96 | steps %{ 97 | Then the log should contain "Client #{client_version} ready" 98 | Then the log should contain "Environment Info: #{environment_info}" 99 | } 100 | end 101 | 102 | Then /^the log should contain "([^"]*)"$/ do |line| 103 | prefix = "** [Copycopter] " 104 | pattern = Regexp.compile([Regexp.escape(prefix), Regexp.escape(line)].join(".*")) 105 | log_path = "log/development.log" 106 | in_current_dir do 107 | File.open(log_path) do |file| 108 | unless file.readlines.any? { |file_line| file_line =~ pattern } 109 | raise "In log file:\n#{IO.read(log_path)}\n\nMissing line:\n#{pattern}" 110 | end 111 | end 112 | end 113 | end 114 | 115 | Then /^the log should not contain "([^"]*)"$/ do |line| 116 | log_path = "log/development.log" 117 | in_current_dir do 118 | File.open(log_path) do |file| 119 | if bad_line = file.readlines.detect { |file_line| file_line.include?(line) } 120 | raise "In log file:\n#{log_path}\n\nGot unexpected line:\n#{bad_line}" 121 | end 122 | end 123 | end 124 | end 125 | 126 | When /^I successfully rake "([^"]*)"$/ do |task| 127 | run_simple("rake #{task}") 128 | end 129 | 130 | Then /^the response should contain "([^"]+)"$/ do |text| 131 | @last_response.body.should include(text) 132 | end 133 | 134 | When /^I route the "([^"]+)" resource$/ do |resource| 135 | if Rails::VERSION::MAJOR == 3 136 | draw = "Testapp::Application.routes.draw do\n" 137 | else 138 | draw = "ActionController::Routing::Routes.draw do |map|\nmap." 139 | end 140 | 141 | routes = "#{draw}resources :#{resource}\nend" 142 | 143 | overwrite_file("config/routes.rb", routes) 144 | end 145 | 146 | When /^I run a short lived process that sets the key "([^"]*)" to "([^"]*)"$/ do |key, value| 147 | if Rails::VERSION::MAJOR == 3 148 | run_simple %[script/rails runner 'I18n.translate("#{key}", :default => "#{value}")'] 149 | else 150 | run_simple %[script/runner 'I18n.translate("#{key}", :default => "#{value}")'] 151 | end 152 | end 153 | 154 | When /^I remove lines containing "([^"]*)" from "([^"]*)"$/ do |content, filename| 155 | in_current_dir do 156 | result = "" 157 | File.open(filename, "r") do |file| 158 | file.each_line do |line| 159 | result << line unless line.include?(content) 160 | end 161 | end 162 | 163 | File.open(filename, "w") do |file| 164 | file.write(result) 165 | end 166 | end 167 | end 168 | 169 | 170 | After do 171 | RailsServer.stop 172 | end 173 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require 'sham_rack' 2 | require 'aruba/cucumber' 3 | require 'rails/version' 4 | 5 | PROJECT_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..', '..')) 6 | $LOAD_PATH << File.join(PROJECT_ROOT, 'lib') 7 | require "copycopter_client/version" 8 | 9 | Before do 10 | @aruba_timeout_seconds = 15 11 | end 12 | 13 | -------------------------------------------------------------------------------- /features/support/rails_server.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | 3 | # Starts a Rails application server in a fork and waits for it to be responsive 4 | class RailsServer 5 | HOST = 'localhost'.freeze 6 | 7 | class << self 8 | attr_accessor :instance 9 | end 10 | 11 | def self.start(port = nil, debug = nil) 12 | self.instance = new(port, debug) 13 | self.instance.start 14 | self.instance 15 | end 16 | 17 | def self.stop 18 | self.instance.stop if instance 19 | self.instance = nil 20 | end 21 | 22 | def self.get(path) 23 | self.instance.get(path) 24 | end 25 | 26 | def self.post(path, data) 27 | self.instance.post(path, data) 28 | end 29 | 30 | def self.run(port, silent) 31 | if silent 32 | require 'stringio' 33 | $stdout = StringIO.new 34 | $stderr = StringIO.new 35 | end 36 | 37 | require './config/environment' 38 | require 'thin' 39 | 40 | if Rails::VERSION::MAJOR == 3 41 | rails = Rails.application 42 | else 43 | rails = ActionController::Dispatcher.new 44 | end 45 | app = Identify.new(rails) 46 | 47 | Thin::Logging.silent = silent 48 | Rack::Handler::Thin.run(app, :Port => port, :AccessLog => []) 49 | end 50 | 51 | def self.app_host 52 | self.instance.app_host 53 | end 54 | 55 | def initialize(port, debug) 56 | @port = (port || 3001).to_i 57 | @debug = debug 58 | end 59 | 60 | def start 61 | @pid = fork do 62 | command = "ruby -r#{__FILE__} -e 'RailsServer.run(#{@port}, #{(!@debug).inspect})'" 63 | puts command if @debug 64 | exec(command) 65 | end 66 | wait_until_responsive 67 | end 68 | 69 | def stop 70 | if @pid 71 | Process.kill('INT', @pid) 72 | Process.wait(@pid) 73 | @pid = nil 74 | end 75 | end 76 | 77 | def get(path) 78 | puts "GET #{path}" if @debug 79 | Net::HTTP.start(HOST, @port) { |http| http.get(path) } 80 | end 81 | 82 | def post(path, data) 83 | puts "POST #{path}\n#{data}" if @debug 84 | Net::HTTP.start(HOST, @port) { |http| http.post(path, data) } 85 | end 86 | 87 | def wait_until_responsive 88 | 20.times do 89 | if responsive? 90 | return true 91 | else 92 | sleep(0.5) 93 | end 94 | end 95 | raise "Couldn't connect to Rails application server at #{HOST}:#{@port}" 96 | end 97 | 98 | def responsive? 99 | response = Net::HTTP.start(HOST, @port) { |http| http.get('/__identify__') } 100 | response.is_a?(Net::HTTPSuccess) 101 | rescue Errno::ECONNREFUSED, Errno::EBADF 102 | return false 103 | end 104 | 105 | def app_host 106 | "http://#{HOST}:#{@port}" 107 | end 108 | 109 | # From Capybara::Server 110 | 111 | class Identify 112 | def initialize(app) 113 | @app = app 114 | end 115 | 116 | def call(env) 117 | if env["PATH_INFO"] == "/__identify__" 118 | [200, {}, 'OK'] 119 | else 120 | @app.call(env) 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /gemfiles/2.3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "rails", "2.3.14" 6 | 7 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/2.3.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: /Users/croaky/dev/copycopter-ruby-client 3 | specs: 4 | copycopter_client (1.1.2) 5 | i18n (>= 0.5.0) 6 | json 7 | 8 | GEM 9 | remote: http://rubygems.org/ 10 | specs: 11 | actionmailer (2.3.14) 12 | actionpack (= 2.3.14) 13 | actionpack (2.3.14) 14 | activesupport (= 2.3.14) 15 | rack (~> 1.1.0) 16 | activerecord (2.3.14) 17 | activesupport (= 2.3.14) 18 | activeresource (2.3.14) 19 | activesupport (= 2.3.14) 20 | activesupport (2.3.14) 21 | addressable (2.2.6) 22 | appraisal (0.4.0) 23 | bundler 24 | rake 25 | aruba (0.3.7) 26 | childprocess (>= 0.1.9) 27 | cucumber (>= 0.10.5) 28 | rspec (>= 2.6.0) 29 | bourne (1.0) 30 | mocha (= 0.9.8) 31 | builder (3.0.0) 32 | childprocess (0.2.2) 33 | ffi (~> 1.0.6) 34 | crack (0.3.1) 35 | cucumber (0.10.7) 36 | builder (>= 2.1.2) 37 | diff-lcs (>= 1.1.2) 38 | gherkin (~> 2.4.0) 39 | json (>= 1.4.6) 40 | term-ansicolor (>= 1.0.5) 41 | daemons (1.1.4) 42 | diff-lcs (1.1.3) 43 | eventmachine (0.12.10) 44 | ffi (1.0.11) 45 | gherkin (2.4.21) 46 | json (>= 1.4.6) 47 | i18n (0.6.0) 48 | json (1.6.1) 49 | mocha (0.9.8) 50 | rake 51 | rack (1.1.2) 52 | rails (2.3.14) 53 | actionmailer (= 2.3.14) 54 | actionpack (= 2.3.14) 55 | activerecord (= 2.3.14) 56 | activeresource (= 2.3.14) 57 | activesupport (= 2.3.14) 58 | rake (>= 0.8.3) 59 | rake (0.9.2) 60 | rspec (2.7.0) 61 | rspec-core (~> 2.7.0) 62 | rspec-expectations (~> 2.7.0) 63 | rspec-mocks (~> 2.7.0) 64 | rspec-core (2.7.1) 65 | rspec-expectations (2.7.0) 66 | diff-lcs (~> 1.1.2) 67 | rspec-mocks (2.7.0) 68 | sham_rack (1.3.3) 69 | rack 70 | sinatra (1.2.7) 71 | rack (~> 1.1) 72 | tilt (>= 1.2.2, < 2.0) 73 | sqlite3 (1.3.4) 74 | sqlite3-ruby (1.3.3) 75 | sqlite3 (>= 1.3.3) 76 | term-ansicolor (1.0.7) 77 | thin (1.3.1) 78 | daemons (>= 1.0.9) 79 | eventmachine (>= 0.12.6) 80 | rack (>= 1.0.0) 81 | tilt (1.3.3) 82 | webmock (1.7.7) 83 | addressable (~> 2.2, > 2.2.5) 84 | crack (>= 0.1.7) 85 | yard (0.7.3) 86 | 87 | PLATFORMS 88 | ruby 89 | 90 | DEPENDENCIES 91 | appraisal (~> 0.4) 92 | aruba (~> 0.3.2) 93 | bourne 94 | copycopter_client! 95 | cucumber (~> 0.10.0) 96 | i18n 97 | rails (= 2.3.14) 98 | rake (= 0.9.2) 99 | rspec (~> 2.3) 100 | sham_rack 101 | sinatra 102 | sqlite3-ruby 103 | thin 104 | webmock 105 | yard 106 | -------------------------------------------------------------------------------- /gemfiles/3.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "rails", "3.0.3" 6 | 7 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/3.0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: /Users/croaky/dev/copycopter-ruby-client 3 | specs: 4 | copycopter_client (1.1.2) 5 | i18n (>= 0.5.0) 6 | json 7 | 8 | GEM 9 | remote: http://rubygems.org/ 10 | specs: 11 | abstract (1.0.0) 12 | actionmailer (3.0.3) 13 | actionpack (= 3.0.3) 14 | mail (~> 2.2.9) 15 | actionpack (3.0.3) 16 | activemodel (= 3.0.3) 17 | activesupport (= 3.0.3) 18 | builder (~> 2.1.2) 19 | erubis (~> 2.6.6) 20 | i18n (~> 0.4) 21 | rack (~> 1.2.1) 22 | rack-mount (~> 0.6.13) 23 | rack-test (~> 0.5.6) 24 | tzinfo (~> 0.3.23) 25 | activemodel (3.0.3) 26 | activesupport (= 3.0.3) 27 | builder (~> 2.1.2) 28 | i18n (~> 0.4) 29 | activerecord (3.0.3) 30 | activemodel (= 3.0.3) 31 | activesupport (= 3.0.3) 32 | arel (~> 2.0.2) 33 | tzinfo (~> 0.3.23) 34 | activeresource (3.0.3) 35 | activemodel (= 3.0.3) 36 | activesupport (= 3.0.3) 37 | activesupport (3.0.3) 38 | addressable (2.2.6) 39 | appraisal (0.4.0) 40 | bundler 41 | rake 42 | arel (2.0.10) 43 | aruba (0.3.7) 44 | childprocess (>= 0.1.9) 45 | cucumber (>= 0.10.5) 46 | rspec (>= 2.6.0) 47 | bourne (1.0) 48 | mocha (= 0.9.8) 49 | builder (2.1.2) 50 | childprocess (0.2.2) 51 | ffi (~> 1.0.6) 52 | crack (0.3.1) 53 | cucumber (0.10.7) 54 | builder (>= 2.1.2) 55 | diff-lcs (>= 1.1.2) 56 | gherkin (~> 2.4.0) 57 | json (>= 1.4.6) 58 | term-ansicolor (>= 1.0.5) 59 | daemons (1.1.4) 60 | diff-lcs (1.1.3) 61 | erubis (2.6.6) 62 | abstract (>= 1.0.0) 63 | eventmachine (0.12.10) 64 | ffi (1.0.11) 65 | gherkin (2.4.21) 66 | json (>= 1.4.6) 67 | i18n (0.6.0) 68 | json (1.6.1) 69 | mail (2.2.19) 70 | activesupport (>= 2.3.6) 71 | i18n (>= 0.4.0) 72 | mime-types (~> 1.16) 73 | treetop (~> 1.4.8) 74 | mime-types (1.17.2) 75 | mocha (0.9.8) 76 | rake 77 | polyglot (0.3.3) 78 | rack (1.2.4) 79 | rack-mount (0.6.14) 80 | rack (>= 1.0.0) 81 | rack-test (0.5.7) 82 | rack (>= 1.0) 83 | rails (3.0.3) 84 | actionmailer (= 3.0.3) 85 | actionpack (= 3.0.3) 86 | activerecord (= 3.0.3) 87 | activeresource (= 3.0.3) 88 | activesupport (= 3.0.3) 89 | bundler (~> 1.0) 90 | railties (= 3.0.3) 91 | railties (3.0.3) 92 | actionpack (= 3.0.3) 93 | activesupport (= 3.0.3) 94 | rake (>= 0.8.7) 95 | thor (~> 0.14.4) 96 | rake (0.9.2) 97 | rspec (2.7.0) 98 | rspec-core (~> 2.7.0) 99 | rspec-expectations (~> 2.7.0) 100 | rspec-mocks (~> 2.7.0) 101 | rspec-core (2.7.1) 102 | rspec-expectations (2.7.0) 103 | diff-lcs (~> 1.1.2) 104 | rspec-mocks (2.7.0) 105 | sham_rack (1.3.3) 106 | rack 107 | sinatra (1.2.7) 108 | rack (~> 1.1) 109 | tilt (>= 1.2.2, < 2.0) 110 | sqlite3 (1.3.4) 111 | sqlite3-ruby (1.3.3) 112 | sqlite3 (>= 1.3.3) 113 | term-ansicolor (1.0.7) 114 | thin (1.3.1) 115 | daemons (>= 1.0.9) 116 | eventmachine (>= 0.12.6) 117 | rack (>= 1.0.0) 118 | thor (0.14.6) 119 | tilt (1.3.3) 120 | treetop (1.4.10) 121 | polyglot 122 | polyglot (>= 0.3.1) 123 | tzinfo (0.3.32) 124 | webmock (1.7.7) 125 | addressable (~> 2.2, > 2.2.5) 126 | crack (>= 0.1.7) 127 | yard (0.7.3) 128 | 129 | PLATFORMS 130 | ruby 131 | 132 | DEPENDENCIES 133 | appraisal (~> 0.4) 134 | aruba (~> 0.3.2) 135 | bourne 136 | copycopter_client! 137 | cucumber (~> 0.10.0) 138 | i18n 139 | rails (= 3.0.3) 140 | rake (= 0.9.2) 141 | rspec (~> 2.3) 142 | sham_rack 143 | sinatra 144 | sqlite3-ruby 145 | thin 146 | webmock 147 | yard 148 | -------------------------------------------------------------------------------- /gemfiles/3.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "rails", "3.1.0" 6 | gem "jquery-rails" 7 | gem "uglifier" 8 | gem "sass-rails" 9 | gem "coffee-rails" 10 | 11 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/3.1.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: /Users/croaky/dev/copycopter-ruby-client 3 | specs: 4 | copycopter_client (1.1.2) 5 | i18n (>= 0.5.0) 6 | json 7 | 8 | GEM 9 | remote: http://rubygems.org/ 10 | specs: 11 | actionmailer (3.1.0) 12 | actionpack (= 3.1.0) 13 | mail (~> 2.3.0) 14 | actionpack (3.1.0) 15 | activemodel (= 3.1.0) 16 | activesupport (= 3.1.0) 17 | builder (~> 3.0.0) 18 | erubis (~> 2.7.0) 19 | i18n (~> 0.6) 20 | rack (~> 1.3.2) 21 | rack-cache (~> 1.0.3) 22 | rack-mount (~> 0.8.2) 23 | rack-test (~> 0.6.1) 24 | sprockets (~> 2.0.0) 25 | activemodel (3.1.0) 26 | activesupport (= 3.1.0) 27 | bcrypt-ruby (~> 3.0.0) 28 | builder (~> 3.0.0) 29 | i18n (~> 0.6) 30 | activerecord (3.1.0) 31 | activemodel (= 3.1.0) 32 | activesupport (= 3.1.0) 33 | arel (~> 2.2.1) 34 | tzinfo (~> 0.3.29) 35 | activeresource (3.1.0) 36 | activemodel (= 3.1.0) 37 | activesupport (= 3.1.0) 38 | activesupport (3.1.0) 39 | multi_json (~> 1.0) 40 | addressable (2.2.7) 41 | appraisal (0.4.1) 42 | bundler 43 | rake 44 | arel (2.2.3) 45 | aruba (0.3.7) 46 | childprocess (>= 0.1.9) 47 | cucumber (>= 0.10.5) 48 | rspec (>= 2.6.0) 49 | bcrypt-ruby (3.0.1) 50 | bourne (1.1.1) 51 | mocha (= 0.10.4) 52 | builder (3.0.0) 53 | childprocess (0.3.1) 54 | ffi (~> 1.0.6) 55 | coffee-rails (3.1.1) 56 | coffee-script (>= 2.2.0) 57 | railties (~> 3.1.0) 58 | coffee-script (2.2.0) 59 | coffee-script-source 60 | execjs 61 | coffee-script-source (1.2.0) 62 | crack (0.3.1) 63 | cucumber (0.10.7) 64 | builder (>= 2.1.2) 65 | diff-lcs (>= 1.1.2) 66 | gherkin (~> 2.4.0) 67 | json (>= 1.4.6) 68 | term-ansicolor (>= 1.0.5) 69 | daemons (1.1.8) 70 | diff-lcs (1.1.3) 71 | erubis (2.7.0) 72 | eventmachine (0.12.10) 73 | execjs (1.3.0) 74 | multi_json (~> 1.0) 75 | ffi (1.0.11) 76 | gherkin (2.4.21) 77 | json (>= 1.4.6) 78 | hike (1.2.1) 79 | i18n (0.6.0) 80 | jquery-rails (1.0.19) 81 | railties (~> 3.0) 82 | thor (~> 0.14) 83 | json (1.6.5) 84 | mail (2.3.3) 85 | i18n (>= 0.4.0) 86 | mime-types (~> 1.16) 87 | treetop (~> 1.4.8) 88 | metaclass (0.0.1) 89 | mime-types (1.17.2) 90 | mocha (0.10.4) 91 | metaclass (~> 0.0.1) 92 | multi_json (1.1.0) 93 | polyglot (0.3.3) 94 | rack (1.3.6) 95 | rack-cache (1.0.3) 96 | rack (>= 0.4) 97 | rack-mount (0.8.3) 98 | rack (>= 1.0.0) 99 | rack-protection (1.2.0) 100 | rack 101 | rack-ssl (1.3.2) 102 | rack 103 | rack-test (0.6.1) 104 | rack (>= 1.0) 105 | rails (3.1.0) 106 | actionmailer (= 3.1.0) 107 | actionpack (= 3.1.0) 108 | activerecord (= 3.1.0) 109 | activeresource (= 3.1.0) 110 | activesupport (= 3.1.0) 111 | bundler (~> 1.0) 112 | railties (= 3.1.0) 113 | railties (3.1.0) 114 | actionpack (= 3.1.0) 115 | activesupport (= 3.1.0) 116 | rack-ssl (~> 1.3.2) 117 | rake (>= 0.8.7) 118 | rdoc (~> 3.4) 119 | thor (~> 0.14.6) 120 | rake (0.9.2) 121 | rdoc (3.12) 122 | json (~> 1.4) 123 | rspec (2.8.0) 124 | rspec-core (~> 2.8.0) 125 | rspec-expectations (~> 2.8.0) 126 | rspec-mocks (~> 2.8.0) 127 | rspec-core (2.8.0) 128 | rspec-expectations (2.8.0) 129 | diff-lcs (~> 1.1.2) 130 | rspec-mocks (2.8.0) 131 | sass (3.1.15) 132 | sass-rails (3.1.5) 133 | actionpack (~> 3.1.0) 134 | railties (~> 3.1.0) 135 | sass (~> 3.1.10) 136 | tilt (~> 1.3.2) 137 | sham_rack (1.3.3) 138 | rack 139 | sinatra (1.3.2) 140 | rack (~> 1.3, >= 1.3.6) 141 | rack-protection (~> 1.2) 142 | tilt (~> 1.3, >= 1.3.3) 143 | sprockets (2.0.3) 144 | hike (~> 1.2) 145 | rack (~> 1.0) 146 | tilt (~> 1.1, != 1.3.0) 147 | sqlite3 (1.3.5) 148 | sqlite3-ruby (1.3.3) 149 | sqlite3 (>= 1.3.3) 150 | term-ansicolor (1.0.7) 151 | thin (1.3.1) 152 | daemons (>= 1.0.9) 153 | eventmachine (>= 0.12.6) 154 | rack (>= 1.0.0) 155 | thor (0.14.6) 156 | tilt (1.3.3) 157 | treetop (1.4.10) 158 | polyglot 159 | polyglot (>= 0.3.1) 160 | tzinfo (0.3.32) 161 | uglifier (1.2.3) 162 | execjs (>= 0.3.0) 163 | multi_json (>= 1.0.2) 164 | webmock (1.8.3) 165 | addressable (>= 2.2.7) 166 | crack (>= 0.1.7) 167 | yard (0.7.5) 168 | 169 | PLATFORMS 170 | ruby 171 | 172 | DEPENDENCIES 173 | appraisal (~> 0.4) 174 | aruba (~> 0.3.2) 175 | bourne 176 | coffee-rails 177 | copycopter_client! 178 | cucumber (~> 0.10.0) 179 | i18n 180 | jquery-rails 181 | rails (= 3.1.0) 182 | rake (= 0.9.2) 183 | rspec (~> 2.3) 184 | sass-rails 185 | sham_rack 186 | sinatra 187 | sqlite3-ruby 188 | thin 189 | uglifier 190 | webmock 191 | yard 192 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'copycopter_client' 2 | 3 | -------------------------------------------------------------------------------- /lib/copycopter_client.rb: -------------------------------------------------------------------------------- 1 | require 'copycopter_client/version' 2 | require 'copycopter_client/configuration' 3 | 4 | # Top-level interface to the Copycopter client. 5 | # 6 | # Most applications should only need to use the {.configure} 7 | # method, which will setup all the pieces and begin synchronization when 8 | # appropriate. 9 | module CopycopterClient 10 | class << self 11 | # @return [Configuration] current client configuration 12 | # Must act like a hash and return sensible values for all Copycopter 13 | # configuration options. Usually set when {.configure} is called. 14 | attr_accessor :configuration 15 | 16 | # @return [Poller] instance used to poll for changes. 17 | # This is set when {.configure} is called. 18 | attr_accessor :poller 19 | end 20 | 21 | # Issues a new deploy, marking all draft blurbs as published. 22 | # This is called when the copycopter:deploy rake task is invoked. 23 | def self.deploy 24 | client.deploy 25 | end 26 | 27 | # Issues a new export, returning yaml representation of blurb cache. 28 | # This is called when the copycopter:export rake task is invoked. 29 | def self.export 30 | cache.export 31 | end 32 | 33 | # Starts the polling process. 34 | def self.start_poller 35 | poller.start 36 | end 37 | 38 | # Flush queued changed synchronously 39 | def self.flush 40 | cache.flush 41 | end 42 | 43 | def self.cache 44 | CopycopterClient.configuration.cache 45 | end 46 | 47 | def self.client 48 | CopycopterClient.configuration.client 49 | end 50 | 51 | # Call this method to modify defaults in your initializers. 52 | # 53 | # @example 54 | # CopycopterClient.configure do |config| 55 | # config.api_key = '1234567890abcdef' 56 | # config.host = 'your-copycopter-server.herokuapp.com' 57 | # config.secure = true 58 | # end 59 | # 60 | # @param apply [Boolean] (internal) whether the configuration should be applied yet. 61 | # 62 | # @yield [Configuration] the configuration to be modified 63 | def self.configure(apply = true) 64 | self.configuration ||= Configuration.new 65 | yield configuration 66 | 67 | if apply 68 | configuration.apply 69 | end 70 | end 71 | end 72 | 73 | if defined? Rails 74 | require 'copycopter_client/rails' 75 | end 76 | 77 | -------------------------------------------------------------------------------- /lib/copycopter_client/cache.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'copycopter_client/client' 3 | 4 | module CopycopterClient 5 | # Manages synchronization of copy between {I18nBackend} and {Client}. Acts 6 | # like a Hash. Applications using the client will not need to interact with 7 | # this class directly. 8 | # 9 | # Responsible for locking down access to data used by both threads. 10 | class Cache 11 | # Usually instantiated when {Configuration#apply} is invoked. 12 | # @param client [Client] the client used to fetch and upload data 13 | # @param options [Hash] 14 | # @option options [Logger] :logger where errors should be logged 15 | def initialize(client, options) 16 | @blurbs = {} 17 | @client = client 18 | @downloaded = false 19 | @logger = options[:logger] 20 | @mutex = Mutex.new 21 | @queued = {} 22 | @started = false 23 | end 24 | 25 | # Returns content for the given blurb. 26 | # @param key [String] the key of the desired blurb 27 | # @return [String] the contents of the blurb 28 | def [](key) 29 | lock { @blurbs[key] } 30 | end 31 | 32 | # Sets content for the given blurb. The content will be pushed to the 33 | # server on the next flush. 34 | # @param key [String] the key of the blurb to update 35 | # @param value [String] the new contents of the blurb 36 | def []=(key, value) 37 | lock { @queued[key] = value } 38 | end 39 | 40 | # Keys for all blurbs stored on the server. 41 | # @return [Array] keys 42 | def keys 43 | lock { @blurbs.keys } 44 | end 45 | 46 | # Yaml representation of all blurbs 47 | # @return [String] yaml 48 | def export 49 | keys = {} 50 | lock do 51 | @blurbs.sort.each do |(blurb_key, value)| 52 | current = keys 53 | yaml_keys = blurb_key.split('.') 54 | 55 | 0.upto(yaml_keys.size - 2) do |i| 56 | key = yaml_keys[i] 57 | 58 | # Overwrite en.key with en.sub.key 59 | unless current[key].class == Hash 60 | current[key] = {} 61 | end 62 | 63 | current = current[key] 64 | end 65 | 66 | current[yaml_keys.last] = value 67 | end 68 | end 69 | 70 | unless keys.size < 1 71 | keys.to_yaml 72 | end 73 | end 74 | 75 | # Waits until the first download has finished. 76 | def wait_for_download 77 | if pending? 78 | logger.info 'Waiting for first download' 79 | 80 | if logger.respond_to? :flush 81 | logger.flush 82 | end 83 | 84 | while pending? 85 | sleep 0.1 86 | end 87 | end 88 | end 89 | 90 | def flush 91 | with_queued_changes do |queued| 92 | client.upload queued 93 | end 94 | rescue ConnectionError => error 95 | logger.error error.message 96 | end 97 | 98 | def download 99 | @started = true 100 | 101 | client.download do |downloaded_blurbs| 102 | downloaded_blurbs.reject! { |key, value| value == '' } 103 | lock { @blurbs = downloaded_blurbs } 104 | end 105 | rescue ConnectionError => error 106 | logger.error error.message 107 | ensure 108 | @downloaded = true 109 | end 110 | 111 | # Downloads and then flushes 112 | def sync 113 | download 114 | flush 115 | end 116 | 117 | private 118 | 119 | attr_reader :client, :logger 120 | 121 | def with_queued_changes 122 | changes_to_push = nil 123 | 124 | lock do 125 | unless @queued.empty? 126 | changes_to_push = @queued 127 | @queued = {} 128 | end 129 | end 130 | 131 | if changes_to_push 132 | yield changes_to_push 133 | end 134 | end 135 | 136 | def lock(&block) 137 | @mutex.synchronize &block 138 | end 139 | 140 | def pending? 141 | @started && !@downloaded 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/copycopter_client/client.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'net/https' 3 | require 'copycopter_client/errors' 4 | 5 | module CopycopterClient 6 | # Communicates with the Copycopter server. This class is used to actually 7 | # download and upload blurbs, as well as issuing deploys. 8 | # 9 | # A client is usually instantiated when {Configuration#apply} is called, and 10 | # the application will not need to interact with it directly. 11 | class Client 12 | # These errors will be rescued when connecting Copycopter. 13 | HTTP_ERRORS = [Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError, 14 | Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, 15 | Net::ProtocolError, SocketError, OpenSSL::SSL::SSLError, 16 | Errno::ECONNREFUSED] 17 | 18 | # Usually instantiated from {Configuration#apply}. Copies options. 19 | # @param options [Hash] 20 | # @option options [String] :api_key API key of the project to connect to 21 | # @option options [Fixnum] :port the port to connect to 22 | # @option options [Boolean] :public whether to download draft or published content 23 | # @option options [Fixnum] :http_read_timeout how long to wait before timing out when reading data from the socket 24 | # @option options [Fixnum] :http_open_timeout how long to wait before timing out when opening the socket 25 | # @option options [Boolean] :secure whether to use SSL 26 | # @option options [Logger] :logger where to log transactions 27 | # @option options [String] :ca_file path to root certificate file for ssl verification 28 | def initialize(options) 29 | [:api_key, :host, :port, :public, :http_read_timeout, 30 | :http_open_timeout, :secure, :logger, :ca_file].each do |option| 31 | instance_variable_set "@#{option}", options[option] 32 | end 33 | end 34 | 35 | # Downloads all blurbs for the given api_key. 36 | # 37 | # If the +public+ option was set to +true+, this will use published blurbs. 38 | # Otherwise, draft content is fetched. 39 | # 40 | # The client tracks ETags between download requests, and will return 41 | # without yielding anything if the server returns a not modified response. 42 | # 43 | # @yield [Hash] downloaded blurbs 44 | # @raise [ConnectionError] if the connection fails 45 | def download 46 | connect do |http| 47 | request = Net::HTTP::Get.new(uri(download_resource)) 48 | request['If-None-Match'] = @etag 49 | response = http.request(request) 50 | 51 | if check response 52 | log 'Downloaded translations' 53 | yield JSON.parse(response.body) 54 | else 55 | log 'No new translations' 56 | end 57 | 58 | @etag = response['ETag'] 59 | end 60 | end 61 | 62 | # Uploads the given hash of blurbs as draft content. 63 | # @param data [Hash] the blurbs to upload 64 | # @raise [ConnectionError] if the connection fails 65 | def upload(data) 66 | connect do |http| 67 | response = http.post(uri('draft_blurbs'), data.to_json, 'Content-Type' => 'application/json') 68 | check response 69 | log 'Uploaded missing translations' 70 | end 71 | end 72 | 73 | # Issues a deploy, marking all draft content as published for this project. 74 | # @raise [ConnectionError] if the connection fails 75 | def deploy 76 | connect do |http| 77 | response = http.post(uri('deploys'), '') 78 | check response 79 | log 'Deployed' 80 | end 81 | end 82 | 83 | private 84 | 85 | attr_reader :host, :port, :api_key, :http_read_timeout, 86 | :http_open_timeout, :secure, :logger, :ca_file 87 | 88 | def public? 89 | @public 90 | end 91 | 92 | def uri(resource) 93 | "/api/v2/projects/#{api_key}/#{resource}" 94 | end 95 | 96 | def download_resource 97 | if public? 98 | 'published_blurbs' 99 | else 100 | 'draft_blurbs' 101 | end 102 | end 103 | 104 | def connect 105 | http = Net::HTTP.new(host, port) 106 | http.open_timeout = http_open_timeout 107 | http.read_timeout = http_read_timeout 108 | http.use_ssl = secure 109 | http.verify_mode = OpenSSL::SSL::VERIFY_PEER 110 | http.ca_file = ca_file 111 | 112 | begin 113 | yield http 114 | rescue *HTTP_ERRORS => exception 115 | raise ConnectionError, "#{exception.class.name}: #{exception.message}" 116 | end 117 | end 118 | 119 | def check(response) 120 | case response 121 | when Net::HTTPNotFound 122 | raise InvalidApiKey, "Invalid API key: #{api_key}" 123 | when Net::HTTPNotModified 124 | false 125 | when Net::HTTPSuccess 126 | true 127 | else 128 | raise ConnectionError, "#{response.code}: #{response.body}" 129 | end 130 | end 131 | 132 | def log(message) 133 | logger.info message 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/copycopter_client/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'copycopter_client/i18n_backend' 3 | require 'copycopter_client/client' 4 | require 'copycopter_client/cache' 5 | require 'copycopter_client/process_guard' 6 | require 'copycopter_client/poller' 7 | require 'copycopter_client/prefixed_logger' 8 | require 'copycopter_client/request_sync' 9 | 10 | module CopycopterClient 11 | # Used to set up and modify settings for the client. 12 | class Configuration 13 | 14 | # These options will be present in the Hash returned by {#to_hash}. 15 | OPTIONS = [:api_key, :development_environments, :environment_name, :host, 16 | :http_open_timeout, :http_read_timeout, :client_name, :client_url, 17 | :client_version, :port, :protocol, :proxy_host, :proxy_pass, 18 | :proxy_port, :proxy_user, :secure, :polling_delay, :logger, 19 | :framework, :middleware, :ca_file].freeze 20 | 21 | # @return [String] The API key for your project, found on the project edit form. 22 | attr_accessor :api_key 23 | 24 | # @return [String] The host to connect to (defaults to +copycopter.com+). 25 | attr_accessor :host 26 | 27 | # @return [Fixnum] The port on which your Copycopter server runs (defaults to +443+ for secure connections, +80+ for insecure connections). 28 | attr_accessor :port 29 | 30 | # @return [Boolean] +true+ for https connections, +false+ for http connections. 31 | attr_accessor :secure 32 | 33 | # @return [Fixnum] The HTTP open timeout in seconds (defaults to +2+). 34 | attr_accessor :http_open_timeout 35 | 36 | # @return [Fixnum] The HTTP read timeout in seconds (defaults to +5+). 37 | attr_accessor :http_read_timeout 38 | 39 | # @return [String, NilClass] The hostname of your proxy server (if using a proxy) 40 | attr_accessor :proxy_host 41 | 42 | # @return [String, Fixnum] The port of your proxy server (if using a proxy) 43 | attr_accessor :proxy_port 44 | 45 | # @return [String, NilClass] The username to use when logging into your proxy server (if using a proxy) 46 | attr_accessor :proxy_user 47 | 48 | # @return [String, NilClass] The password to use when logging into your proxy server (if using a proxy) 49 | attr_accessor :proxy_pass 50 | 51 | # @return [Array] A list of environments in which content should be editable 52 | attr_accessor :development_environments 53 | 54 | # @return [Array] A list of environments in which the server should not be contacted 55 | attr_accessor :test_environments 56 | 57 | # @return [String] The name of the environment the application is running in 58 | attr_accessor :environment_name 59 | 60 | # @return [String] The name of the client library being used to send notifications (defaults to +Copycopter Client+) 61 | attr_accessor :client_name 62 | 63 | # @return [String, NilClass] The framework notifications are being sent from, if any (such as +Rails 2.3.9+) 64 | attr_accessor :framework 65 | 66 | # @return [String] The version of the client library being used to send notifications (such as +1.0.2+) 67 | attr_accessor :client_version 68 | 69 | # @return [String] The url of the client library being used 70 | attr_accessor :client_url 71 | 72 | # @return [Integer] The time, in seconds, in between each sync to the server. Defaults to +300+. 73 | attr_accessor :polling_delay 74 | 75 | # @return [Logger] Where to log messages. Must respond to same interface as Logger. 76 | attr_reader :logger 77 | 78 | # @return the middleware stack, if any, which should respond to +use+ 79 | attr_accessor :middleware 80 | 81 | # @return [String] the path to a root certificate file used to verify ssl sessions. Default's to the root certificate file for copycopter.com. 82 | attr_accessor :ca_file 83 | 84 | # @return [Cache] instance used internally to synchronize changes. 85 | attr_accessor :cache 86 | 87 | # @return [Client] instance used to communicate with a Copycopter Server. 88 | attr_accessor :client 89 | 90 | alias_method :secure?, :secure 91 | 92 | # Instantiated from {CopycopterClient.configure}. Sets defaults. 93 | def initialize 94 | self.client_name = 'Copycopter Client' 95 | self.client_url = 'https://rubygems.org/gems/copycopter_client' 96 | self.client_version = VERSION 97 | self.development_environments = %w(development staging) 98 | self.host = 'copycopter.com' 99 | self.http_open_timeout = 2 100 | self.http_read_timeout = 5 101 | self.logger = Logger.new($stdout) 102 | self.polling_delay = 300 103 | self.secure = false 104 | self.test_environments = %w(test cucumber) 105 | @applied = false 106 | end 107 | 108 | # Allows config options to be read like a hash 109 | # 110 | # @param [Symbol] option Key for a given attribute 111 | # @return [Object] the given attribute 112 | def [](option) 113 | send(option) 114 | end 115 | 116 | # Returns a hash of all configurable options 117 | # @return [Hash] configuration attributes 118 | def to_hash 119 | base_options = { :public => public? } 120 | 121 | OPTIONS.inject(base_options) do |hash, option| 122 | hash.merge option.to_sym => send(option) 123 | end 124 | end 125 | 126 | # Returns a hash of all configurable options merged with +hash+ 127 | # 128 | # @param [Hash] hash A set of configuration options that will take precedence over the defaults 129 | # @return [Hash] the merged configuration hash 130 | def merge(hash) 131 | to_hash.merge hash 132 | end 133 | 134 | # Determines if the published or draft content will be used 135 | # @return [Boolean] Returns +false+ if in a development or test 136 | # environment, +true+ otherwise. 137 | def public? 138 | !(development_environments + test_environments).include?(environment_name) 139 | end 140 | 141 | # Determines if the content will be editable 142 | # @return [Boolean] Returns +true+ if in a development environment, +false+ otherwise. 143 | def development? 144 | development_environments.include? environment_name 145 | end 146 | 147 | # Determines if the content will fetched from the server 148 | # @return [Boolean] Returns +true+ if in a test environment, +false+ otherwise. 149 | def test? 150 | test_environments.include? environment_name 151 | end 152 | 153 | # Determines if the configuration has been applied (internal) 154 | # @return [Boolean] Returns +true+ if applied, +false+ otherwise. 155 | def applied? 156 | @applied 157 | end 158 | 159 | # Applies the configuration (internal). 160 | # 161 | # Called automatically when {CopycopterClient.configure} is called in the application. 162 | # 163 | # This creates the {I18nBackend} and puts them together. 164 | # 165 | # When {#test?} returns +false+, the poller will be started. 166 | def apply 167 | self.client ||= Client.new(to_hash) 168 | self.cache ||= Cache.new(client, to_hash) 169 | poller = Poller.new(cache, to_hash) 170 | process_guard = ProcessGuard.new(cache, poller, to_hash) 171 | I18n.backend = I18nBackend.new(cache) 172 | 173 | if middleware && development? 174 | middleware.use RequestSync, :cache => cache 175 | end 176 | 177 | @applied = true 178 | logger.info "Client #{VERSION} ready" 179 | logger.info "Environment Info: #{environment_info}" 180 | 181 | unless test? 182 | process_guard.start 183 | end 184 | end 185 | 186 | def port 187 | @port || default_port 188 | end 189 | 190 | # The protocol that should be used when generating URLs to Copycopter. 191 | # @return [String] +https+ if {#secure?} returns +true+, +http+ otherwise. 192 | def protocol 193 | if secure? 194 | 'https' 195 | else 196 | 'http' 197 | end 198 | end 199 | 200 | # For logging/debugging (internal). 201 | # @return [String] a description of the environment in which this configuration was built. 202 | def environment_info 203 | parts = ["Ruby: #{RUBY_VERSION}", framework, "Env: #{environment_name}"] 204 | parts.compact.map { |part| "[#{part}]" }.join(" ") 205 | end 206 | 207 | # Wraps the given logger in a PrefixedLogger. This way, CopycopterClient 208 | # log messages are recognizable. 209 | # @param original_logger [Logger] the upstream logger to use, which must respond to the standard +Logger+ severity methods. 210 | def logger=(original_logger) 211 | @logger = PrefixedLogger.new("** [Copycopter]", original_logger) 212 | end 213 | 214 | private 215 | 216 | def default_port 217 | if secure? 218 | 443 219 | else 220 | 80 221 | end 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /lib/copycopter_client/errors.rb: -------------------------------------------------------------------------------- 1 | module CopycopterClient 2 | # Raised when an error occurs while contacting the Copycopter server. This is 3 | # raised by {Client} and generally rescued by {Cache}. The application will 4 | # not encounter this error. Polling will continue even if this error is raised. 5 | class ConnectionError < StandardError 6 | end 7 | 8 | # Raised when the client is configured with an api key that the Copycopter 9 | # server does not recognize. Polling is aborted when this error is raised. 10 | class InvalidApiKey < StandardError 11 | end 12 | end 13 | 14 | -------------------------------------------------------------------------------- /lib/copycopter_client/i18n_backend.rb: -------------------------------------------------------------------------------- 1 | require 'i18n' 2 | 3 | module CopycopterClient 4 | # I18n implementation designed to synchronize with Copycopter. 5 | # 6 | # Expects an object that acts like a Hash, responding to +[]+, +[]=+, and +keys+. 7 | # 8 | # This backend will be used as the default I18n backend when the client is 9 | # configured, so you will not need to instantiate this class from the 10 | # application. Instead, just use methods on the I18n class. 11 | # 12 | # This implementation will also load translations from locale files. 13 | class I18nBackend 14 | include I18n::Backend::Simple::Implementation 15 | 16 | # Usually instantiated when {Configuration#apply} is invoked. 17 | # @param cache [Cache] must act like a hash, returning and accept blurbs by key. 18 | def initialize(cache) 19 | @cache = cache 20 | end 21 | 22 | # Translates the given local and key. See the I18n API documentation for details. 23 | # 24 | # @return [Object] the translated key (usually a String) 25 | def translate(locale, key, options = {}) 26 | content = super(locale, key, options.merge(:fallback => true)) 27 | if content.respond_to?(:html_safe) 28 | content.html_safe 29 | else 30 | content 31 | end 32 | end 33 | 34 | # Returns locales availabile for this Copycopter project. 35 | # @return [Array] available locales 36 | def available_locales 37 | cached_locales = cache.keys.map { |key| key.split('.').first } 38 | (cached_locales + super).uniq.map { |locale| locale.to_sym } 39 | end 40 | 41 | # Stores the given translations. 42 | # 43 | # Updates will be visible in the current process immediately, and will 44 | # propagate to Copycopter during the next flush. 45 | # 46 | # @param [String] locale the locale (ie "en") to store translations for 47 | # @param [Hash] data nested key-value pairs to be added as blurbs 48 | # @param [Hash] options unused part of the I18n API 49 | def store_translations(locale, data, options = {}) 50 | super 51 | store_item(locale, data) 52 | end 53 | 54 | private 55 | 56 | def lookup(locale, key, scope = [], options = {}) 57 | parts = I18n.normalize_keys(locale, key, scope, options[:separator]) 58 | key_with_locale = parts.join('.') 59 | content = cache[key_with_locale] || super 60 | cache[key_with_locale] = "" if content.nil? 61 | content 62 | end 63 | 64 | def store_item(locale, data, scope = []) 65 | if data.respond_to?(:to_hash) 66 | data.to_hash.each do |key, value| 67 | store_item(locale, value, scope + [key]) 68 | end 69 | elsif data.respond_to?(:to_str) 70 | key = ([locale] + scope).join('.') 71 | cache[key] = data.to_str 72 | end 73 | end 74 | 75 | def load_translations(*filenames) 76 | super 77 | cache.wait_for_download 78 | end 79 | 80 | def default(locale, object, subject, options = {}) 81 | content = super(locale, object, subject, options) 82 | if content.respond_to?(:to_str) 83 | parts = I18n.normalize_keys(locale, object, options[:scope], options[:separator]) 84 | key = parts.join('.') 85 | cache[key] = content.to_str 86 | end 87 | content 88 | end 89 | 90 | attr_reader :cache 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/copycopter_client/poller.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'copycopter_client/cache' 3 | 4 | module CopycopterClient 5 | # Starts a background thread that continually resynchronizes with the remote 6 | # server using the given {Cache} after a set delay. 7 | class Poller 8 | # @param options [Hash] 9 | # @option options [Logger] :logger where errors should be logged 10 | # @option options [Fixnum] :polling_delay how long to wait in between requests 11 | def initialize(cache, options) 12 | @cache = cache 13 | @polling_delay = options[:polling_delay] 14 | @logger = options[:logger] 15 | @stop = false 16 | end 17 | 18 | def start 19 | Thread.new { poll } or logger.error("Couldn't start poller thread") 20 | end 21 | 22 | def stop 23 | @stop = true 24 | end 25 | 26 | private 27 | 28 | attr_reader :cache, :logger, :polling_delay 29 | 30 | def poll 31 | until @stop 32 | cache.sync 33 | logger.flush if logger.respond_to?(:flush) 34 | delay 35 | end 36 | rescue InvalidApiKey => error 37 | logger.error(error.message) 38 | end 39 | 40 | def delay 41 | sleep(polling_delay) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/copycopter_client/prefixed_logger.rb: -------------------------------------------------------------------------------- 1 | module CopycopterClient 2 | class PrefixedLogger 3 | attr_reader :prefix, :original_logger 4 | 5 | def initialize(prefix, logger) 6 | @prefix = prefix 7 | @original_logger = logger 8 | end 9 | 10 | def info(message = nil, &block) 11 | log(:info, message, &block) 12 | end 13 | 14 | def debug(message = nil, &block) 15 | log(:debug, message, &block) 16 | end 17 | 18 | def warn(message = nil, &block) 19 | log(:warn, message, &block) 20 | end 21 | 22 | def error(message = nil, &block) 23 | log(:error, message, &block) 24 | end 25 | 26 | def fatal(message = nil, &block) 27 | log(:fatal, message, &block) 28 | end 29 | 30 | def flush 31 | original_logger.flush if original_logger.respond_to?(:flush) 32 | end 33 | 34 | private 35 | 36 | def log(severity, message, &block) 37 | prefixed_message = "#{prefix} #{thread_info} #{message}" 38 | original_logger.send(severity, prefixed_message, &block) 39 | end 40 | 41 | def thread_info 42 | "[P:#{Process.pid}] [T:#{Thread.current.object_id}]" 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/copycopter_client/process_guard.rb: -------------------------------------------------------------------------------- 1 | module CopycopterClient 2 | # Starts the poller from a worker process, or register hooks for a spawner 3 | # process (such as in Unicorn or Passenger). Also registers hooks for exiting 4 | # processes and completing background jobs. Applications using the client 5 | # will not need to interact with this class directly. 6 | class ProcessGuard 7 | # @param options [Hash] 8 | # @option options [Logger] :logger where errors should be logged 9 | def initialize(cache, poller, options) 10 | @cache = cache 11 | @poller = poller 12 | @logger = options[:logger] 13 | end 14 | 15 | # Starts the poller or registers hooks 16 | def start 17 | if spawner? 18 | register_spawn_hooks 19 | else 20 | register_exit_hooks 21 | register_job_hooks 22 | start_polling 23 | end 24 | end 25 | 26 | private 27 | 28 | def start_polling 29 | @poller.start 30 | end 31 | 32 | def spawner? 33 | passenger_spawner? || unicorn_spawner? 34 | end 35 | 36 | def passenger_spawner? 37 | $0.include?("ApplicationSpawner") 38 | end 39 | 40 | def unicorn_spawner? 41 | $0.include?("unicorn") && !caller.any? { |line| line.include?("worker_loop") } 42 | end 43 | 44 | def register_spawn_hooks 45 | if defined?(PhusionPassenger) 46 | register_passenger_hook 47 | elsif defined?(Unicorn::HttpServer) 48 | register_unicorn_hook 49 | end 50 | end 51 | 52 | def register_passenger_hook 53 | @logger.info("Registered Phusion Passenger fork hook") 54 | PhusionPassenger.on_event(:starting_worker_process) do |forked| 55 | start_polling 56 | end 57 | end 58 | 59 | def register_unicorn_hook 60 | @logger.info("Registered Unicorn fork hook") 61 | poller = @poller 62 | Unicorn::HttpServer.class_eval do 63 | alias_method :worker_loop_without_copycopter, :worker_loop 64 | define_method :worker_loop do |worker| 65 | poller.start 66 | worker_loop_without_copycopter(worker) 67 | end 68 | end 69 | end 70 | 71 | def register_exit_hooks 72 | at_exit do 73 | @cache.flush 74 | end 75 | end 76 | 77 | def register_job_hooks 78 | if defined?(Resque::Job) 79 | @logger.info("Registered Resque after_perform hook") 80 | cache = @cache 81 | Resque::Job.class_eval do 82 | alias_method :perform_without_copycopter, :perform 83 | define_method :perform do 84 | job_was_performed = perform_without_copycopter 85 | cache.flush 86 | job_was_performed 87 | end 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/copycopter_client/rails.rb: -------------------------------------------------------------------------------- 1 | module CopycopterClient 2 | # Responsible for Rails initialization 3 | module Rails 4 | # Sets up the logger, environment, name, project root, and framework name 5 | # for Rails applications. Must be called after framework initialization. 6 | def self.initialize 7 | CopycopterClient.configure(false) do |config| 8 | config.environment_name = ::Rails.env 9 | config.logger = ::Rails.logger 10 | config.framework = "Rails: #{::Rails::VERSION::STRING}" 11 | config.middleware = ::Rails.configuration.middleware 12 | end 13 | end 14 | end 15 | end 16 | 17 | if defined?(Rails::Railtie) 18 | require 'copycopter_client/railtie' 19 | else 20 | CopycopterClient::Rails.initialize 21 | end 22 | 23 | -------------------------------------------------------------------------------- /lib/copycopter_client/railtie.rb: -------------------------------------------------------------------------------- 1 | module CopycopterClient 2 | # Connects to integration points for Rails 3 applications 3 | class Railtie < ::Rails::Railtie 4 | initializer :initialize_copycopter_rails, :after => :before_initialize do 5 | CopycopterClient::Rails.initialize 6 | end 7 | 8 | rake_tasks do 9 | load "tasks/copycopter_client_tasks.rake" 10 | end 11 | end 12 | end 13 | 14 | -------------------------------------------------------------------------------- /lib/copycopter_client/request_sync.rb: -------------------------------------------------------------------------------- 1 | module CopycopterClient 2 | # Rack middleware that synchronizes with Copycopter during each request. 3 | # 4 | # This is injected into the Rails middleware stack in development environments. 5 | class RequestSync 6 | # @param app [Rack] the upstream app into whose responses to inject the editor 7 | # @param options [Hash] 8 | # @option options [Cache] :cache agent that should be flushed after each request 9 | def initialize(app, options) 10 | @app = app 11 | @cache = options[:cache] 12 | end 13 | 14 | # Invokes the upstream Rack application and flushes the cache after each 15 | # request. 16 | def call(env) 17 | @cache.download unless asset_request?(env) 18 | response = @app.call(env) 19 | @cache.flush unless asset_request?(env) 20 | response 21 | end 22 | 23 | private 24 | def asset_request?(env) 25 | env['PATH_INFO'] =~ /^\/assets/ 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/copycopter_client/version.rb: -------------------------------------------------------------------------------- 1 | module CopycopterClient 2 | # Client version 3 | VERSION = '2.0.1' 4 | 5 | # API version being used to communicate with the server 6 | API_VERSION = '2.0'.freeze 7 | end 8 | -------------------------------------------------------------------------------- /lib/tasks/copycopter_client_tasks.rake: -------------------------------------------------------------------------------- 1 | namespace :copycopter do 2 | desc "Notify Copycopter of a new deploy." 3 | task :deploy => :environment do 4 | CopycopterClient.deploy 5 | puts "Successfully marked all blurbs as published." 6 | end 7 | 8 | desc "Export Copycopter blurbs to yaml." 9 | task :export => :environment do 10 | CopycopterClient.cache.sync 11 | 12 | if yml = CopycopterClient.export 13 | PATH = "config/locales/copycopter.yml" 14 | File.new("#{Rails.root}/#{PATH}", 'w').write(yml) 15 | puts "Successfully exported blurbs to #{PATH}." 16 | else 17 | puts "No blurbs have been cached." 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/copycopter_client/cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient::Cache do 4 | let(:client) { FakeClient.new } 5 | 6 | def build_cache(config = {}) 7 | config[:logger] ||= FakeLogger.new 8 | default_config = CopycopterClient::Configuration.new.to_hash 9 | CopycopterClient::Cache.new(client, default_config.update(config)) 10 | end 11 | 12 | it "provides access to downloaded data" do 13 | client['en.test.key'] = 'expected' 14 | client['en.test.other_key'] = 'expected' 15 | 16 | cache = build_cache 17 | 18 | cache.download 19 | 20 | cache['en.test.key'].should == 'expected' 21 | cache.keys.should =~ %w(en.test.key en.test.other_key) 22 | end 23 | 24 | it "doesn't upload without changes" do 25 | cache = build_cache 26 | cache.flush 27 | client.should_not be_uploaded 28 | end 29 | 30 | it "uploads changes when flushed" do 31 | cache = build_cache 32 | cache['test.key'] = 'test value' 33 | 34 | cache.flush 35 | 36 | client.uploaded.should == { 'test.key' => 'test value' } 37 | end 38 | 39 | it "downloads changes" do 40 | client['test.key'] = 'test value' 41 | cache = build_cache 42 | 43 | cache.download 44 | 45 | cache['test.key'].should == 'test value' 46 | end 47 | 48 | it "downloads and uploads when synced" do 49 | cache = build_cache 50 | client['test.key'] = 'test value' 51 | cache['other.key'] = 'other value' 52 | 53 | cache.sync 54 | 55 | client.uploaded.should == { 'other.key' => 'other value' } 56 | cache['test.key'].should == 'test value' 57 | end 58 | 59 | it "handles connection errors when flushing" do 60 | failure = "server is napping" 61 | logger = FakeLogger.new 62 | client.stubs(:upload).raises(CopycopterClient::ConnectionError.new(failure)) 63 | cache = build_cache(:logger => logger) 64 | cache['upload.key'] = 'upload' 65 | 66 | cache.flush 67 | 68 | logger.should have_entry(:error, failure) 69 | end 70 | 71 | it "handles connection errors when downloading" do 72 | failure = "server is napping" 73 | logger = FakeLogger.new 74 | client.stubs(:download).raises(CopycopterClient::ConnectionError.new(failure)) 75 | cache = build_cache(:logger => logger) 76 | 77 | cache.download 78 | 79 | logger.should have_entry(:error, failure) 80 | end 81 | 82 | it "blocks until the first download is complete" do 83 | logger = FakeLogger.new 84 | logger.stubs(:flush) 85 | client.delay = 0.5 86 | cache = build_cache(:logger => logger) 87 | 88 | Thread.new { cache.download } 89 | 90 | finished = false 91 | Thread.new do 92 | cache.wait_for_download 93 | finished = true 94 | end 95 | 96 | sleep(1) 97 | 98 | finished.should == true 99 | logger.should have_entry(:info, "Waiting for first download") 100 | logger.should have_received(:flush) 101 | end 102 | 103 | it "doesn't block if the first download fails" do 104 | client.delay = 0.5 105 | client.error = StandardError.new("Failure") 106 | cache = build_cache 107 | 108 | Thread.new { cache.download } 109 | 110 | finished = false 111 | Thread.new do 112 | cache.wait_for_download 113 | finished = true 114 | end 115 | 116 | sleep(1) 117 | 118 | expect { cache.download }.to raise_error(StandardError, "Failure") 119 | finished.should == true 120 | end 121 | 122 | it "doesn't block before downloading" do 123 | logger = FakeLogger.new 124 | cache = build_cache(:logger => logger) 125 | 126 | finished = false 127 | Thread.new do 128 | cache.wait_for_download 129 | finished = true 130 | end 131 | 132 | sleep(1) 133 | 134 | finished.should == true 135 | logger.should_not have_entry(:info, "Waiting for first download") 136 | end 137 | 138 | it "doesn't return blank copy" do 139 | client['en.test.key'] = '' 140 | cache = build_cache 141 | 142 | cache.download 143 | 144 | cache['en.test.key'].should be_nil 145 | end 146 | 147 | describe "given locked mutex" do 148 | RSpec::Matchers.define :finish_after_unlocking do |mutex| 149 | match do |thread| 150 | sleep(0.1) 151 | 152 | if thread.status === false 153 | violated("finished before unlocking") 154 | else 155 | mutex.unlock 156 | sleep(0.1) 157 | 158 | if thread.status === false 159 | true 160 | else 161 | violated("still running after unlocking") 162 | end 163 | end 164 | end 165 | 166 | def violated(failure) 167 | @failure_message = failure 168 | false 169 | end 170 | 171 | failure_message_for_should do 172 | @failure_message 173 | end 174 | end 175 | 176 | let(:mutex) { Mutex.new } 177 | let(:cache) { build_cache } 178 | 179 | before do 180 | mutex.lock 181 | Mutex.stubs(:new => mutex) 182 | end 183 | 184 | it "synchronizes read access to keys between threads" do 185 | Thread.new { cache['test.key'] }.should finish_after_unlocking(mutex) 186 | end 187 | 188 | it "synchronizes read access to the key list between threads" do 189 | Thread.new { cache.keys }.should finish_after_unlocking(mutex) 190 | end 191 | 192 | it "synchronizes write access to keys between threads" do 193 | Thread.new { cache['test.key'] = 'value' }.should finish_after_unlocking(mutex) 194 | end 195 | end 196 | 197 | it "flushes from the top level" do 198 | cache = build_cache 199 | CopycopterClient.configure do |config| 200 | config.cache = cache 201 | end 202 | cache.stubs(:flush) 203 | 204 | CopycopterClient.flush 205 | 206 | cache.should have_received(:flush) 207 | end 208 | 209 | describe "#export" do 210 | before do 211 | save_blurbs 212 | @cache = build_cache 213 | @cache.download 214 | end 215 | 216 | let(:save_blurbs) {} 217 | 218 | it "can be invoked from the top-level constant" do 219 | CopycopterClient.configure do |config| 220 | config.cache = @cache 221 | end 222 | @cache.stubs(:export) 223 | 224 | CopycopterClient.export 225 | 226 | @cache.should have_received(:export) 227 | end 228 | 229 | it "returns no yaml with no blurb keys" do 230 | @cache.export.should == nil 231 | end 232 | 233 | context "with single-level blurb keys" do 234 | let(:save_blurbs) do 235 | client['key'] = 'test value' 236 | client['other_key'] = 'other test value' 237 | end 238 | 239 | it "returns blurbs as yaml" do 240 | exported = YAML.load(@cache.export) 241 | exported['key'].should == 'test value' 242 | exported['other_key'].should == 'other test value' 243 | end 244 | end 245 | 246 | context "with multi-level blurb keys" do 247 | let(:save_blurbs) do 248 | client['en.test.key'] = 'en test value' 249 | client['en.test.other_key'] = 'en other test value' 250 | client['fr.test.key'] = 'fr test value' 251 | end 252 | 253 | it "returns blurbs as yaml" do 254 | exported = YAML.load(@cache.export) 255 | exported['en']['test']['key'].should == 'en test value' 256 | exported['en']['test']['other_key'].should == 'en other test value' 257 | exported['fr']['test']['key'].should == 'fr test value' 258 | end 259 | end 260 | 261 | context "with conflicting blurb keys" do 262 | let(:save_blurbs) do 263 | client['en.test'] = 'test value' 264 | client['en.test.key'] = 'other test value' 265 | end 266 | 267 | it "retains the new key" do 268 | exported = YAML.load(@cache.export) 269 | exported['en']['test']['key'].should == 'other test value' 270 | end 271 | end 272 | end 273 | end 274 | 275 | -------------------------------------------------------------------------------- /spec/copycopter_client/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient do 4 | def build_client(config = {}) 5 | config[:logger] ||= FakeLogger.new 6 | default_config = CopycopterClient::Configuration.new.to_hash 7 | CopycopterClient::Client.new(default_config.update(config)) 8 | end 9 | 10 | def add_project 11 | api_key = 'xyz123' 12 | FakeCopycopterApp.add_project(api_key) 13 | end 14 | 15 | def build_client_with_project(config = {}) 16 | project = add_project 17 | config[:api_key] = project.api_key 18 | build_client(config) 19 | end 20 | 21 | describe 'opening a connection' do 22 | let(:config) { CopycopterClient::Configuration.new } 23 | let(:http) { Net::HTTP.new(config.host, config.port) } 24 | 25 | before do 26 | Net::HTTP.stubs(:new => http) 27 | end 28 | 29 | it 'should timeout when connecting' do 30 | project = add_project 31 | client = build_client(:api_key => project.api_key, :http_open_timeout => 4) 32 | client.download { |ignore| } 33 | http.open_timeout.should == 4 34 | end 35 | 36 | it 'should timeout when reading' do 37 | project = add_project 38 | client = build_client(:api_key => project.api_key, :http_read_timeout => 4) 39 | client.download { |ignore| } 40 | http.read_timeout.should == 4 41 | end 42 | 43 | it 'uses verified ssl when secure' do 44 | project = add_project 45 | client = build_client(:api_key => project.api_key, :secure => true) 46 | client.download { |ignore| } 47 | http.use_ssl?.should == true 48 | http.verify_mode.should == OpenSSL::SSL::VERIFY_PEER 49 | end 50 | 51 | it 'does not use ssl when insecure' do 52 | project = add_project 53 | client = build_client(:api_key => project.api_key, :secure => false) 54 | client.download { |ignore| } 55 | http.use_ssl?.should == false 56 | end 57 | 58 | it 'wraps HTTP errors with ConnectionError' do 59 | errors = [ 60 | Timeout::Error.new, 61 | Errno::EINVAL.new, 62 | Errno::ECONNRESET.new, 63 | EOFError.new, 64 | Net::HTTPBadResponse.new, 65 | Net::HTTPHeaderSyntaxError.new, 66 | Net::ProtocolError.new, 67 | SocketError.new, 68 | OpenSSL::SSL::SSLError.new, 69 | Errno::ECONNREFUSED.new 70 | ] 71 | 72 | errors.each do |original_error| 73 | http.stubs(:request).raises(original_error) 74 | client = build_client_with_project 75 | expect { client.download { |ignore| } }. 76 | to raise_error(CopycopterClient::ConnectionError) { |error| 77 | error.message. 78 | should == "#{original_error.class.name}: #{original_error.message}" 79 | } 80 | end 81 | end 82 | 83 | it 'handles 500 errors from downloads with ConnectionError' do 84 | client = build_client(:api_key => 'raise_error') 85 | expect { client.download { |ignore| } }. 86 | to raise_error(CopycopterClient::ConnectionError) 87 | end 88 | 89 | it 'handles 500 errors from uploads with ConnectionError' do 90 | client = build_client(:api_key => 'raise_error') 91 | expect { client.upload({}) }.to raise_error(CopycopterClient::ConnectionError) 92 | end 93 | 94 | it 'handles 404 errors from downloads with ConnectionError' do 95 | client = build_client(:api_key => 'bogus') 96 | expect { client.download { |ignore| } }. 97 | to raise_error(CopycopterClient::InvalidApiKey) 98 | end 99 | 100 | it 'handles 404 errors from uploads with ConnectionError' do 101 | client = build_client(:api_key => 'bogus') 102 | expect { client.upload({}) }.to raise_error(CopycopterClient::InvalidApiKey) 103 | end 104 | end 105 | 106 | it 'downloads published blurbs for an existing project' do 107 | project = add_project 108 | project.update({ 109 | 'draft' => { 110 | 'key.one' => 'unexpected one', 111 | 'key.three' => 'unexpected three' 112 | }, 113 | 'published' => { 114 | 'key.one' => 'expected one', 115 | 'key.two' => 'expected two' 116 | } 117 | }) 118 | client = build_client(:api_key => project.api_key, :public => true) 119 | blurbs = nil 120 | 121 | client.download { |yielded| blurbs = yielded } 122 | 123 | blurbs.should == { 124 | 'key.one' => 'expected one', 125 | 'key.two' => 'expected two' 126 | } 127 | end 128 | 129 | it 'logs that it performed a download' do 130 | logger = FakeLogger.new 131 | client = build_client_with_project(:logger => logger) 132 | client.download { |ignore| } 133 | logger.should have_entry(:info, 'Downloaded translations') 134 | end 135 | 136 | it 'downloads draft blurbs for an existing project' do 137 | project = add_project 138 | project.update({ 139 | 'draft' => { 140 | 'key.one' => 'expected one', 141 | 'key.two' => 'expected two' 142 | }, 143 | 'published' => { 144 | 'key.one' => 'unexpected one', 145 | 'key.three' => 'unexpected three' 146 | } 147 | }) 148 | client = build_client(:api_key => project.api_key, :public => false) 149 | blurbs = nil 150 | 151 | client.download { |yielded| blurbs = yielded } 152 | 153 | blurbs.should == { 154 | 'key.one' => 'expected one', 155 | 'key.two' => 'expected two' 156 | } 157 | end 158 | 159 | it "handles a 304 response when downloading" do 160 | project = add_project 161 | project.update('draft' => { 'key.one' => "expected one" }) 162 | logger = FakeLogger.new 163 | client = build_client(:api_key => project.api_key, 164 | :public => false, 165 | :logger => logger) 166 | yields = 0 167 | 168 | 2.times do 169 | client.download { |ignore| yields += 1 } 170 | end 171 | 172 | yields.should == 1 173 | logger.should have_entry(:info, "No new translations") 174 | end 175 | 176 | it "uploads defaults for missing blurbs in an existing project" do 177 | project = add_project 178 | 179 | blurbs = { 180 | 'key.one' => 'expected one', 181 | 'key.two' => 'expected two' 182 | } 183 | 184 | client = build_client(:api_key => project.api_key, :public => true) 185 | client.upload(blurbs) 186 | 187 | project.reload.draft.should == blurbs 188 | end 189 | 190 | it "logs that it performed an upload" do 191 | logger = FakeLogger.new 192 | client = build_client_with_project(:logger => logger) 193 | client.upload({}) 194 | logger.should have_entry(:info, "Uploaded missing translations") 195 | end 196 | 197 | it "deploys from the top-level constant" do 198 | client = build_client 199 | CopycopterClient.configure do |config| 200 | config.client = client 201 | end 202 | client.stubs(:deploy) 203 | 204 | CopycopterClient.deploy 205 | 206 | client.should have_received(:deploy) 207 | end 208 | 209 | it "deploys" do 210 | project = add_project 211 | project.update({ 212 | 'draft' => { 213 | 'key.one' => "expected one", 214 | 'key.two' => "expected two" 215 | }, 216 | 'published' => { 217 | 'key.one' => "unexpected one", 218 | 'key.two' => "unexpected one", 219 | } 220 | }) 221 | logger = FakeLogger.new 222 | client = build_client(:api_key => project.api_key, :logger => logger) 223 | 224 | client.deploy 225 | 226 | project.reload.published.should == { 227 | 'key.one' => "expected one", 228 | 'key.two' => "expected two" 229 | } 230 | logger.should have_entry(:info, "Deployed") 231 | end 232 | 233 | it "handles deploy errors" do 234 | expect { build_client.deploy }.to raise_error(CopycopterClient::InvalidApiKey) 235 | end 236 | end 237 | 238 | -------------------------------------------------------------------------------- /spec/copycopter_client/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient::Configuration do 4 | RSpec::Matchers.define :have_config_option do |option| 5 | match do |config| 6 | config.should respond_to(option) 7 | 8 | if instance_variables.include?(:'@default') 9 | config.send(option).should == @default 10 | end 11 | 12 | if @overridable 13 | value = 'a value' 14 | config.send(:"#{option}=", value) 15 | config.send(option).should == value 16 | end 17 | end 18 | 19 | chain :default do |default| 20 | @default = default 21 | end 22 | 23 | chain :overridable do 24 | @overridable = true 25 | end 26 | end 27 | 28 | it { should have_config_option(:proxy_host).overridable.default(nil) } 29 | it { should have_config_option(:proxy_port).overridable.default(nil) } 30 | it { should have_config_option(:proxy_user).overridable.default(nil) } 31 | it { should have_config_option(:proxy_pass).overridable.default(nil) } 32 | it { should have_config_option(:environment_name).overridable.default(nil) } 33 | it { should have_config_option(:client_version).overridable.default(CopycopterClient::VERSION) } 34 | it { should have_config_option(:client_name).overridable.default('Copycopter Client') } 35 | it { should have_config_option(:client_url).overridable.default('https://rubygems.org/gems/copycopter_client') } 36 | it { should have_config_option(:secure).overridable.default(false) } 37 | it { should have_config_option(:host).overridable.default('copycopter.com') } 38 | it { should have_config_option(:http_open_timeout).overridable.default(2) } 39 | it { should have_config_option(:http_read_timeout).overridable.default(5) } 40 | it { should have_config_option(:port).overridable } 41 | it { should have_config_option(:development_environments).overridable } 42 | it { should have_config_option(:api_key).overridable } 43 | it { should have_config_option(:polling_delay).overridable.default(300) } 44 | it { should have_config_option(:framework).overridable } 45 | it { should have_config_option(:middleware).overridable } 46 | it { should have_config_option(:client).overridable } 47 | it { should have_config_option(:cache).overridable } 48 | 49 | it 'should provide default values for secure connections' do 50 | config = CopycopterClient::Configuration.new 51 | config.secure = true 52 | config.port.should == 443 53 | config.protocol.should == 'https' 54 | end 55 | 56 | it 'should provide default values for insecure connections' do 57 | config = CopycopterClient::Configuration.new 58 | config.secure = false 59 | config.port.should == 80 60 | config.protocol.should == 'http' 61 | end 62 | 63 | it 'should not cache inferred ports' do 64 | config = CopycopterClient::Configuration.new 65 | config.secure = false 66 | config.port 67 | config.secure = true 68 | config.port.should == 443 69 | end 70 | 71 | it 'should act like a hash' do 72 | config = CopycopterClient::Configuration.new 73 | hash = config.to_hash 74 | 75 | [:api_key, :environment_name, :host, :http_open_timeout, 76 | :http_read_timeout, :client_name, :client_url, :client_version, :port, 77 | :protocol, :proxy_host, :proxy_pass, :proxy_port, :proxy_user, :secure, 78 | :development_environments, :logger, :framework, :ca_file].each do |option| 79 | hash[option].should == config[option] 80 | end 81 | 82 | hash[:public].should == config.public? 83 | end 84 | 85 | it 'should be mergable' do 86 | config = CopycopterClient::Configuration.new 87 | hash = config.to_hash 88 | config.merge(:key => 'value').should == hash.merge(:key => 'value') 89 | end 90 | 91 | it 'should use development and staging as development environments by default' do 92 | config = CopycopterClient::Configuration.new 93 | config.development_environments.should =~ %w(development staging) 94 | end 95 | 96 | it 'should use test and cucumber as test environments by default' do 97 | config = CopycopterClient::Configuration.new 98 | config.test_environments.should =~ %w(test cucumber) 99 | end 100 | 101 | it 'should be test in a test environment' do 102 | config = CopycopterClient::Configuration.new 103 | config.test_environments = %w(test) 104 | config.environment_name = 'test' 105 | config.should be_test 106 | end 107 | 108 | it 'should be public in a public environment' do 109 | config = CopycopterClient::Configuration.new 110 | config.development_environments = %w(development) 111 | config.environment_name = 'production' 112 | config.should be_public 113 | config.should_not be_development 114 | end 115 | 116 | it 'should be development in a development environment' do 117 | config = CopycopterClient::Configuration.new 118 | config.development_environments = %w(staging) 119 | config.environment_name = 'staging' 120 | config.should be_development 121 | config.should_not be_public 122 | end 123 | 124 | it 'should be public without an environment name' do 125 | config = CopycopterClient::Configuration.new 126 | config.should be_public 127 | end 128 | 129 | it 'should yield and save a configuration when configuring' do 130 | yielded_configuration = nil 131 | 132 | CopycopterClient.configure(false) do |config| 133 | yielded_configuration = config 134 | end 135 | 136 | yielded_configuration.should be_kind_of(CopycopterClient::Configuration) 137 | CopycopterClient.configuration.should == yielded_configuration 138 | end 139 | 140 | it 'does not apply the configuration when asked not to' do 141 | logger = FakeLogger.new 142 | CopycopterClient.configure(false) { |config| config.logger = logger } 143 | CopycopterClient.configuration.should_not be_applied 144 | logger.entries[:info].should be_empty 145 | end 146 | 147 | it 'should not remove existing config options when configuring twice' do 148 | first_config = nil 149 | 150 | CopycopterClient.configure(false) do |config| 151 | first_config = config 152 | end 153 | 154 | CopycopterClient.configure(false) do |config| 155 | config.should == first_config 156 | end 157 | end 158 | 159 | it 'starts out unapplied' do 160 | CopycopterClient::Configuration.new.should_not be_applied 161 | end 162 | 163 | it 'logs to $stdout by default' do 164 | logger = FakeLogger.new 165 | Logger.stubs :new => logger 166 | config = CopycopterClient::Configuration.new 167 | Logger.should have_received(:new).with($stdout) 168 | config.logger.original_logger.should == logger 169 | end 170 | 171 | it 'generates environment info without a framework' do 172 | subject.environment_name = 'production' 173 | subject.environment_info.should == "[Ruby: #{RUBY_VERSION}] [Env: production]" 174 | end 175 | 176 | it 'generates environment info with a framework' do 177 | subject.environment_name = 'production' 178 | subject.framework = 'Sinatra: 1.0.0' 179 | subject.environment_info. 180 | should == "[Ruby: #{RUBY_VERSION}] [Sinatra: 1.0.0] [Env: production]" 181 | end 182 | 183 | it 'prefixes log entries' do 184 | logger = FakeLogger.new 185 | config = CopycopterClient::Configuration.new 186 | 187 | config.logger = logger 188 | 189 | prefixed_logger = config.logger 190 | prefixed_logger.should be_a(CopycopterClient::PrefixedLogger) 191 | prefixed_logger.original_logger.should == logger 192 | end 193 | end 194 | 195 | share_examples_for 'applied configuration' do 196 | subject { CopycopterClient::Configuration.new } 197 | let(:backend) { stub('i18n-backend') } 198 | let(:cache) { stub('cache') } 199 | let(:client) { stub('client') } 200 | let(:logger) { FakeLogger.new } 201 | let(:poller) { stub('poller') } 202 | let(:process_guard) { stub('process_guard', :start => nil) } 203 | 204 | before do 205 | CopycopterClient::I18nBackend.stubs :new => backend 206 | CopycopterClient::Client.stubs :new => client 207 | CopycopterClient::Cache.stubs :new => cache 208 | CopycopterClient::Poller.stubs :new => poller 209 | CopycopterClient::ProcessGuard.stubs :new => process_guard 210 | subject.logger = logger 211 | apply 212 | end 213 | 214 | it { should be_applied } 215 | 216 | it 'builds and assigns an I18n backend' do 217 | CopycopterClient::I18nBackend.should have_received(:new).with(cache) 218 | I18n.backend.should == backend 219 | end 220 | 221 | it 'builds and assigns a poller' do 222 | CopycopterClient::Poller.should have_received(:new).with(cache, subject.to_hash) 223 | end 224 | 225 | it 'builds a process guard' do 226 | CopycopterClient::ProcessGuard.should have_received(:new). 227 | with(cache, poller, subject.to_hash) 228 | end 229 | 230 | it 'logs that it is ready' do 231 | logger.should have_entry(:info, "Client #{CopycopterClient::VERSION} ready") 232 | end 233 | 234 | it 'logs environment info' do 235 | logger.should have_entry(:info, "Environment Info: #{subject.environment_info}") 236 | end 237 | end 238 | 239 | describe CopycopterClient::Configuration, 'applied when testing' do 240 | it_should_behave_like 'applied configuration' do 241 | it 'does not start the process guard' do 242 | process_guard.should have_received(:start).never 243 | end 244 | end 245 | 246 | def apply 247 | subject.environment_name = 'test' 248 | subject.apply 249 | end 250 | end 251 | 252 | describe CopycopterClient::Configuration, 'applied when not testing' do 253 | it_should_behave_like 'applied configuration' do 254 | it 'starts the process guard' do 255 | process_guard.should have_received(:start) 256 | end 257 | end 258 | 259 | def apply 260 | subject.environment_name = 'development' 261 | subject.apply 262 | end 263 | end 264 | 265 | describe CopycopterClient::Configuration, 'applied when developing with middleware' do 266 | it_should_behave_like 'applied configuration' do 267 | it 'adds the sync middleware' do 268 | middleware.should include(CopycopterClient::RequestSync) 269 | end 270 | end 271 | 272 | let(:middleware) { MiddlewareStack.new } 273 | 274 | def apply 275 | subject.middleware = middleware 276 | subject.environment_name = 'development' 277 | subject.apply 278 | end 279 | end 280 | 281 | describe CopycopterClient::Configuration, 'applied when developing without middleware' do 282 | it_should_behave_like 'applied configuration' 283 | 284 | def apply 285 | subject.middleware = nil 286 | subject.environment_name = 'development' 287 | subject.apply 288 | end 289 | end 290 | 291 | describe CopycopterClient::Configuration, 'applied with middleware when not developing' do 292 | it_should_behave_like 'applied configuration' 293 | 294 | let(:middleware) { MiddlewareStack.new } 295 | 296 | def apply 297 | subject.middleware = middleware 298 | subject.environment_name = 'test' 299 | subject.apply 300 | end 301 | 302 | it 'does not add the sync middleware' do 303 | middleware.should_not include(CopycopterClient::RequestSync) 304 | end 305 | end 306 | 307 | -------------------------------------------------------------------------------- /spec/copycopter_client/i18n_backend_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient::I18nBackend do 4 | let(:cache) { {} } 5 | 6 | def build_backend 7 | backend = CopycopterClient::I18nBackend.new(cache) 8 | I18n.backend = backend 9 | backend 10 | end 11 | 12 | before do 13 | @default_backend = I18n.backend 14 | cache.stubs(:wait_for_download) 15 | end 16 | 17 | after { I18n.backend = @default_backend } 18 | 19 | subject { build_backend } 20 | 21 | it "reloads locale files and waits for the download to complete" do 22 | I18n.stubs(:load_path => []) 23 | subject.reload! 24 | subject.translate('en', 'test.key', :default => 'something') 25 | 26 | cache.should have_received(:wait_for_download) 27 | I18n.should have_received(:load_path) 28 | end 29 | 30 | it "includes the base i18n backend" do 31 | should be_kind_of(I18n::Backend::Base) 32 | end 33 | 34 | it "looks up a key in cache" do 35 | value = 'hello' 36 | cache['en.prefix.test.key'] = value 37 | 38 | backend = build_backend 39 | 40 | backend.translate('en', 'test.key', :scope => 'prefix').should == value 41 | end 42 | 43 | it "finds available locales from locale files and cache" do 44 | YAML.stubs(:load_file => { 'es' => { 'key' => 'value' } }) 45 | I18n.stubs(:load_path => ["test.yml"]) 46 | 47 | cache['en.key'] = '' 48 | cache['fr.key'] = '' 49 | 50 | subject.available_locales.should =~ [:en, :es, :fr] 51 | end 52 | 53 | it "queues missing keys with default" do 54 | default = 'default value' 55 | 56 | subject.translate('en', 'test.key', :default => default).should == default 57 | 58 | cache['en.test.key'].should == default 59 | end 60 | 61 | it "queues missing keys without default" do 62 | expect { subject.translate('en', 'test.key') }. 63 | to throw_symbol(:exception) 64 | 65 | cache['en.test.key'].should == "" 66 | end 67 | 68 | it "queues missing keys with scope" do 69 | default = 'default value' 70 | 71 | subject.translate('en', 'key', :default => default, :scope => ['test']). 72 | should == default 73 | 74 | cache['en.test.key'].should == default 75 | end 76 | 77 | it "marks strings as html safe" do 78 | cache['en.test.key'] = FakeHtmlSafeString.new("Hello") 79 | backend = build_backend 80 | backend.translate('en', 'test.key').should be_html_safe 81 | end 82 | 83 | it "looks up an array of defaults" do 84 | cache['en.key.one'] = "Expected" 85 | backend = build_backend 86 | backend.translate('en', 'key.three', :default => [:"key.two", :"key.one"]). 87 | should == 'Expected' 88 | end 89 | 90 | describe "with stored translations" do 91 | subject { build_backend } 92 | 93 | it "uses stored translations as a default" do 94 | subject.store_translations('en', 'test' => { 'key' => 'Expected' }) 95 | subject.translate('en', 'test.key', :default => 'Unexpected'). 96 | should include('Expected') 97 | cache['en.test.key'].should == 'Expected' 98 | end 99 | 100 | it "preserves interpolation markers in the stored translation" do 101 | subject.store_translations('en', 'test' => { 'key' => '%{interpolate}' }) 102 | subject.translate('en', 'test.key', :interpolate => 'interpolated'). 103 | should include('interpolated') 104 | cache['en.test.key'].should == '%{interpolate}' 105 | end 106 | 107 | it "uses the default if the stored translations don't have the key" do 108 | subject.translate('en', 'test.key', :default => 'Expected'). 109 | should include('Expected') 110 | end 111 | 112 | it "uses the cached key when present" do 113 | subject.store_translations('en', 'test' => { 'key' => 'Unexpected' }) 114 | cache['en.test.key'] = 'Expected' 115 | subject.translate('en', 'test.key', :default => 'default'). 116 | should include('Expected') 117 | end 118 | 119 | it "stores a nested hash" do 120 | nested = { :nested => 'value' } 121 | subject.store_translations('en', 'key' => nested) 122 | subject.translate('en', 'key', :default => 'Unexpected').should == nested 123 | cache['en.key.nested'].should == 'value' 124 | end 125 | 126 | it "returns an array directly without storing" do 127 | array = ['value'] 128 | subject.store_translations('en', 'key' => array) 129 | subject.translate('en', 'key', :default => 'Unexpected').should == array 130 | cache['en.key'].should be_nil 131 | end 132 | 133 | it "looks up an array of defaults" do 134 | subject.store_translations('en', 'key' => { 'one' => 'Expected' }) 135 | subject.translate('en', 'key.three', :default => [:"key.two", :"key.one"]). 136 | should include('Expected') 137 | end 138 | end 139 | 140 | describe "with a backend using fallbacks" do 141 | subject { build_backend } 142 | 143 | before do 144 | CopycopterClient::I18nBackend.class_eval do 145 | include I18n::Backend::Fallbacks 146 | end 147 | end 148 | 149 | it "queues missing keys with default" do 150 | default = 'default value' 151 | 152 | subject.translate('en', 'test.key', :default => default).should == default 153 | 154 | cache['en.test.key'].should == default 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/copycopter_client/poller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient::Poller do 4 | POLLING_DELAY = 0.5 5 | 6 | let(:client) { FakeClient.new } 7 | let(:cache) { CopycopterClient::Cache.new(client, :logger => FakeLogger.new) } 8 | 9 | def build_poller(config = {}) 10 | config[:logger] ||= FakeLogger.new 11 | config[:polling_delay] = POLLING_DELAY 12 | default_config = CopycopterClient::Configuration.new.to_hash 13 | poller = CopycopterClient::Poller.new(cache, default_config.update(config)) 14 | @pollers << poller 15 | poller 16 | end 17 | 18 | def wait_for_next_sync 19 | sleep(POLLING_DELAY * 3) 20 | end 21 | 22 | before do 23 | @pollers = [] 24 | end 25 | 26 | after do 27 | @pollers.each { |poller| poller.stop } 28 | end 29 | 30 | it "it polls after being started" do 31 | poller = build_poller 32 | poller.start 33 | 34 | client['test.key'] = 'value' 35 | wait_for_next_sync 36 | 37 | cache['test.key'].should == 'value' 38 | end 39 | 40 | it "it doesn't poll before being started" do 41 | poller = build_poller 42 | client['test.key'] = 'value' 43 | 44 | wait_for_next_sync 45 | 46 | cache['test.key'].should be_nil 47 | end 48 | 49 | it "stops polling when stopped" do 50 | poller = build_poller 51 | 52 | poller.start 53 | poller.stop 54 | 55 | client['test.key'] = 'value' 56 | wait_for_next_sync 57 | 58 | cache['test.key'].should be_nil 59 | end 60 | 61 | it "stops polling with an invalid api key" do 62 | failure = "server is napping" 63 | logger = FakeLogger.new 64 | cache.stubs(:download).raises(CopycopterClient::InvalidApiKey.new(failure)) 65 | poller = build_poller(:logger => logger) 66 | 67 | cache['upload.key'] = 'upload' 68 | poller.start 69 | wait_for_next_sync 70 | 71 | logger.should have_entry(:error, failure) 72 | 73 | client['test.key'] = 'test value' 74 | wait_for_next_sync 75 | 76 | cache['test.key'].should be_nil 77 | end 78 | 79 | it "logs an error if the background thread can't start" do 80 | Thread.stubs(:new => nil) 81 | logger = FakeLogger.new 82 | 83 | build_poller(:logger => logger).start 84 | 85 | logger.should have_entry(:error, "Couldn't start poller thread") 86 | end 87 | 88 | it "flushes the log when polling" do 89 | logger = FakeLogger.new 90 | logger.stubs(:flush) 91 | 92 | build_poller(:logger => logger).start 93 | 94 | wait_for_next_sync 95 | 96 | logger.should have_received(:flush).at_least_once 97 | end 98 | 99 | it "starts from the top-level constant" do 100 | poller = build_poller 101 | CopycopterClient.poller = poller 102 | poller.stubs(:start) 103 | 104 | CopycopterClient.start_poller 105 | 106 | poller.should have_received(:start) 107 | end 108 | end 109 | 110 | 111 | -------------------------------------------------------------------------------- /spec/copycopter_client/prefixed_logger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient::PrefixedLogger do 4 | let(:output_logger) { FakeLogger.new } 5 | let(:prefix) { "** NOTICE:" } 6 | let(:thread_info) { "[P:#{Process.pid}] [T:#{Thread.current.object_id}]" } 7 | subject { CopycopterClient::PrefixedLogger.new(prefix, output_logger) } 8 | 9 | it "provides the prefix" do 10 | subject.prefix.should == prefix 11 | end 12 | 13 | it "provides the logger" do 14 | subject.original_logger.should == output_logger 15 | end 16 | 17 | [:debug, :info, :warn, :error, :fatal].each do |level| 18 | it "prefixes #{level} log messages" do 19 | message = 'hello' 20 | subject.send(level, message) 21 | 22 | output_logger.should have_entry(level, "#{prefix} #{thread_info} #{message}") 23 | end 24 | end 25 | 26 | it "calls flush for a logger that responds to flush" do 27 | output_logger.stubs(:flush) 28 | 29 | subject.flush 30 | 31 | output_logger.should have_received(:flush) 32 | end 33 | 34 | it "doesn't call flush for a logger that doesn't respond to flush" do 35 | lambda { subject.flush }.should_not raise_error 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/copycopter_client/process_guard_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient::ProcessGuard do 4 | include DefinesConstants 5 | 6 | before do 7 | @original_process_name = $0 8 | end 9 | 10 | after do 11 | $0 = @original_process_name 12 | end 13 | 14 | let(:cache) { stub('cache', :flush => nil) } 15 | let(:poller) { stub('poller', :start => nil) } 16 | 17 | def build_process_guard(options = {}) 18 | options[:logger] ||= FakeLogger.new 19 | options[:cache] ||= cache 20 | CopycopterClient::ProcessGuard.new(options[:cache], poller, options) 21 | end 22 | 23 | it "starts polling from a worker process" do 24 | process_guard = build_process_guard 25 | 26 | process_guard.start 27 | 28 | poller.should have_received(:start) 29 | end 30 | 31 | it "registers passenger hooks from the passenger master" do 32 | logger = FakeLogger.new 33 | passenger = define_constant('PhusionPassenger', FakePassenger.new) 34 | passenger.become_master 35 | 36 | process_guard = build_process_guard(:logger => logger) 37 | process_guard.start 38 | 39 | logger.should have_entry(:info, "Registered Phusion Passenger fork hook") 40 | poller.should have_received(:start).never 41 | end 42 | 43 | it "starts polling from a passenger worker" do 44 | logger = FakeLogger.new 45 | passenger = define_constant('PhusionPassenger', FakePassenger.new) 46 | passenger.become_master 47 | process_guard = build_process_guard(:logger => logger) 48 | 49 | process_guard.start 50 | passenger.spawn 51 | 52 | poller.should have_received(:start) 53 | end 54 | 55 | it "registers unicorn hooks from the unicorn master" do 56 | logger = FakeLogger.new 57 | define_constant('Unicorn', Module.new) 58 | http_server = Class.new(FakeUnicornServer) 59 | unicorn = define_constant('Unicorn::HttpServer', http_server).new 60 | unicorn.become_master 61 | 62 | process_guard = build_process_guard(:logger => logger) 63 | process_guard.start 64 | 65 | logger.should have_entry(:info, "Registered Unicorn fork hook") 66 | poller.should have_received(:start).never 67 | end 68 | 69 | it "starts polling from a unicorn worker" do 70 | logger = FakeLogger.new 71 | define_constant('Unicorn', Module.new) 72 | http_server = Class.new(FakeUnicornServer) 73 | unicorn = define_constant('Unicorn::HttpServer', http_server).new 74 | unicorn.become_master 75 | process_guard = build_process_guard(:logger => logger) 76 | 77 | process_guard.start 78 | unicorn.spawn 79 | 80 | poller.should have_received(:start) 81 | end 82 | 83 | it "flushes when the process terminates" do 84 | cache = WritingCache.new 85 | pid = fork do 86 | process_guard = build_process_guard(:cache => cache) 87 | process_guard.start 88 | exit 89 | end 90 | Process.wait 91 | 92 | cache.should be_written 93 | end 94 | 95 | it "flushes after running a resque job" do 96 | logger = FakeLogger.new 97 | cache = WritingCache.new 98 | define_constant('Resque', Module.new) 99 | job_class = define_constant('Resque::Job', FakeResqueJob) 100 | job = job_class.new 101 | process_guard = build_process_guard(:cache => cache, :logger => logger) 102 | 103 | process_guard.start 104 | job.fork_and_perform 105 | 106 | cache.should be_written 107 | logger.should have_entry(:info, "Registered Resque after_perform hook") 108 | end 109 | 110 | it "doesn't fail if only Resque is defined and not Resque::Job" do 111 | logger = FakeLogger.new 112 | cache = WritingCache.new 113 | define_constant('Resque', Module.new) 114 | process_guard = build_process_guard(:cache => cache, :logger => logger) 115 | 116 | process_guard.start 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/copycopter_client/request_sync_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient::RequestSync do 4 | 5 | let(:cache) { {} } 6 | let(:response) { 'response' } 7 | let(:env) { 'env' } 8 | let(:app) { stub('app', :call => response) } 9 | before { cache.stubs(:flush => nil, :download => nil) } 10 | subject { CopycopterClient::RequestSync.new(app, :cache => cache) } 11 | 12 | it "invokes the upstream app" do 13 | result = subject.call(env) 14 | app.should have_received(:call).with(env) 15 | result.should == response 16 | end 17 | 18 | it "flushes defaults" do 19 | subject.call(env) 20 | cache.should have_received(:flush) 21 | end 22 | 23 | it "downloads new copy" do 24 | subject.call(env) 25 | cache.should have_received(:download) 26 | end 27 | end 28 | 29 | describe CopycopterClient::RequestSync, 'serving assets' do 30 | let(:env) do 31 | { "PATH_INFO" => '/assets/choper.png' } 32 | end 33 | let(:cache) { {} } 34 | let(:response) { 'response' } 35 | let(:app) { stub('app', :call => response) } 36 | before { cache.stubs(:flush => nil, :download => nil) } 37 | subject { CopycopterClient::RequestSync.new(app, :cache => cache) } 38 | 39 | it "does not flush defaults" do 40 | subject.call(env) 41 | cache.should_not have_received(:flush) 42 | end 43 | it "does not download new copy" do 44 | subject.call(env) 45 | cache.should_not have_received(:download) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/copycopter_client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CopycopterClient do 4 | 5 | before do 6 | CopycopterClient.configuration.stubs(:cache => 'cache', 7 | :client => 'client') 8 | end 9 | 10 | it 'delegates cache to the configuration object' do 11 | CopycopterClient.cache.should == 'cache' 12 | CopycopterClient.configuration.should have_received(:cache).once 13 | end 14 | 15 | it 'delegates client to the configuration object' do 16 | CopycopterClient.client.should == 'client' 17 | CopycopterClient.configuration.should have_received(:client).once 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rspec' 3 | require 'bourne' 4 | require 'sham_rack' 5 | require 'webmock/rspec' 6 | 7 | PROJECT_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) 8 | 9 | $LOAD_PATH << File.join(PROJECT_ROOT, 'lib') 10 | 11 | require 'copycopter_client' 12 | 13 | Dir.glob(File.join(PROJECT_ROOT, 'spec', 'support', '**', '*.rb')).each do |file| 14 | require(file) 15 | end 16 | 17 | WebMock.disable_net_connect! 18 | ShamRack.mount FakeCopycopterApp.new, 'copycopter.com', 80 19 | 20 | RSpec.configure do |config| 21 | config.include ClientSpecHelpers 22 | config.include WebMock::API 23 | config.mock_with :mocha 24 | 25 | config.before do 26 | FakeCopycopterApp.reset 27 | reset_config 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/client_spec_helpers.rb: -------------------------------------------------------------------------------- 1 | module ClientSpecHelpers 2 | def reset_config 3 | CopycopterClient.configuration = nil 4 | CopycopterClient.configure(false) do |config| 5 | config.api_key = 'abc123' 6 | end 7 | end 8 | end 9 | 10 | -------------------------------------------------------------------------------- /spec/support/defines_constants.rb: -------------------------------------------------------------------------------- 1 | module DefinesConstants 2 | def self.included(example_group) 3 | super 4 | example_group.class_eval do 5 | before { @defined_constants = [] } 6 | after { undefine_constants } 7 | end 8 | end 9 | 10 | def define_class(class_name, base = Object, &block) 11 | class_name = class_name.to_s.camelize 12 | klass = Class.new(base) 13 | define_constant(class_name, klass) 14 | klass.class_eval(&block) if block_given? 15 | klass 16 | end 17 | 18 | def define_constant(path, value) 19 | parse_constant(path) do |parent, name| 20 | parent.const_set(name, value) 21 | end 22 | 23 | @defined_constants << path 24 | value 25 | end 26 | 27 | def parse_constant(path) 28 | parent_names = path.split('::') 29 | name = parent_names.pop 30 | parent = parent_names.inject(Object) do |ref, child_name| 31 | ref.const_get(child_name) 32 | end 33 | yield(parent, name) 34 | end 35 | 36 | def undefine_constants 37 | @defined_constants.reverse.each do |path| 38 | parse_constant(path) do |parent, name| 39 | parent.send(:remove_const, name) 40 | end 41 | end 42 | end 43 | end 44 | 45 | -------------------------------------------------------------------------------- /spec/support/fake_client.rb: -------------------------------------------------------------------------------- 1 | class FakeClient 2 | def initialize 3 | @data = {} 4 | @uploaded = {} 5 | @uploads = 0 6 | @downloads = 0 7 | end 8 | 9 | attr_reader :uploaded, :uploads, :downloads 10 | attr_accessor :delay, :error 11 | 12 | def []=(key, value) 13 | @data[key] = value 14 | end 15 | 16 | def download 17 | wait_for_delay 18 | raise_error_if_present 19 | @downloads += 1 20 | yield @data.dup 21 | nil 22 | end 23 | 24 | def upload(data) 25 | wait_for_delay 26 | raise_error_if_present 27 | @uploaded.update(data) 28 | @uploads += 1 29 | end 30 | 31 | def uploaded? 32 | @uploads > 0 33 | end 34 | 35 | def downloaded? 36 | @downloads > 0 37 | end 38 | 39 | private 40 | 41 | def wait_for_delay 42 | if delay 43 | sleep delay 44 | end 45 | end 46 | 47 | def raise_error_if_present 48 | if error 49 | raise error 50 | end 51 | end 52 | end 53 | 54 | -------------------------------------------------------------------------------- /spec/support/fake_copycopter_app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'json' 3 | require 'thin' 4 | 5 | class FakeCopycopterApp < Sinatra::Base 6 | disable :show_exceptions 7 | 8 | def self.start 9 | Thread.new do 10 | if ENV['DEBUG'] 11 | Thin::Logging.debug = true 12 | else 13 | Thin::Logging.silent = true 14 | end 15 | 16 | Rack::Handler::Thin.run self, :Port => port 17 | end 18 | end 19 | 20 | def self.port 21 | (ENV['COPYCOPTER_PORT'] || 3002).to_i 22 | end 23 | 24 | def self.add_project(api_key) 25 | Project.create api_key 26 | end 27 | 28 | def self.reset 29 | Project.delete_all 30 | end 31 | 32 | def self.project(api_key) 33 | Project.find api_key 34 | end 35 | 36 | def with_project(api_key) 37 | if api_key == 'raise_error' 38 | halt 500, { :error => 'Blah ha' }.to_json 39 | elsif project = Project.find(api_key) 40 | yield project 41 | else 42 | halt 404, { :error => 'No such project' }.to_json 43 | end 44 | end 45 | 46 | get '/api/v2/projects/:api_key/published_blurbs' do |api_key| 47 | with_project(api_key) do |project| 48 | etag project.etag 49 | project.published.to_json 50 | end 51 | end 52 | 53 | get '/api/v2/projects/:api_key/draft_blurbs' do |api_key| 54 | with_project(api_key) do |project| 55 | etag project.etag 56 | project.draft.to_json 57 | end 58 | end 59 | 60 | post '/api/v2/projects/:api_key/draft_blurbs' do |api_key| 61 | with_project(api_key) do |project| 62 | with_json_data do |data| 63 | project.update 'draft' => data 64 | 201 65 | end 66 | end 67 | end 68 | 69 | def with_json_data 70 | if request.content_type == 'application/json' 71 | yield JSON.parse(request.body.read) 72 | else 73 | 406 74 | end 75 | end 76 | 77 | post '/api/v2/projects/:api_key/deploys' do |api_key| 78 | with_project(api_key) do |project| 79 | project.deploy 80 | 201 81 | end 82 | end 83 | 84 | class Project 85 | attr_reader :draft, :published, :api_key 86 | 87 | def initialize(attrs) 88 | @api_key = attrs['api_key'] 89 | @draft = attrs['draft'] || {} 90 | @etag = attrs['etag'] || 1 91 | @published = attrs['published'] || {} 92 | end 93 | 94 | def to_hash 95 | { 96 | 'api_key' => @api_key, 97 | 'etag' => @etag, 98 | 'draft' => @draft, 99 | 'published' => @published 100 | } 101 | end 102 | 103 | def update(attrs) 104 | if attrs['draft'] 105 | @draft.update attrs['draft'] 106 | end 107 | 108 | if attrs['published'] 109 | @published.update attrs['published'] 110 | end 111 | 112 | @etag += 1 113 | self.class.save self 114 | end 115 | 116 | def reload 117 | self.class.find api_key 118 | end 119 | 120 | def deploy 121 | @published.update @draft 122 | self.class.save self 123 | end 124 | 125 | def etag 126 | @etag.to_s 127 | end 128 | 129 | def self.create(api_key) 130 | project = Project.new('api_key' => api_key) 131 | save project 132 | project 133 | end 134 | 135 | def self.find(api_key) 136 | open_project_data do |data| 137 | if project_hash = data[api_key] 138 | Project.new project_hash.dup 139 | else 140 | nil 141 | end 142 | end 143 | end 144 | 145 | def self.delete_all 146 | open_project_data do |data| 147 | data.clear 148 | end 149 | end 150 | 151 | def self.save(project) 152 | open_project_data do |data| 153 | data[project.api_key] = project.to_hash 154 | end 155 | end 156 | 157 | def self.open_project_data 158 | project_file = File.expand_path('/../../../tmp/projects.json', __FILE__) 159 | 160 | if File.exist? project_file 161 | data = JSON.parse(IO.read(project_file)) 162 | else 163 | data = {} 164 | end 165 | 166 | result = yield(data) 167 | 168 | File.open(project_file, 'w') do |file| 169 | file.write data.to_json 170 | end 171 | 172 | result 173 | end 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /spec/support/fake_html_safe_string.rb: -------------------------------------------------------------------------------- 1 | class FakeHtmlSafeString < String 2 | def initialize(*args) 3 | super(*args) 4 | @html_safe = false 5 | end 6 | 7 | def html_safe 8 | dup.html_safe! 9 | end 10 | 11 | def html_safe! 12 | @html_safe = true 13 | self 14 | end 15 | 16 | def html_safe? 17 | @html_safe 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- /spec/support/fake_logger.rb: -------------------------------------------------------------------------------- 1 | class FakeLogger 2 | def initialize 3 | @entries = { 4 | :info => [], 5 | :debug => [], 6 | :warn => [], 7 | :error => [], 8 | :fatal => [], 9 | } 10 | end 11 | 12 | def info(message = nil, &block) 13 | log(:info, message, &block) 14 | end 15 | 16 | def debug(message = nil, &block) 17 | log(:debug, message, &block) 18 | end 19 | 20 | def warn(message = nil, &block) 21 | log(:warn, message, &block) 22 | end 23 | 24 | def error(message = nil, &block) 25 | log(:error, message, &block) 26 | end 27 | 28 | def fatal(message = nil, &block) 29 | log(:fatal, message, &block) 30 | end 31 | 32 | def log(severity, message = nil, &block) 33 | message ||= block.call 34 | @entries[severity] << message 35 | end 36 | 37 | def has_entry?(level, expected_entry) 38 | @entries[level].any? { |actual_entry| actual_entry.include?(expected_entry) } 39 | end 40 | 41 | attr_reader :entries 42 | end 43 | 44 | RSpec::Matchers.define :have_entry do |severity, entry| 45 | match do |logger| 46 | @logger = logger 47 | logger.has_entry?(severity, entry) 48 | end 49 | 50 | failure_message_for_should do 51 | "Expected #{severity}(#{entry.inspect}); got entries:\n\n#{entries}" 52 | end 53 | 54 | failure_message_for_should_not do 55 | "Unexpected #{severity}(#{entry.inspect}); got entries:\n\n#{entries}" 56 | end 57 | 58 | def entries 59 | lines = @logger.entries.inject([]) do |result, (severity, entries)| 60 | if entries.empty? 61 | result 62 | else 63 | result << "#{severity}:\n#{entries.join("\n")}" 64 | end 65 | end 66 | lines.join("\n\n") 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/support/fake_passenger.rb: -------------------------------------------------------------------------------- 1 | class FakePassenger 2 | def initialize 3 | @handlers = {} 4 | end 5 | 6 | def on_event(name, &handler) 7 | @handlers[name] ||= [] 8 | @handlers[name] << handler 9 | end 10 | 11 | def call_event(name, *args) 12 | if @handlers[name] 13 | @handlers[name].each do |handler| 14 | handler.call(*args) 15 | end 16 | end 17 | end 18 | 19 | def become_master 20 | $0 = "PassengerApplicationSpawner" 21 | end 22 | 23 | def spawn 24 | $0 = "PassengerFork" 25 | call_event(:starting_worker_process, true) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/fake_resque_job.rb: -------------------------------------------------------------------------------- 1 | class FakeResqueJob 2 | def initialize(&action) 3 | @action = action || lambda {} 4 | end 5 | 6 | def fork_and_perform 7 | fork do 8 | perform 9 | exit! 10 | end 11 | Process.wait 12 | end 13 | 14 | def perform 15 | @action.call 16 | true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/fake_unicorn.rb: -------------------------------------------------------------------------------- 1 | class FakeUnicornServer 2 | def become_master 3 | $0 = "unicorn" 4 | end 5 | 6 | def spawn 7 | worker_loop(nil) 8 | end 9 | 10 | def worker_loop(worker) 11 | end 12 | end 13 | 14 | -------------------------------------------------------------------------------- /spec/support/middleware_stack.rb: -------------------------------------------------------------------------------- 1 | class MiddlewareStack 2 | def initialize 3 | @middlewares = [] 4 | end 5 | 6 | def use(klass, *args) 7 | @middlewares << klass.new('fake_app', *args) 8 | end 9 | 10 | def include?(klass) 11 | @middlewares.any? { |middleware| klass === middleware } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/writing_cache.rb: -------------------------------------------------------------------------------- 1 | class WritingCache 2 | def flush 3 | File.open(path, "w") do |file| 4 | file.write(object_id.to_s) 5 | end 6 | end 7 | 8 | def written? 9 | IO.read(path) == object_id.to_s 10 | end 11 | 12 | private 13 | 14 | def path 15 | File.join(PROJECT_ROOT, 'tmp', 'written_cache') 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /tmp/projects.json: -------------------------------------------------------------------------------- 1 | {} --------------------------------------------------------------------------------