diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 00000000..c57d7f99 --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,14 @@ +### Steps to reproduce + +### Expected behavior + + +### Actual behavior + + +### System configuration +**Rails version**: + +**Ruby version**: + +**Other configurations**: diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 00000000..7193eaa3 --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,13 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 14 +# Label requiring a response +responseRequiredLabel: more-information-needed +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. Please reach out if you have or find the answers we need so + that we can investigate further. diff --git a/.gitignore b/.gitignore index abecd20e..136bc30a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /coverage/ *.gemfile.lock /Gemfile.lock +/.idea/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..dfc663eb --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,10 @@ +inherit_from: .rubocop_todo.yml + +AllCops: + TargetRubyVersion: 2.0 + DisplayCopNames: true + Exclude: + - "gemfiles/**/*" + +Style/Documentation: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 00000000..29ebfc99 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,63 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2019-01-23 23:57:48 -0300 using RuboCop version 0.50.0. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 4 +Lint/RescueException: + Exclude: + - 'lib/exception_notification/rack.rb' + - 'lib/exception_notification/sidekiq.rb' + - 'lib/exception_notifier.rb' + +# Offense count: 18 +Metrics/AbcSize: + Max: 98 + +# Offense count: 3 +# Configuration parameters: CountComments, ExcludedMethods. +Metrics/BlockLength: + Max: 88 + +# Offense count: 11 +# Configuration parameters: CountComments. +Metrics/ClassLength: + Max: 186 + +# Offense count: 9 +Metrics/CyclomaticComplexity: + Max: 24 + +# Offense count: 253 +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. +# URISchemes: http, https +Metrics/LineLength: + Max: 226 + +# Offense count: 29 +# Configuration parameters: CountComments. +Metrics/MethodLength: + Max: 90 + +# Offense count: 7 +Metrics/PerceivedComplexity: + Max: 24 + +# Offense count: 6 +Style/ClassVars: + Exclude: + - 'lib/exception_notifier.rb' + - 'test/exception_notifier/modules/error_grouping_test.rb' + +# Offense count: 3 +Style/MethodMissing: + Exclude: + - 'lib/exception_notifier/email_notifier.rb' + - 'lib/exception_notifier/teams_notifier.rb' + +Metrics/ModuleLength: + Exclude: + - 'lib/exception_notifier.rb' diff --git a/.travis.yml b/.travis.yml index bde8eada..c22f6155 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,66 @@ language: ruby +cache: bundler rvm: - - 2.1.9 - - 2.2.5 - - 2.3.1 + - 2.1.10 + - 2.2.10 + - 2.3.8 + - 2.4.7 + - 2.5.6 + - 2.6.4 env: - COVERALLS_SILENT=true +before_install: + - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true + - gem install bundler -v '< 2' + install: - - "gem install bundler" - - "bundle install --jobs=3 --retry=3" + - "bundle install --jobs=3 --retry=3 --path=vendor/bundle" - "mkdir -p test/dummy/tmp/cache" + - "mkdir -p test/dummy/tmp/non_default_location" + gemfile: - gemfiles/rails4_0.gemfile - gemfiles/rails4_1.gemfile - gemfiles/rails4_2.gemfile - gemfiles/rails5_0.gemfile + - gemfiles/rails5_1.gemfile + - gemfiles/rails5_2.gemfile + - gemfiles/rails6_0.gemfile + matrix: exclude: - - rvm: 2.1.9 + # Rails 5 supports Ruby 2.2.2 and up + - rvm: 2.1.10 gemfile: gemfiles/rails5_0.gemfile + - rvm: 2.1.10 + gemfile: gemfiles/rails5_1.gemfile + - rvm: 2.1.10 + gemfile: gemfiles/rails5_2.gemfile + # Rails 6 supports Ruby 2.5 and up + - rvm: 2.1.10 + gemfile: gemfiles/rails6_0.gemfile + - rvm: 2.2.10 + gemfile: gemfiles/rails6_0.gemfile + - rvm: 2.3.8 + gemfile: gemfiles/rails6_0.gemfile + - rvm: 2.4.7 + gemfile: gemfiles/rails6_0.gemfile + # rails <=4.1 segfaults with ruby 2.4+ + - rvm: 2.4.7 + gemfile: gemfiles/rails4_0.gemfile + - rvm: 2.4.7 + gemfile: gemfiles/rails4_1.gemfile + - rvm: 2.5.6 + gemfile: gemfiles/rails4_0.gemfile + - rvm: 2.5.6 + gemfile: gemfiles/rails4_1.gemfile + - rvm: 2.6.4 + gemfile: gemfiles/rails4_0.gemfile + - rvm: 2.6.4 + gemfile: gemfiles/rails4_1.gemfile + +jobs: + include: + - stage: Lint + rvm: 2.6.4 + script: bundle exec rubocop diff --git a/Appraisals b/Appraisals index a8b339df..0684677d 100644 --- a/Appraisals +++ b/Appraisals @@ -1,8 +1,7 @@ -rails_versions = ['~> 4.0.5', '~> 4.1.1', '~> 4.2.0', '~> 5.0.0'] +rails_versions = ['~> 4.0.5', '~> 4.1.1', '~> 4.2.0', '~> 5.0.0', '~> 5.1.0', '~> 5.2.0', '~> 6.0.0'] rails_versions.each do |rails_version| - appraise "rails#{rails_version.slice(/\d+\.\d+/).gsub('.', '_')}" do + appraise "rails#{rails_version.slice(/\d+\.\d+/).tr('.', '_')}" do gem 'rails', rails_version - gem "sqlite3" end end diff --git a/CHANGELOG.rdoc b/CHANGELOG.rdoc index 683f0489..f82ae46d 100644 --- a/CHANGELOG.rdoc +++ b/CHANGELOG.rdoc @@ -1,7 +1,36 @@ +== 4.4.0 + +* enhancements + * Rails 6 compatibility (by @shanecav) + * Add Datadog notifier (by @ajain0184) + * Use backtrace cleaner for Slack notifications (by @pomier) + * Add slack channel name override option (by @chaadow) + * Addition of sample application for testing purposes (by @ampeigonet) + +* bug fixes + * Fix error in Resque failure backend (by @EmilioCristalli) + * Remove sqlite dependency (by @EmilioCristalli) + * Configure ignore_crawlers from Rails initializer (by @EmilioCristalli) + +== 4.3.0 + +* enhancements + * Add Microsoft Teams Notifier (by @phaelin) + * Add SNS notifier (by @FLarra) + * Add Google Chats notifier (by @renatolond) + * Align output of section-headers consistently (by @kronn) + * ExceptionNotifier.notify_exception receives block & pass it to each notifier (by @pocke) + * Update Travis to latest rubies (by @lostapathy) + +* bug fixes + * Replace all before_filter to before_action on readme (by @pastullo) + * Fix error when using error grouping outside of rails (by @garethcokell) + * Fix missing MissingController Mattermost class (by @n-rodriguez) + == 4.2.2 * enhancements - * Error groupiong (by @Martin91) + * Error grouping (by @Martin91) * Additional fields for Slack support (by @schurig) * Enterprise HipChat support (by @seanhuber) @@ -131,7 +160,7 @@ * Add normalize_subject option to remove numbers from email so that they thread (by @jjb) * Allow the user to provide a custom message and hash of data (by @jjb) * Add support for configurable background sections and a data partial (by @jeffrafter) - * Include timestamp of exception in notification body + * Include timestamp of exception in notification body * Add support for rack based session management (by @phoet) * Add ignore_crawlers option to ignore exceptions generated by crawlers * Add verbode_subject option to exclude exception message from subject (by @amishyn) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c2ba7a1..12899257 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,24 @@ need contributors to follow: like OS version, gem versions, etc... * Even better, provide a failing test case for it. +To help you add information to an issue, you can use the sample_app. +Steps to use sample_app: + +1) Add your configuration to (ex. with webhook): +```ruby +config.middleware.use ExceptionNotification::Rack, + # ----------------------------------- + # Change this with your configuration + # https://github.com/smartinez87/exception_notification#notifiers + webhook: { + url: 'http://domain.com:5555/hubot/path' + } + # ----------------------------------- +``` + +2) Run `ruby examples/sample_app.rb` +If exception notification is working OK, the test should pass and trigger a notification as configured above. If it's not, you can copy the information printed on the terminal related to exception notification and report an issue with more info! + ## Pull Requests If you've gone the extra mile and have a patch that fixes the issue, you diff --git a/Gemfile b/Gemfile index b4e2a20b..fa75df15 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,3 @@ -source "https://rubygems.org" +source 'https://rubygems.org' gemspec diff --git a/README.md b/README.md index f5a1ee26..f67d0344 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Exception Notification -[![Gem Version](https://fury-badge.herokuapp.com/rb/exception_notification.png)](http://badge.fury.io/rb/exception_notification) -[![Travis](https://api.travis-ci.org/smartinez87/exception_notification.png)](http://travis-ci.org/smartinez87/exception_notification) -[![Coverage Status](https://coveralls.io/repos/smartinez87/exception_notification/badge.png?branch=master)](https://coveralls.io/r/smartinez87/exception_notification) -[![Code Climate](https://codeclimate.com/github/smartinez87/exception_notification.png)](https://codeclimate.com/github/smartinez87/exception_notification) +[![Gem Version](https://badge.fury.io/rb/exception_notification.svg)](https://badge.fury.io/rb/exception_notification) +[![Build Status](https://travis-ci.org/smartinez87/exception_notification.svg?branch=master)](https://travis-ci.org/smartinez87/exception_notification) +[![Coverage Status](https://coveralls.io/repos/github/smartinez87/exception_notification/badge.svg?branch=master)](https://coveralls.io/github/smartinez87/exception_notification?branch=master) +[![Maintainability](https://api.codeclimate.com/v1/badges/78a9a12be00a6d305136/maintainability)](https://codeclimate.com/github/smartinez87/exception_notification/maintainability) **THIS README IS FOR THE MASTER BRANCH AND REFLECTS THE WORK CURRENTLY EXISTING ON THE MASTER BRANCH. IF YOU ARE WISHING TO USE A NON-MASTER BRANCH OF EXCEPTION NOTIFICATION, PLEASE CONSULT THAT BRANCH'S README AND NOT THIS ONE.** --- -The Exception Notification gem provides a set of [notifiers](#notifiers) for sending notifications when errors occur in a Rack/Rails application. The built-in notifiers can deliver notifications by [email](#email-notifier), [Campfire](#campfire-notifier), [HipChat](#hipchat-notifier), [Slack](#slack-notifier), [Mattermost](#mattermost-notifier), [IRC](#irc-notifier) or via custom [WebHooks](#webhook-notifier). +The Exception Notification gem provides a set of [notifiers](#notifiers) for sending notifications when errors occur in a Rack/Rails application. The built-in notifiers can deliver notifications by [email](docs/notifiers/email.md), [Campfire](docs/notifiers/campfire.md), [HipChat](docs/notifiers/hipchat.md), [Slack](docs/notifiers/slack.md), [Mattermost](docs/notifiers/mattermost.md), [Teams](docs/notifiers/teams.md), [IRC](docs/notifiers/irc.md), [Amazon SNS](docs/notifiers/sns.md), [Google Chat](docs/notifiers/google_chat.md), [Datadog](docs/notifiers/datadog.md) or via custom [WebHooks](docs/notifiers/webhook.md). There's a great [Railscast about Exception Notification](http://railscasts.com/episodes/104-exception-notifications-revised) you can see that may help you getting started. @@ -36,15 +36,15 @@ ExceptionNotification is used as a rack middleware, or in the environment you wa ```ruby Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :deliver_with => :deliver, # Rails >= 4.2.1 do not need this option since it defaults to :deliver_now - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} + email: { + deliver_with: :deliver, # Rails >= 4.2.1 do not need this option since it defaults to :deliver_now + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} } ``` -**Note**: In order to enable delivery notifications by email make sure you have [ActionMailer configured](#actionmailer-configuration). +**Note**: In order to enable delivery notifications by email make sure you have [ActionMailer configured](docs/notifiers/email.md#actionmailer-configuration). ### Rack/Sinatra @@ -56,11 +56,13 @@ Save the current user in the `request` using a controller callback. ```ruby class ApplicationController < ActionController::Base - before_filter :prepare_exception_notifier + before_action :prepare_exception_notifier + private + def prepare_exception_notifier request.env["exception_notifier.exception_data"] = { - :current_user => current_user + current_user: current_user } end end @@ -82,777 +84,48 @@ Options -> sections" below. ## Notifiers -ExceptionNotification relies on notifiers to deliver notifications when errors occur in your applications. By default, 7 notifiers are available: - -* [Campfire notifier](#campfire-notifier) -* [Email notifier](#email-notifier) -* [HipChat notifier](#hipchat-notifier) -* [IRC notifier](#irc-notifier) -* [Slack notifier](#slack-notifier) -* [Mattermost notifier](#mattermost-notifier) -* [WebHook notifier](#webhook-notifier) - -But, you also can easily implement your own [custom notifier](#custom-notifier). - -### Campfire notifier - -This notifier sends notifications to your Campfire room. - -#### Usage - -Just add the [tinder](https://github.com/collectiveidea/tinder) gem to your `Gemfile`: - -```ruby -gem 'tinder' -``` - -To configure it, you need to set the `subdomain`, `token` and `room_name` options, like this: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :campfire => { - :subdomain => 'my_subdomain', - :token => 'my_token', - :room_name => 'my_room' - } -``` - -#### Options - -##### subdomain - -*String, required* - -Your subdomain at Campfire. - -##### room_name - -*String, required* - -The Campfire room where the notifications must be published to. - -##### token - -*String, required* - -The API token to allow access to your Campfire account. - - -For more options to set Campfire, like _ssl_, check [here](https://github.com/collectiveidea/tinder/blob/master/lib/tinder/campfire.rb#L17). - -### Email notifier - -The Email notifier sends notifications by email. The notifications/emails sent includes information about the current request, session, and environment, and also gives a backtrace of the exception. - -After an exception notification has been delivered the rack environment variable `exception_notifier.delivered` will be set to true. - -#### ActionMailer configuration - -For the email to be sent, there must be a default ActionMailer `delivery_method` setting configured. If you do not have one, you can use the following code (assuming your app server machine has `sendmail`). Depending on the environment you want ExceptionNotification to run in, put the following code in your `config/environments/production.rb` and/or `config/environments/development.rb`: - -```ruby -config.action_mailer.delivery_method = :sendmail -# Defaults to: -# config.action_mailer.sendmail_settings = { -# :location => '/usr/sbin/sendmail', -# :arguments => '-i -t' -# } -config.action_mailer.perform_deliveries = true -config.action_mailer.raise_delivery_errors = true -``` - -#### Options - -##### sender_address - -*String, default: %("Exception Notifier" )* - -Who the message is from. - -##### exception_recipients - -*String/Array of strings/Proc, default: []* - -Who the message is destined for, can be a string of addresses, an array of addresses, or it can be a proc that returns a string of addresses or an array of addresses. The proc will be evaluated when the mail is sent. - -##### email_prefix - -*String, default: [ERROR]* - -The subject's prefix of the message. - -##### sections - -*Array of strings, default: %w(request session environment backtrace)* - -By default, the notification email includes four parts: request, session, environment, and backtrace (in that order). You can customize how each of those sections are rendered by placing a partial named for that part in your `app/views/exception_notifier` directory (e.g., `_session.rhtml`). Each partial has access to the following variables: - -```ruby -@kontroller # the controller that caused the error -@request # the current request object -@exception # the exception that was raised -@backtrace # a sanitized version of the exception's backtrace -@data # a hash of optional data values that were passed to the notifier -@sections # the array of sections to include in the email -``` - -You can reorder the sections, or exclude sections completely, by using `sections` option. You can even add new sections that -describe application-specific data--just add the section's name to the list (wherever you'd like), and define the corresponding partial. Like the following example with two new added sections: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com}, - :sections => %w{my_section1 my_section2} - } -``` - -Place your custom sections under `./app/views/exception_notifier/` with the suffix `.text.erb`, e.g. `./app/views/exception_notifier/_my_section1.text.erb`. - -If your new section requires information that isn't available by default, make sure it is made available to the email using the `exception_data` macro: - -```ruby -class ApplicationController < ActionController::Base - before_filter :log_additional_data - ... - protected - def log_additional_data - request.env["exception_notifier.exception_data"] = { - :document => @document, - :person => @person - } - end - ... -end -``` - -In the above case, `@document` and `@person` would be made available to the email renderer, allowing your new section(s) to access and display them. See the existing sections defined by the plugin for examples of how to write your own. - -##### background_sections - -*Array of strings, default: %w(backtrace data)* - -When using [background notifications](#background-notifications) some variables are not available in the views, like `@kontroller` and `@request`. Thus, you may want to include different sections for background notifications: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com}, - :background_sections => %w{my_section1 my_section2 backtrace data} - } -``` - -##### email_headers - -*Hash of strings, default: {}* - -Additionally, you may want to set customized headers on the outcoming emails. To do so, simply use the `:email_headers` option: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com}, - :email_headers => { "X-Custom-Header" => "foobar" } - } -``` - -##### verbose_subject - -*Boolean, default: true* - -If enabled, include the exception message in the subject. Use `:verbose_subject => false` to exclude it. - -##### normalize_subject - -*Boolean, default: false* - -If enabled, remove numbers from subject so they thread as a single one. Use `:normalize_subject => true` to enable it. - -##### include_controller_and_action_names_in_subject - -*Boolean, default: true* - -If enabled, include the controller and action names in the subject. Use `:include_controller_and_action_names_in_subject => false` to exclude them. - -##### email_format - -*Symbol, default: :text* - -By default, ExceptionNotification sends emails in plain text, in order to sends multipart notifications (aka HTML emails) use `:email_format => :html`. - -##### delivery_method - -*Symbol, default: :smtp* - -By default, ExceptionNotification sends emails using the ActionMailer configuration of the application. In order to send emails by another delivery method, use the `delivery_method` option: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com}, - :delivery_method => :postmark, - :postmark_settings => { - :api_key => ENV["POSTMARK_API_KEY"] - } - } -``` - -Besides the `delivery_method` option, you also can customize the mailer settings by passing a hash under an option named `DELIVERY_METHOD_settings`. Thus, you can use override specific SMTP settings for notifications using: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com}, - :delivery_method => :smtp, - :smtp_settings => { - :user_name => "bob", - :password => "password", - } - } -``` - -A complete list of `smtp_settings` options can be found in the [ActionMailer Configuration documentation](http://api.rubyonrails.org/classes/ActionMailer/Base.html#class-ActionMailer::Base-label-Configuration+options). - -##### mailer_parent - -*String, default: ActionMailer::Base* - -The parent mailer which ExceptionNotification mailer inherit from. - -##### deliver_with - -*Symbol, default: :deliver_now - -The method name to send emalis using ActionMailer. - -### HipChat notifier - -This notifier sends notifications to your Hipchat room. - -#### Usage - -Just add the [hipchat](https://github.com/hipchat/hipchat-rb) gem to your `Gemfile`: - -```ruby -gem 'hipchat' -``` - -To configure it, you need to set the `token` and `room_name` options, like this: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :hipchat => { - :api_token => 'my_token', - :room_name => 'my_room' - } -``` - -#### Options - -##### room_name - -*String, required* - -The HipChat room where the notifications must be published to. - -##### api_token - -*String, required* - -The API token to allow access to your HipChat account. - -##### notify - -*Boolean, optional* - -Notify users. Default : false. - -##### color - -*String, optional* - -Color of the message. Default : 'red'. - -##### from - -*String, optional, maximum length : 15* - -Message will appear from this nickname. Default : 'Exception'. - -##### server_url - -*String, optional* - -Custom Server URL for self-hosted, Enterprise HipChat Server - -For all options & possible values see [Hipchat API](https://www.hipchat.com/docs/api/method/rooms/message). - -### IRC notifier - -This notifier sends notifications to an IRC channel using the carrier-pigeon gem. - -#### Usage - -Just add the [carrier-pigeon](https://github.com/portertech/carrier-pigeon) gem to your `Gemfile`: - -```ruby -gem 'carrier-pigeon' -``` - -To configure it, you need to set at least the 'domain' option, like this: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :irc => { - :domain => 'irc.example.com' - } -``` - -There are several other options, which are described below. For example, to use ssl and a password, add a prefix, post to the '#log' channel, and include recipients in the message (so that they will be notified), your configuration might look like this: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :irc => { - :domain => 'irc.example.com', - :nick => 'BadNewsBot', - :password => 'secret', - :port => 6697, - :channel => '#log', - :ssl => true, - :prefix => '[Exception Notification]', - :recipients => ['peter', 'michael', 'samir'] - } - -``` - -#### Options - -##### domain - -*String, required* - -The domain name of your IRC server. - -##### nick - -*String, optional* - -The message will appear from this nick. Default : 'ExceptionNotifierBot'. - -##### password - -*String, optional* - -Password for your IRC server. - -##### port +ExceptionNotification relies on notifiers to deliver notifications when errors occur in your applications. By default, 8 notifiers are available: -*String, optional* +* [Campfire notifier](docs/notifiers/campfire.md) +* [Datadog notifier](docs/notifiers/datadog.md) +* [Email notifier](docs/notifiers/email.md) +* [HipChat notifier](docs/notifiers/hipchat.md) +* [IRC notifier](docs/notifiers/irc.md) +* [Slack notifier](docs/notifiers/slack.md) +* [Mattermost notifier](docs/notifiers/mattermost.md) +* [Teams notifier](docs/notifiers/teams.md) +* [Amazon SNS](docs/notifiers/sns.md) +* [Google Chat notifier](docs/notifiers/google_chat.md) +* [WebHook notifier](docs/notifiers/webhook.md) -Port your IRC server is listening on. Default : 6667. - -##### channel - -*String, optional* - -Message will appear in this channel. Default : '#log'. - -##### notice - -*Boolean, optional* - -Send a notice. Default : false. - -##### ssl - -*Boolean, optional* - -Whether to use SSL. Default : false. - -##### join - -*Boolean, optional* - -Join a channel. Default : false. - -##### recipients - -*Array of strings, optional* - -Nicks to include in the message. Default: [] - -### Slack notifier - -This notifier sends notifications to a slack channel using the slack-notifier gem. - -#### Usage - -Just add the [slack-notifier](https://github.com/stevenosloan/slack-notifier) gem to your `Gemfile`: - -```ruby -gem 'slack-notifier' -``` - -To configure it, you need to set at least the 'webhook_url' option, like this: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :slack => { - :webhook_url => "[Your webhook url]", - :channel => "#exceptions", - :additional_parameters => { - :icon_url => "http://image.jpg", - :mrkdwn => true - } - } -``` - -The slack notification will include any data saved under `env["exception_notifier.exception_data"]`. - -An example of how to send the server name to Slack in Rails (put this code in application_controller.rb): - -```ruby -before_filter :set_notification - -def set_notification - request.env['exception_notifier.exception_data'] = {"server" => request.env['SERVER_NAME']} - # can be any key-value pairs -end -``` - -If you find this too verbose, you can determine to exclude certain information by doing the following: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :slack => { - :webhook_url => "[Your webhook url]", - :channel => "#exceptions", - :additional_parameters => { - :icon_url => "http://image.jpg", - :mrkdwn => true - }, - :ignore_data_if => lambda {|key, value| - "#{key}" == 'key_to_ignore' || value.is_a?(ClassToBeIgnored) - } - } -``` - -Any evaluation to `true` will cause the key / value pair not be be sent along to Slack. - -#### Options - -##### webhook_url - -*String, required* - -The Incoming WebHook URL on slack. - -##### channel - -*String, optional* - -Message will appear in this channel. Defaults to the channel you set as such on slack. - -##### username - -*String, optional* - -Username of the bot. Defaults to the name you set as such on slack - -##### custom_hook - -*String, optional* - -Custom hook name. See [slack-notifier](https://github.com/stevenosloan/slack-notifier#custom-hook-name) for -more information. Default: 'incoming-webhook' - -##### additional_parameters - -*Hash of strings, optional* - -Contains additional payload for a message (e.g avatar, attachments, etc). See [slack-notifier](https://github.com/stevenosloan/slack-notifier#additional-parameters) for more information.. Default: '{}' - -##### additional_fields - -*Array of Hashes, optional* - -Contains additional fields that will be added to the attachement. See [Slack documentation](https://api.slack.com/docs/message-attachments). - -## Mattermost notifier - -Post notification in a mattermost channel via [incoming webhook](http://docs.mattermost.com/developer/webhooks-incoming.html) - -Just add the [HTTParty](https://github.com/jnunemaker/httparty) gem to your `Gemfile`: - -```ruby -gem 'httparty' -``` - -To configure it, you **need** to set the `webhook_url` option. -You can also specify an other channel with `channel` option. - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :mattermost => { - :webhook_url => 'http://your-mattermost.com/hooks/blablabla', - :channel => 'my-channel' - } -``` - -If you are using Github or Gitlab for issues tracking, you can specify `git_url` as follow to add a *Create issue* link in you notification. -By default this will use your Rails application name to match the git repository. If yours differ you can specify `app_name`. - - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :mattermost => { - :webhook_url => 'http://your-mattermost.com/hooks/blablabla', - :git_url => 'github.com/aschen' - } -``` - -You can also specify the bot name and avatar with `username` and `avatar` options. - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :mattermost => { - :webhook_url => 'http://your-mattermost.com/hooks/blablabla', - :avatar => 'http://example.com/your-image.png', - :username => 'Fail bot' - } -``` - -Finally since the notifier use HTTParty, you can include all HTTParty options, like basic_auth for example. - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :mattermost => { - :webhook_url => 'http://your-mattermost.com/hooks/blablabla', - :basic_auth => { - :username => 'clara', - :password => 'password' - } - } -``` - -#### Options - -##### webhook_url - -*String, required* - -The Incoming WebHook URL on mattermost. - -##### channel - -*String, optional* - -Message will appear in this channel. Defaults to the channel you set as such on mattermost. - -##### username - -*String, optional* - -Username of the bot. Defaults to "Incoming Webhook" - -##### avatar - -*String, optional* - -Avatar of the bot. Defaults to incoming webhook icon. - -##### git_url - -*String, optional* - -Url of your gitlab or github with your organisation name for issue creation link (Eg: `github.com/aschen`). Defaults to nil and don't add link to the notification. - -##### app_name - -*String, optional* - -Your application name used for issue creation link. Defaults to ``` Rails.application.class.parent_name.underscore```. - - -### WebHook notifier - -This notifier ships notifications over the HTTP protocol. - -#### Usage - -Just add the [HTTParty](https://github.com/jnunemaker/httparty) gem to your `Gemfile`: - -```ruby -gem 'httparty' -``` - -To configure it, you need to set the `url` option, like this: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :webhook => { - :url => 'http://domain.com:5555/hubot/path' - } -``` - -By default, the WebhookNotifier will call the URLs using the POST method. But, you can change this using the `http_method` option. - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :webhook => { - :url => 'http://domain.com:5555/hubot/path', - :http_method => :get - } -``` - -Besides the `url` and `http_method` options, all the other options are passed directly to HTTParty. Thus, if the HTTP server requires authentication, you can include the following options: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :webhook => { - :url => 'http://domain.com:5555/hubot/path', - :basic_auth => { - :username => 'alice', - :password => 'password' - } - } -``` - -For more HTTParty options, check out the [documentation](https://github.com/jnunemaker/httparty). - -### Custom notifier - -Simply put, notifiers are objects which respond to `#call(exception, options)` method. Thus, a lambda can be used as a notifier as follow: - -```ruby -ExceptionNotifier.add_notifier :custom_notifier_name, - ->(exception, options) { puts "Something goes wrong: #{exception.message}"} -``` - -More advanced users or third-party framework developers, also can create notifiers to be shipped in gems and take advantage of ExceptionNotification's Notifier API to standardize the [various](https://github.com/airbrake/airbrake) [solutions](https://www.honeybadger.io) [out](http://www.exceptional.io) [there](https://bugsnag.com). For this, beyond the `#call(exception, options)` method, the notifier class MUST BE defined under the ExceptionNotifier namespace and its name sufixed by `Notifier`, e.g: ExceptionNotifier::SimpleNotifier. - -#### Example - -Define the custom notifier: - -```ruby -module ExceptionNotifier - class SimpleNotifier - def initialize(options) - # do something with the options... - end - - def call(exception, options={}) - # send the notification - end - end -end -``` - -Using it: - -```ruby -Rails.application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - }, - :simple => { - # simple notifier options - } -``` +But, you also can easily implement your own [custom notifier](docs/notifiers/custom.md). ## Error Grouping -In general, exception notification will send every notification when an error occured, which may result in a problem: if your site has a high throughput and an same error raised frequently, you will receive too many notifications during a short period time, your mail box may be full of thousands of exception mails or even your mail server will be slow. To prevent this, you can choose to error errors by using `:error_grouping` option and set it to `true`. -Error grouping has a default formula `log2(errors_count)` to determine if it is needed to send the notification based on the accumulated errors count for specified exception, this makes the notifier only send notification when count is: 1, 2, 4, 8, 16, 32, 64, 128, ... (2**n). You can use `:notification_trigger` to override this default formula. +In general, ExceptionNotification will send a notification when every error occurs, which may result in a problem: if your site has a high throughput and a particular error is raised frequently, you will receive too many notifications. During a short period of time, your mail box may be filled with thousands of exception mails, or your mail server may even become slow. To prevent this, you can choose to group errors by setting the `:error_grouping` option to `true`. -The below shows options used to enable error grouping: +Error grouping uses a default formula of `log2(errors_count)` to determine whether to send the notification, based on the accumulated error count for each specific exception. This makes the notifier only send a notification when the count is: 1, 2, 4, 8, 16, 32, 64, 128, ..., (2**n). You can use `:notification_trigger` to override this default formula. + +The following code shows the available options to configure error grouping: ```ruby Rails.application.config.middleware.use ExceptionNotification::Rack, - :ignore_exceptions => ['ActionView::TemplateError'] + ExceptionNotifier.ignored_exceptions, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} + ignore_exceptions: ['ActionView::TemplateError'] + ExceptionNotifier.ignored_exceptions, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} }, - :error_grouping => true, - # :error_grouping_period => 5.minutes, # the time before an error is regarded as fixed - # :error_grouping_cache => Rails.cache, # for other applications such as Sinatra, use one instance of ActiveSupport::Cache::Store + error_grouping: true, + # error_grouping_period: 5.minutes, # the time before an error is regarded as fixed + # error_grouping_cache: Rails.cache, # for other applications such as Sinatra, use one instance of ActiveSupport::Cache::Store # # notification_trigger: specify a callback to determine when a notification should be sent, # the callback will be invoked with two arguments: # exception: the exception raised # count: accumulated errors count for this exception # - # :notification_trigger => lambda { |exception, count| count % 10 == 0 } + # notification_trigger: lambda { |exception, count| count % 10 == 0 } ``` ## Ignore Exceptions @@ -874,12 +147,12 @@ Ignore specified exception types. To achieve that, you should use the `:ignore_e ```ruby Rails.application.config.middleware.use ExceptionNotification::Rack, - :ignore_exceptions => ['ActionView::TemplateError'] + ExceptionNotifier.ignored_exceptions, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - } + ignore_exceptions: ['ActionView::TemplateError'] + ExceptionNotifier.ignored_exceptions, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + } ``` The above will make ExceptionNotifier ignore a *TemplateError* exception, plus the ones ignored by default. @@ -892,12 +165,12 @@ In some cases you may want to avoid getting notifications from exceptions made b ```ruby Rails.application.config.middleware.use ExceptionNotification::Rack, - :ignore_crawlers => %w{Googlebot bingbot}, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com} - } + ignore_crawlers: %w{Googlebot bingbot}, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + } ``` ### :ignore_if @@ -908,12 +181,12 @@ Last but not least, you can ignore exceptions based on a condition. Take a look: ```ruby Rails.application.config.middleware.use ExceptionNotification::Rack, - :ignore_if => ->(env, exception) { exception.message =~ /^Couldn't find Page with ID=/ }, - :email => { - :email_prefix => "[PREFIX] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com}, - } + ignore_if: ->(env, exception) { exception.message =~ /^Couldn't find Page with ID=/ }, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com}, + } ``` You can make use of both the environment and the exception inside the lambda to decide wether to avoid or not sending the notification. @@ -947,9 +220,11 @@ You can include information about the background process that created the error ```ruby begin some code... -rescue => exception - ExceptionNotifier.notify_exception(exception, - :data => {:worker => worker.to_s, :queue => queue, :payload => payload}) +rescue => e + ExceptionNotifier.notify_exception( + e, + data: { worker: worker.to_s, queue: queue, payload: payload} + ) end ``` @@ -958,13 +233,15 @@ end If your controller action manually handles an error, the notifier will never be run. To manually notify of an error you can do something like the following: ```ruby -rescue_from Exception, :with => :server_error +rescue_from Exception, with: :server_error def server_error(exception) # Whatever code that handles the exception - ExceptionNotifier.notify_exception(exception, - :env => request.env, :data => {:message => "was doing something wrong"}) + ExceptionNotifier.notify_exception( + exception, + env: request.env, data: { message: 'was doing something wrong' } + ) end ``` @@ -993,29 +270,6 @@ or As above, make sure the gem is not listed solely under the `production` group, since this initializer will be loaded regardless of environment. -## Versions - -For v4.2.1, see this tag: - -http://github.com/smartinez87/exception_notification/tree/v4.2.1 - -For v4.2.0, see this tag: - -http://github.com/smartinez87/exception_notification/tree/v4.2.0 - -For previous releases, visit: - -https://github.com/smartinez87/exception_notification/tags - -If you are running Rails 2.3 then see the branch for that: - -http://github.com/smartinez87/exception_notification/tree/2-3-stable - -If you are running pre-rack Rails then see this tag: - -http://github.com/smartinez87/exception_notification/tree/pre-2-3 - - ## Support and tickets Here's the list of [issues](https://github.com/smartinez87/exception_notification/issues) we're currently working on. diff --git a/Rakefile b/Rakefile index 50bf4120..9474a97f 100644 --- a/Rakefile +++ b/Rakefile @@ -5,11 +5,11 @@ require 'appraisal' require 'rake/testtask' -task :default => [:test] +task default: [:test] Rake::TestTask.new(:test) do |t| t.libs << 'lib' t.libs << 'test' t.pattern = 'test/**/*_test.rb' - t.verbose = true + t.warning = false end diff --git a/docs/notifiers/campfire.md b/docs/notifiers/campfire.md new file mode 100644 index 00000000..430a0889 --- /dev/null +++ b/docs/notifiers/campfire.md @@ -0,0 +1,50 @@ +### Campfire notifier + +This notifier sends notifications to your Campfire room. + +#### Usage + +Just add the [tinder](https://github.com/collectiveidea/tinder) gem to your `Gemfile`: + +```ruby +gem 'tinder' +``` + +To configure it, you need to set the `subdomain`, `token` and `room_name` options, like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + campfire: { + subdomain: 'my_subdomain', + token: 'my_token', + room_name: 'my_room' + } +``` + +#### Options + +##### subdomain + +*String, required* + +Your subdomain at Campfire. + +##### room_name + +*String, required* + +The Campfire room where the notifications must be published to. + +##### token + +*String, required* + +The API token to allow access to your Campfire account. + + +For more options to set Campfire, like _ssl_, check [here](https://github.com/collectiveidea/tinder/blob/master/lib/tinder/campfire.rb#L17). diff --git a/docs/notifiers/custom.md b/docs/notifiers/custom.md new file mode 100644 index 00000000..38cbf6d9 --- /dev/null +++ b/docs/notifiers/custom.md @@ -0,0 +1,42 @@ +### Custom notifier + +Simply put, notifiers are objects which respond to `#call(exception, options)` method. Thus, a lambda can be used as a notifier as follow: + +```ruby +ExceptionNotifier.add_notifier :custom_notifier_name, + ->(exception, options) { puts "Something goes wrong: #{exception.message}"} +``` + +More advanced users or third-party framework developers, also can create notifiers to be shipped in gems and take advantage of ExceptionNotification's Notifier API to standardize the [various](https://github.com/airbrake/airbrake) [solutions](https://www.honeybadger.io) [out](http://www.exceptional.io) [there](https://bugsnag.com). For this, beyond the `#call(exception, options)` method, the notifier class MUST BE defined under the ExceptionNotifier namespace and its name sufixed by `Notifier`, e.g: ExceptionNotifier::SimpleNotifier. + +#### Example + +Define the custom notifier: + +```ruby +module ExceptionNotifier + class SimpleNotifier + def initialize(options) + # do something with the options... + end + + def call(exception, options={}) + # send the notification + end + end +end +``` + +Using it: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + simple: { + # simple notifier options + } +``` diff --git a/docs/notifiers/datadog.md b/docs/notifiers/datadog.md new file mode 100644 index 00000000..c5696702 --- /dev/null +++ b/docs/notifiers/datadog.md @@ -0,0 +1,51 @@ +### Datadog notifier + +This notifier sends error events to Datadog using the [Dogapi](https://github.com/DataDog/dogapi-rb) gem. + +#### Usage + +Just add the [Dogapi](https://github.com/DataDog/dogapi-rb) gem to your `Gemfile`: + +```ruby +gem 'dogapi' +``` + +To use datadog notifier, you first need to create a `Dogapi::Client` with your datadog api and application keys, like this: + +```ruby +client = Dogapi::Client.new(api_key, application_key) +``` + +You then need to set the `client` option, like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: "[PREFIX] ", + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + datadog: { + client: client + } +``` + +#### Options + +##### client + +*DogApi::Client, required* + +The API client to send events to Datadog. + +##### title_prefix + +*String, optional* + +Prefix for event title in Datadog. + +##### tags + +*Array of Strings, optional* + +Optional tags for events in Datadog. diff --git a/docs/notifiers/email.md b/docs/notifiers/email.md new file mode 100644 index 00000000..e63773d2 --- /dev/null +++ b/docs/notifiers/email.md @@ -0,0 +1,195 @@ +### Email notifier + +The Email notifier sends notifications by email. The notifications/emails sent includes information about the current request, session, and environment, and also gives a backtrace of the exception. + +After an exception notification has been delivered the rack environment variable `exception_notifier.delivered` will be set to true. + +#### ActionMailer configuration + +For the email to be sent, there must be a default ActionMailer `delivery_method` setting configured. If you do not have one, you can use the following code (assuming your app server machine has `sendmail`). Depending on the environment you want ExceptionNotification to run in, put the following code in your `config/environments/production.rb` and/or `config/environments/development.rb`: + +```ruby +config.action_mailer.delivery_method = :sendmail +# Defaults to: +# config.action_mailer.sendmail_settings = { +# location: '/usr/sbin/sendmail', +# arguments: '-i -t' +# } +config.action_mailer.perform_deliveries = true +config.action_mailer.raise_delivery_errors = true +``` + +#### Options + +##### sender_address + +*String, default: %("Exception Notifier" )* + +Who the message is from. + +##### exception_recipients + +*String/Array of strings/Proc, default: []* + +Who the message is destined for, can be a string of addresses, an array of addresses, or it can be a proc that returns a string of addresses or an array of addresses. The proc will be evaluated when the mail is sent. + +##### email_prefix + +*String, default: [ERROR]* + +The subject's prefix of the message. + +##### sections + +*Array of strings, default: %w(request session environment backtrace)* + +By default, the notification email includes four parts: request, session, environment, and backtrace (in that order). You can customize how each of those sections are rendered by placing a partial named for that part in your `app/views/exception_notifier` directory (e.g., `_session.rhtml`). Each partial has access to the following variables: + +```ruby +@kontroller # the controller that caused the error +@request # the current request object +@exception # the exception that was raised +@backtrace # a sanitized version of the exception's backtrace +@data # a hash of optional data values that were passed to the notifier +@sections # the array of sections to include in the email +``` + +You can reorder the sections, or exclude sections completely, by using `sections` option. You can even add new sections that +describe application-specific data--just add the section's name to the list (wherever you'd like), and define the corresponding partial. Like the following example with two new added sections: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com}, + sections: %w{my_section1 my_section2} + } +``` + +Place your custom sections under `./app/views/exception_notifier/` with the suffix `.text.erb`, e.g. `./app/views/exception_notifier/_my_section1.text.erb`. + +If your new section requires information that isn't available by default, make sure it is made available to the email using the `exception_data` macro: + +```ruby +class ApplicationController < ActionController::Base + before_action :log_additional_data + ... + protected + + def log_additional_data + request.env['exception_notifier.exception_data'] = { + document: @document, + person: @person + } + end + ... +end +``` + +In the above case, `@document` and `@person` would be made available to the email renderer, allowing your new section(s) to access and display them. See the existing sections defined by the plugin for examples of how to write your own. + +##### background_sections + +*Array of strings, default: %w(backtrace data)* + +When using [background notifications](#background-notifications) some variables are not available in the views, like `@kontroller` and `@request`. Thus, you may want to include different sections for background notifications: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com}, + background_sections: %w{my_section1 my_section2 backtrace data} + } +``` + +##### email_headers + +*Hash of strings, default: {}* + +Additionally, you may want to set customized headers on the outcoming emails. To do so, simply use the `:email_headers` option: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: "[PREFIX] ", + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com}, + email_headers: { "X-Custom-Header" => "foobar" } + } +``` + +##### verbose_subject + +*Boolean, default: true* + +If enabled, include the exception message in the subject. Use `verbose_subject: false` to exclude it. + +##### normalize_subject + +*Boolean, default: false* + +If enabled, remove numbers from subject so they thread as a single one. Use `normalize_subject: true` to enable it. + +##### include_controller_and_action_names_in_subject + +*Boolean, default: true* + +If enabled, include the controller and action names in the subject. Use `include_controller_and_action_names_in_subject: false` to exclude them. + +##### email_format + +*Symbol, default: :text* + +By default, ExceptionNotification sends emails in plain text, in order to sends multipart notifications (aka HTML emails) use `email_format: :html`. + +##### delivery_method + +*Symbol, default: :smtp* + +By default, ExceptionNotification sends emails using the ActionMailer configuration of the application. In order to send emails by another delivery method, use the `delivery_method` option: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com}, + delivery_method: :postmark, + postmark_settings: { + api_key: ENV['POSTMARK_API_KEY'] + } + } +``` + +Besides the `delivery_method` option, you also can customize the mailer settings by passing a hash under an option named `DELIVERY_METHOD_settings`. Thus, you can use override specific SMTP settings for notifications using: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com}, + delivery_method: :smtp, + smtp_settings: { + user_name: 'bob', + password: 'password', + } + } +``` + +A complete list of `smtp_settings` options can be found in the [ActionMailer Configuration documentation](http://api.rubyonrails.org/classes/ActionMailer/Base.html#class-ActionMailer::Base-label-Configuration+options). + +##### mailer_parent + +*String, default: ActionMailer::Base* + +The parent mailer which ExceptionNotification mailer inherit from. + +##### deliver_with + +*Symbol, default: :deliver_now + +The method name to send emalis using ActionMailer. diff --git a/docs/notifiers/google_chat.md b/docs/notifiers/google_chat.md new file mode 100644 index 00000000..2fdec153 --- /dev/null +++ b/docs/notifiers/google_chat.md @@ -0,0 +1,31 @@ +### Google Chat Notifier + +Post notifications in a Google Chats channel via [incoming webhook](https://developers.google.com/hangouts/chat/how-tos/webhooks) + +Add the [HTTParty](https://github.com/jnunemaker/httparty) gem to your `Gemfile`: + +```ruby +gem 'httparty' +``` + +To configure it, you **need** to set the `webhook_url` option. + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + google_chat: { + webhook_url: 'https://chat.googleapis.com/v1/spaces/XXXXXXXX/messages?key=YYYYYYYYYYYYY&token=ZZZZZZZZZZZZ' + } +``` + +##### webhook_url + +*String, required* + +The Incoming WebHook URL on Google Chats. + +##### app_name + +*String, optional* + +Your application name, shown in the notification. Defaults to `Rails.application.class.module_parent_name.underscore` for Rails versions >= 6; +`Rails.application.class.parent_name.underscore` otherwise. diff --git a/docs/notifiers/hipchat.md b/docs/notifiers/hipchat.md new file mode 100644 index 00000000..dba14415 --- /dev/null +++ b/docs/notifiers/hipchat.md @@ -0,0 +1,66 @@ +### HipChat notifier + +This notifier sends notifications to your Hipchat room. + +#### Usage + +Just add the [hipchat](https://github.com/hipchat/hipchat-rb) gem to your `Gemfile`: + +```ruby +gem 'hipchat' +``` + +To configure it, you need to set the `token` and `room_name` options, like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + hipchat: { + api_token: 'my_token', + room_name: 'my_room' + } +``` + +#### Options + +##### room_name + +*String, required* + +The HipChat room where the notifications must be published to. + +##### api_token + +*String, required* + +The API token to allow access to your HipChat account. + +##### notify + +*Boolean, optional* + +Notify users. Default : false. + +##### color + +*String, optional* + +Color of the message. Default : 'red'. + +##### from + +*String, optional, maximum length : 15* + +Message will appear from this nickname. Default : 'Exception'. + +##### server_url + +*String, optional* + +Custom Server URL for self-hosted, Enterprise HipChat Server + +For all options & possible values see [Hipchat API](https://www.hipchat.com/docs/api/method/rooms/message). diff --git a/docs/notifiers/irc.md b/docs/notifiers/irc.md new file mode 100644 index 00000000..69e733a5 --- /dev/null +++ b/docs/notifiers/irc.md @@ -0,0 +1,97 @@ +### IRC notifier + +This notifier sends notifications to an IRC channel using the carrier-pigeon gem. + +#### Usage + +Just add the [carrier-pigeon](https://github.com/portertech/carrier-pigeon) gem to your `Gemfile`: + +```ruby +gem 'carrier-pigeon' +``` + +To configure it, you need to set at least the 'domain' option, like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + irc: { + domain: 'irc.example.com' + } +``` + +There are several other options, which are described below. For example, to use ssl and a password, add a prefix, post to the '#log' channel, and include recipients in the message (so that they will be notified), your configuration might look like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + irc: { + domain: 'irc.example.com', + nick: 'BadNewsBot', + password: 'secret', + port: 6697, + channel: '#log', + ssl: true, + prefix: '[Exception Notification]', + recipients: ['peter', 'michael', 'samir'] + } +``` + +#### Options + +##### domain + +*String, required* + +The domain name of your IRC server. + +##### nick + +*String, optional* + +The message will appear from this nick. Default : 'ExceptionNotifierBot'. + +##### password + +*String, optional* + +Password for your IRC server. + +##### port + +*String, optional* + +Port your IRC server is listening on. Default : 6667. + +##### channel + +*String, optional* + +Message will appear in this channel. Default : '#log'. + +##### notice + +*Boolean, optional* + +Send a notice. Default : false. + +##### ssl + +*Boolean, optional* + +Whether to use SSL. Default : false. + +##### join + +*Boolean, optional* + +Join a channel. Default : false. + +##### recipients + +*Array of strings, optional* + +Nicks to include in the message. Default: [] diff --git a/docs/notifiers/mattermost.md b/docs/notifiers/mattermost.md new file mode 100644 index 00000000..3575aef5 --- /dev/null +++ b/docs/notifiers/mattermost.md @@ -0,0 +1,115 @@ +### Mattermost notifier + +Post notification in a mattermost channel via [incoming webhook](http://docs.mattermost.com/developer/webhooks-incoming.html) + +Just add the [HTTParty](https://github.com/jnunemaker/httparty) gem to your `Gemfile`: + +```ruby +gem 'httparty' +``` + +To configure it, you **need** to set the `webhook_url` option. +You can also specify an other channel with `channel` option. + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + mattermost: { + webhook_url: 'http://your-mattermost.com/hooks/blablabla', + channel: 'my-channel' + } +``` + +If you are using Github or Gitlab for issues tracking, you can specify `git_url` as follow to add a *Create issue* link in you notification. +By default this will use your Rails application name to match the git repository. If yours differ you can specify `app_name`. + + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + mattermost: { + webhook_url: 'http://your-mattermost.com/hooks/blablabla', + git_url: 'github.com/aschen' + } +``` + +You can also specify the bot name and avatar with `username` and `avatar` options. + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: 'PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + mattermost: { + webhook_url: 'http://your-mattermost.com/hooks/blablabla', + avatar: 'http://example.com/your-image.png', + username: 'Fail bot' + } +``` + +Finally since the notifier use HTTParty, you can include all HTTParty options, like basic_auth for example. + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + mattermost: { + webhook_url: 'http://your-mattermost.com/hooks/blablabla', + basic_auth: { + username: 'clara', + password: 'password' + } + } +``` + +#### Options + +##### webhook_url + +*String, required* + +The Incoming WebHook URL on mattermost. + +##### channel + +*String, optional* + +Message will appear in this channel. Defaults to the channel you set as such on mattermost. + +##### username + +*String, optional* + +Username of the bot. Defaults to "Incoming Webhook" + +##### avatar + +*String, optional* + +Avatar of the bot. Defaults to incoming webhook icon. + +##### git_url + +*String, optional* + +Url of your gitlab or github with your organisation name for issue creation link (Eg: `github.com/aschen`). Defaults to nil and don't add link to the notification. + +##### app_name + +*String, optional* + +Your application name used for issue creation link. Defaults to `Rails.application.class.module_parent_name.underscore` for Rails versions >= 6; +`Rails.application.class.parent_name.underscore` otherwise. diff --git a/docs/notifiers/slack.md b/docs/notifiers/slack.md new file mode 100644 index 00000000..3fa47be6 --- /dev/null +++ b/docs/notifiers/slack.md @@ -0,0 +1,161 @@ +### Slack notifier + +This notifier sends notifications to a slack channel using the slack-notifier gem. + +#### Usage + +Just add the [slack-notifier](https://github.com/stevenosloan/slack-notifier) gem to your `Gemfile`: + +```ruby +gem 'slack-notifier' +``` + +To configure it, you need to set at least the 'webhook_url' option, like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + slack: { + webhook_url: '[Your webhook url]', + channel: '#exceptions', + additional_parameters: { + icon_url: 'http://image.jpg', + mrkdwn: true + } + } +``` + +The slack notification will include any data saved under `env['exception_notifier.exception_data']`. + +An example of how to send the server name to Slack in Rails (put this code in application_controller.rb): + +```ruby +before_action :set_notification + +def set_notification + request.env['exception_notifier.exception_data'] = { 'server' => request.env['SERVER_NAME'] } + # can be any key-value pairs +end +``` + +If you find this too verbose, you can determine to exclude certain information by doing the following: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + slack: { + webhook_url: '[Your webhook url]', + channel: '#exceptions', + additional_parameters: { + icon_url: 'http://image.jpg', + mrkdwn: true + }, + ignore_data_if: lambda {|key, value| + "#{key}" == 'key_to_ignore' || value.is_a?(ClassToBeIgnored) + } + } +``` + +Any evaluation to `true` will cause the key / value pair not be be sent along to Slack. + + +the `slack-notifier` gem allows to override the channel default value, if you ever +need to send a notification to a different slack channel. Simply add the +`channel` option when calling `.notify_exception` + +```ruby +ExceptionNotifier.notify_exception( + exception, + env: request.env, + channel: '#my-custom-channel', # Make sure the channel name starts with `#` + data: { + error: error_variable, + server: server_name + } +) +``` + +If you ever need to add more `slack-notifier` specific options, and +particularly to the `#ping` method of the slack notifier, you can use +the `pre_callback` option when defining the middleware. +```ruby + pre_callback: proc { |opts, _notifier, _backtrace, _message, message_opts| + message_opts[:channel] = opts[:channel] if opts.key?(:channel) + } + +``` +- `message_opts` is the hash you want to append to if you need to add an option. +- `options` is the hash containing the values when you call + `ExceptionNotification.notify_exception` + +An example implementation would be: +```ruby +config.middleware.use ExceptionNotification::Rack, + slack: { + webhook_url: '[Your webhook url]', + pre_callback: proc { |opts, _notifier, _backtrace, _message, message_opts| + message_opts[:ping_option] = opts[:ping_option] if + opts.key?(:ping_option) + } + }, + error_grouping: true +``` +Then when calling from within your application code: +```ruby +ExceptionNotifier.notify_exception( + exception, + env: request.env, + ping_option: 'value', + # this will be passed to the slack notifier's `#ping` + # method, as a parameter. The `:pre_callback` hook will catch it + # and do that for you. + # Helpful, if the API evolves, you only need to update + # the `slack-notifier` gem + data: { + error: error_variable, + server: server_name + } +) + +``` +#### Options + +##### webhook_url + +*String, required* + +The Incoming WebHook URL on slack. + +##### channel + +*String, optional* + +Message will appear in this channel. Defaults to the channel you set as such on slack. + +##### username + +*String, optional* + +Username of the bot. Defaults to the name you set as such on slack + +##### custom_hook + +*String, optional* + +Custom hook name. See [slack-notifier](https://github.com/stevenosloan/slack-notifier#custom-hook-name) for +more information. Default: 'incoming-webhook' + +##### additional_parameters + +*Hash of strings, optional* + +Contains additional payload for a message (e.g avatar, attachments, etc). See [slack-notifier](https://github.com/stevenosloan/slack-notifier#additional-parameters) for more information.. Default: '{}' + +##### additional_fields + +*Array of Hashes, optional* + +Contains additional fields that will be added to the attachement. See [Slack documentation](https://api.slack.com/docs/message-attachments). diff --git a/docs/notifiers/sns.md b/docs/notifiers/sns.md new file mode 100644 index 00000000..7e5700f6 --- /dev/null +++ b/docs/notifiers/sns.md @@ -0,0 +1,37 @@ +### Amazon SNS Notifier + +Notify all exceptions Amazon - Simple Notification Service: [SNS](https://aws.amazon.com/sns/). + +#### Usage + +Add the [aws-sdk-sns](https://github.com/aws/aws-sdk-ruby/tree/master/gems/aws-sdk-sns) gem to your `Gemfile`: + +```ruby + gem 'aws-sdk-sns', '~> 1.5' +``` + +To configure it, you **need** to set 3 required options for aws: `region`, `access_key_id` and `secret_access_key`, and one more option for sns: `topic_arn`. + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + sns: { + region: 'us-east-x', + access_key_id: 'access_key_id', + secret_access_key: 'secret_access_key', + topic_arn: 'arn:aws:sns:us-east-x:XXXX:my-topic' + } +``` + +##### sns_prefix +*String, optional * + +Prefix in the notification subject, by default: "[Error]" + +##### backtrace_lines +*Integer, optional * + +Number of backtrace lines to be displayed in the notification message. By default: 10 + +#### Note: +* You may need to update your previous `aws-sdk-*` gems in order to setup `aws-sdk-sns` correctly. +* If you need any further information about the available regions or any other SNS related topic consider: [SNS faqs](https://aws.amazon.com/sns/faqs/) diff --git a/docs/notifiers/teams.md b/docs/notifiers/teams.md new file mode 100644 index 00000000..df3b4377 --- /dev/null +++ b/docs/notifiers/teams.md @@ -0,0 +1,54 @@ +### Teams notifier + +Post notification in a Microsoft Teams channel via [Incoming Webhook Connector](https://docs.microsoft.com/en-us/outlook/actionable-messages/actionable-messages-via-connectors) +Just add the [HTTParty](https://github.com/jnunemaker/httparty) gem to your `Gemfile`: + +```ruby +gem 'httparty' +``` + +To configure it, you **need** to set the `webhook_url` option. +If you are using GitLab for issue tracking, you can specify `git_url` as follows to add a *Create issue* button in your notification. +By default this will use your Rails application name to match the git repository. If yours differs, you can specify `app_name`. +By that same notion, you may also set a `jira_url` to get a button that will send you to the New Issue screen in Jira. + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: "[PREFIX] ", + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + teams: { + webhook_url: 'https://outlook.office.com/webhook/your-guid/IncomingWebhook/team-guid', + git_url: 'https://your-gitlab.com/Group/Project', + jira_url: 'https://your-jira.com' + } +``` + +#### Options + +##### webhook_url + +*String, required* + +The Incoming WebHook URL on Teams. + +##### git_url + +*String, optional* + +Url of your gitlab or github with your organisation name for issue creation link (Eg: `github.com/aschen`). Defaults to nil and doesn't add link to the notification. + +##### jira_url + +*String, optional* + +Url of your Jira instance, adds button for Create Issue screen. Defaults to nil and doesn't add a button to the card. + +##### app_name + +*String, optional* + +Your application name used for git issue creation link. Defaults to `Rails.application.class.module_parent_name.underscore` for Rails versions >= 6; +`Rails.application.class.parent_name.underscore` otherwise. diff --git a/docs/notifiers/webhook.md b/docs/notifiers/webhook.md new file mode 100644 index 00000000..a8ad2962 --- /dev/null +++ b/docs/notifiers/webhook.md @@ -0,0 +1,60 @@ +### WebHook notifier + +This notifier ships notifications over the HTTP protocol. + +#### Usage + +Just add the [HTTParty](https://github.com/jnunemaker/httparty) gem to your `Gemfile`: + +```ruby +gem 'httparty' +``` + +To configure it, you need to set the `url` option, like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + webhook: { + url: 'http://domain.com:5555/hubot/path' + } +``` + +By default, the WebhookNotifier will call the URLs using the POST method. But, you can change this using the `http_method` option. + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + webhook: { + url: 'http://domain.com:5555/hubot/path', + http_method: :get + } +``` + +Besides the `url` and `http_method` options, all the other options are passed directly to HTTParty. Thus, if the HTTP server requires authentication, you can include the following options: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + email: { + email_prefix: '[PREFIX] ', + sender_address: %{"notifier" }, + exception_recipients: %w{exceptions@example.com} + }, + webhook: { + url: 'http://domain.com:5555/hubot/path', + basic_auth: { + username: 'alice', + password: 'password' + } + } +``` + +For more HTTParty options, check out the [documentation](https://github.com/jnunemaker/httparty). diff --git a/examples/sample_app.rb b/examples/sample_app.rb new file mode 100644 index 00000000..8ff45866 --- /dev/null +++ b/examples/sample_app.rb @@ -0,0 +1,54 @@ +# ------------------------------------------- +# To run the application: ruby examples/sample_app.rb +# ------------------------------------------- + +require 'bundler/inline' + +gemfile do + source 'https://rubygems.org' + + gem 'rails', '5.0.0' + gem 'exception_notification', '4.3.0' + gem 'httparty', '0.15.7' +end + +class SampleApp < Rails::Application + config.middleware.use ExceptionNotification::Rack, + webhook: { + url: 'http://example.com' + } + + config.secret_key_base = 'my secret key base' + + Rails.logger = Logger.new($stdout) + + routes.draw do + get '/', to: 'exceptions#index' + end +end + +require 'action_controller/railtie' + +class ExceptionsController < ActionController::Base + def index + raise 'Sample exception raised, you should receive a notification!' + end +end + +require 'minitest/autorun' + +class Test < Minitest::Test + include Rack::Test::Methods + + def test_raise_exception + get '/' + + assert last_response.server_error? + end + + private + + def app + Rails.application + end +end diff --git a/examples/sinatra/Gemfile b/examples/sinatra/Gemfile index b03b5372..d6c9f377 100644 --- a/examples/sinatra/Gemfile +++ b/examples/sinatra/Gemfile @@ -1,8 +1,8 @@ -source "https://rubygems.org" +source 'https://rubygems.org' -gem "exception_notification", path: "../../" +gem 'exception_notification', path: '../../' -gem "thin", "~> 1.5.1" -gem "sinatra", "~> 1.3.5" -gem "foreman" -gem "mailcatcher" +gem 'foreman' +gem 'mailcatcher' +gem 'sinatra', '~> 1.3.5' +gem 'thin', '~> 1.5.1' diff --git a/examples/sinatra/config.ru b/examples/sinatra/config.ru index 1b41b714..9b26ef06 100644 --- a/examples/sinatra/config.ru +++ b/examples/sinatra/config.ru @@ -1,3 +1,3 @@ -require ::File.expand_path('../sinatra_app', __FILE__) +require ::File.expand_path('../sinatra_app', __FILE__) run SinatraApp diff --git a/examples/sinatra/sinatra_app.rb b/examples/sinatra/sinatra_app.rb index c71315f3..6e0dec51 100644 --- a/examples/sinatra/sinatra_app.rb +++ b/examples/sinatra/sinatra_app.rb @@ -5,27 +5,31 @@ class SinatraApp < Sinatra::Base use Rack::Config do |env| - env["action_dispatch.parameter_filter"] = [:password] # This is highly recommended. It will prevent the ExceptionNotification email from including your users' passwords + env['action_dispatch.parameter_filter'] = [:password] # This is highly recommended. It will prevent the ExceptionNotification email from including your users' passwords end use ExceptionNotification::Rack, - :email => { - :email_prefix => "[Example] ", - :sender_address => %{"notifier" }, - :exception_recipients => %w{exceptions@example.com}, - :smtp_settings => { :address => "localhost", :port => 1025 } - } + email: { + email_prefix: '[Example] ', + sender_address: %("notifier" ), + exception_recipients: %w[exceptions@example.com], + smtp_settings: { + address: 'localhost', + port: 1025 + } + } get '/' do raise StandardError, "ERROR: #{params[:error]}" unless params[:error].blank? + 'Everything is fine! Now, lets break things clicking here . Dont forget to see the emails at mailcatcher !' end get '/background_notification' do begin - 1/0 - rescue Exception => e - ExceptionNotifier.notify_exception(e, :data => {:msg => "Cannot divide by zero!"}) + 1 / 0 + rescue StandardError => e + ExceptionNotifier.notify_exception(e, data: { msg: 'Cannot divide by zero!' }) end 'Check email at mailcatcher.' end diff --git a/exception_notification.gemspec b/exception_notification.gemspec index 8c1bc8f7..9070b05d 100644 --- a/exception_notification.gemspec +++ b/exception_notification.gemspec @@ -1,12 +1,14 @@ +require File.expand_path('../lib/exception_notification/version', __FILE__) + Gem::Specification.new do |s| s.name = 'exception_notification' - s.version = '4.2.2' - s.authors = ["Jamis Buck", "Josh Peek"] - s.date = %q{2017-08-12} - s.summary = "Exception notification for Rails apps" - s.homepage = "https://smartinez87.github.io/exception_notification/" - s.email = "smartinez87@gmail.com" - s.license = "MIT" + s.version = ExceptionNotification::VERSION + s.authors = ['Jamis Buck', 'Josh Peek'] + s.date = '2019-08-16' + s.summary = 'Exception notification for Rails apps' + s.homepage = 'https://smartinez87.github.io/exception_notification/' + s.email = 'smartinez87@gmail.com' + s.license = 'MIT' s.required_ruby_version = '>= 2.0' s.required_rubygems_version = '>= 1.8.11' @@ -16,20 +18,24 @@ Gem::Specification.new do |s| s.test_files = `git ls-files -- test`.split("\n") s.require_path = 'lib' - s.add_dependency("actionmailer", ">= 4.0", "< 6") - s.add_dependency("activesupport", ">= 4.0", "< 6") + s.add_dependency('actionmailer', '>= 4.0', '< 7') + s.add_dependency('activesupport', '>= 4.0', '< 7') - s.add_development_dependency "rails", ">= 4.0", "< 6" - s.add_development_dependency "resque", "~> 1.2.0" + s.add_development_dependency 'appraisal', '~> 2.2.0' + s.add_development_dependency 'aws-sdk-sns', '~> 1' + s.add_development_dependency 'carrier-pigeon', '>= 0.7.0' + s.add_development_dependency 'coveralls', '~> 0.8.2' + s.add_development_dependency 'dogapi', '>= 1.23.0' + s.add_development_dependency 'hipchat', '>= 1.0.0' + s.add_development_dependency 'httparty', '~> 0.10.2' + s.add_development_dependency 'mock_redis', '~> 0.18.0' + s.add_development_dependency 'mocha', '>= 0.13.0' + s.add_development_dependency 'rails', '>= 4.0', '< 7' + s.add_development_dependency 'resque', '~> 1.8.0' + s.add_development_dependency 'rubocop', '0.50.0' # Sidekiq 3.2.2 does not support Ruby 1.9. - s.add_development_dependency "sidekiq", "~> 3.0.0", "< 3.2.2" - s.add_development_dependency "tinder", "~> 1.8" - s.add_development_dependency "httparty", "~> 0.10.2" - s.add_development_dependency "mocha", ">= 0.13.0" - s.add_development_dependency "sqlite3", ">= 1.3.4" - s.add_development_dependency "coveralls", "~> 0.8.2" - s.add_development_dependency "appraisal", "~> 2.0.0" - s.add_development_dependency "hipchat", ">= 1.0.0" - s.add_development_dependency "carrier-pigeon", ">= 0.7.0" - s.add_development_dependency "slack-notifier", ">= 1.0.0" + s.add_development_dependency 'sidekiq', '~> 3.0.0', '< 3.2.2' + s.add_development_dependency 'slack-notifier', '>= 1.0.0' + s.add_development_dependency 'timecop', '~>0.9.0' + s.add_development_dependency 'tinder', '~> 1.8' end diff --git a/gemfiles/rails4_0.gemfile b/gemfiles/rails4_0.gemfile index 7236c649..4f47847a 100644 --- a/gemfiles/rails4_0.gemfile +++ b/gemfiles/rails4_0.gemfile @@ -3,6 +3,5 @@ source "https://rubygems.org" gem "rails", "~> 4.0.5" -gem "sqlite3" -gemspec :path => "../" +gemspec path: "../" diff --git a/gemfiles/rails4_1.gemfile b/gemfiles/rails4_1.gemfile index 4b8d1481..7e4bd92e 100644 --- a/gemfiles/rails4_1.gemfile +++ b/gemfiles/rails4_1.gemfile @@ -3,6 +3,5 @@ source "https://rubygems.org" gem "rails", "~> 4.1.1" -gem "sqlite3" -gemspec :path => "../" +gemspec path: "../" diff --git a/gemfiles/rails4_2.gemfile b/gemfiles/rails4_2.gemfile index 46a47fc3..6977eb02 100644 --- a/gemfiles/rails4_2.gemfile +++ b/gemfiles/rails4_2.gemfile @@ -3,6 +3,5 @@ source "https://rubygems.org" gem "rails", "~> 4.2.0" -gem "sqlite3" -gemspec :path => "../" +gemspec path: "../" diff --git a/gemfiles/rails5_0.gemfile b/gemfiles/rails5_0.gemfile index 0ee5036d..10f52e7a 100644 --- a/gemfiles/rails5_0.gemfile +++ b/gemfiles/rails5_0.gemfile @@ -3,6 +3,5 @@ source "https://rubygems.org" gem "rails", "~> 5.0.0" -gem "sqlite3" -gemspec :path => "../" +gemspec path: "../" diff --git a/gemfiles/rails5_1.gemfile b/gemfiles/rails5_1.gemfile new file mode 100644 index 00000000..6100e830 --- /dev/null +++ b/gemfiles/rails5_1.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 5.1.0" + +gemspec path: "../" diff --git a/gemfiles/rails5_2.gemfile b/gemfiles/rails5_2.gemfile new file mode 100644 index 00000000..5a706dcb --- /dev/null +++ b/gemfiles/rails5_2.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 5.2.0" + +gemspec path: "../" diff --git a/gemfiles/rails6_0.gemfile b/gemfiles/rails6_0.gemfile new file mode 100644 index 00000000..15b9b275 --- /dev/null +++ b/gemfiles/rails6_0.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 6.0.0" + +gemspec path: "../" diff --git a/lib/exception_notification.rb b/lib/exception_notification.rb index fb83d710..4bbdc29b 100644 --- a/lib/exception_notification.rb +++ b/lib/exception_notification.rb @@ -1,5 +1,6 @@ require 'exception_notifier' require 'exception_notification/rack' +require 'exception_notification/version' module ExceptionNotification # Alternative way to setup ExceptionNotification. diff --git a/lib/exception_notification/rack.rb b/lib/exception_notification/rack.rb index bb0d1354..628a62f1 100644 --- a/lib/exception_notification/rack.rb +++ b/lib/exception_notification/rack.rb @@ -1,6 +1,6 @@ module ExceptionNotification class Rack - class CascadePassException < Exception; end + class CascadePassException < RuntimeError; end def initialize(app, options = {}) @app = app @@ -12,7 +12,7 @@ def initialize(app, options = {}) if options.key?(:error_grouping_cache) ExceptionNotifier.error_grouping_cache = options.delete(:error_grouping_cache) - elsif defined?(Rails) + elsif defined?(Rails) && Rails.respond_to?(:cache) ExceptionNotifier.error_grouping_cache = Rails.cache end @@ -23,12 +23,7 @@ def initialize(app, options = {}) end end - if options.key?(:ignore_crawlers) - ignore_crawlers = options.delete(:ignore_crawlers) - ExceptionNotifier.ignore_if do |exception, opts| - opts.key?(:env) && from_crawler(opts[:env], ignore_crawlers) - end - end + ExceptionNotifier.ignore_crawlers(options.delete(:ignore_crawlers)) if options.key?(:ignore_crawlers) @ignore_cascade_pass = options.delete(:ignore_cascade_pass) { true } @@ -38,31 +33,23 @@ def initialize(app, options = {}) end def call(env) - _, headers, _ = response = @app.call(env) + _, headers, = response = @app.call(env) if !@ignore_cascade_pass && headers['X-Cascade'] == 'pass' - msg = "This exception means that the preceding Rack middleware set the 'X-Cascade' header to 'pass' -- in " << - "Rails, this often means that the route was not found (404 error)." + msg = "This exception means that the preceding Rack middleware set the 'X-Cascade' header to 'pass' -- in " \ + 'Rails, this often means that the route was not found (404 error).' raise CascadePassException, msg end response rescue Exception => exception - if ExceptionNotifier.notify_exception(exception, :env => env) + if ExceptionNotifier.notify_exception(exception, env: env) env['exception_notifier.delivered'] = true end raise exception unless exception.is_a?(CascadePassException) - response - end - - private - def from_crawler(env, ignored_crawlers) - agent = env['HTTP_USER_AGENT'] - Array(ignored_crawlers).any? do |crawler| - agent =~ Regexp.new(crawler) - end + response end end end diff --git a/lib/exception_notification/resque.rb b/lib/exception_notification/resque.rb index 8fb9f76d..288f117d 100644 --- a/lib/exception_notification/resque.rb +++ b/lib/exception_notification/resque.rb @@ -2,23 +2,21 @@ module ExceptionNotification class Resque < Resque::Failure::Base - def self.count - Stat[:failed] + ::Resque::Stat[:failed] end def save data = { - :failed_at => Time.now.to_s, - :queue => queue, - :worker => worker.to_s, - :payload => payload, - :error_class => exception.class.name, - :error_message => exception.message + error_class: exception.class.name, + error_message: exception.message, + failed_at: Time.now.to_s, + payload: payload, + queue: queue, + worker: worker.to_s } - ExceptionNotifier.notify_exception(exception, :data => { :resque => data }) + ExceptionNotifier.notify_exception(exception, data: { resque: data }) end - end end diff --git a/lib/exception_notification/sidekiq.rb b/lib/exception_notification/sidekiq.rb index ed01f844..b01e8360 100644 --- a/lib/exception_notification/sidekiq.rb +++ b/lib/exception_notification/sidekiq.rb @@ -3,16 +3,12 @@ # Note: this class is only needed for Sidekiq version < 3. module ExceptionNotification class Sidekiq - - def call(worker, msg, queue) - begin - yield - rescue Exception => exception - ExceptionNotifier.notify_exception(exception, :data => { :sidekiq => msg }) - raise exception - end + def call(_worker, msg, _queue) + yield + rescue Exception => exception + ExceptionNotifier.notify_exception(exception, data: { sidekiq: msg }) + raise exception end - end end @@ -24,8 +20,8 @@ def call(worker, msg, queue) end else ::Sidekiq.configure_server do |config| - config.error_handlers << Proc.new { |ex, context| - ExceptionNotifier.notify_exception(ex, :data => { :sidekiq => context }) - } + config.error_handlers << proc do |ex, context| + ExceptionNotifier.notify_exception(ex, data: { sidekiq: context }) + end end end diff --git a/lib/exception_notification/version.rb b/lib/exception_notification/version.rb new file mode 100644 index 00000000..e0e70d5b --- /dev/null +++ b/lib/exception_notification/version.rb @@ -0,0 +1,3 @@ +module ExceptionNotification + VERSION = '4.4.0'.freeze +end diff --git a/lib/exception_notifier.rb b/lib/exception_notifier.rb index a6b4cab8..4ae01c13 100644 --- a/lib/exception_notifier.rb +++ b/lib/exception_notifier.rb @@ -8,6 +8,7 @@ module ExceptionNotifier include ErrorGrouping autoload :BacktraceCleaner, 'exception_notifier/modules/backtrace_cleaner' + autoload :Formatter, 'exception_notifier/modules/formatter' autoload :Notifier, 'exception_notifier/notifier' autoload :EmailNotifier, 'exception_notifier/email_notifier' @@ -17,6 +18,10 @@ module ExceptionNotifier autoload :IrcNotifier, 'exception_notifier/irc_notifier' autoload :SlackNotifier, 'exception_notifier/slack_notifier' autoload :MattermostNotifier, 'exception_notifier/mattermost_notifier' + autoload :TeamsNotifier, 'exception_notifier/teams_notifier' + autoload :SnsNotifier, 'exception_notifier/sns_notifier' + autoload :GoogleChatNotifier, 'exception_notifier/google_chat_notifier' + autoload :DatadogNotifier, 'exception_notifier/datadog_notifier' autoload :TeamWebhookNotifier, 'exception_notifier/team_webhook_notifier' class UndefinedNotifierError < StandardError; end @@ -27,7 +32,7 @@ class UndefinedNotifierError < StandardError; end # Define a set of exceptions to be ignored, ie, dont send notifications when any of them are raised. mattr_accessor :ignored_exceptions - @@ignored_exceptions = %w{ActiveRecord::RecordNotFound Mongoid::Errors::DocumentNotFound AbstractController::ActionNotFound ActionController::RoutingError ActionController::UnknownFormat ActionController::UrlGenerationError} + @@ignored_exceptions = %w[ActiveRecord::RecordNotFound Mongoid::Errors::DocumentNotFound AbstractController::ActionNotFound ActionController::RoutingError ActionController::UnknownFormat ActionController::UrlGenerationError] mattr_accessor :testing_mode @@testing_mode = false @@ -43,9 +48,10 @@ def testing_mode! self.testing_mode = true end - def notify_exception(exception, options={}) + def notify_exception(exception, options = {}, &block) return false if ignored_exception?(options[:ignore_exceptions], exception) return false if ignored?(exception, options) + if error_grouping errors_count = group_error!(exception, options) return false unless send_notification?(exception, errors_count) @@ -53,7 +59,7 @@ def notify_exception(exception, options={}) selected_notifiers = options.delete(:notifiers) || notifiers [*selected_notifiers].each do |notifier| - fire_notification(notifier, exception, options.dup) + fire_notification(notifier, exception, options.dup, &block) end true end @@ -90,13 +96,20 @@ def ignore_if(&block) @@ignores << block end + def ignore_crawlers(crawlers) + ignore_if do |_exception, opts| + opts.key?(:env) && from_crawler(opts[:env], crawlers) + end + end + def clear_ignore_conditions! @@ignores.clear end private + def ignored?(exception, options) - @@ignores.any?{ |condition| condition.call(exception, options) } + @@ignores.any? { |condition| condition.call(exception, options) } rescue Exception => e raise e if @@testing_mode @@ -105,12 +118,14 @@ def ignored?(exception, options) end def ignored_exception?(ignore_array, exception) - (Array(ignored_exceptions) + Array(ignore_array)).map(&:to_s).include?(exception.class.name) + all_ignored_exceptions = (Array(ignored_exceptions) + Array(ignore_array)).map(&:to_s) + exception_ancestors = exception.class.ancestors.map(&:to_s) + !(all_ignored_exceptions & exception_ancestors).empty? end - def fire_notification(notifier_name, exception, options) + def fire_notification(notifier_name, exception, options, &block) notifier = registered_exception_notifier(notifier_name) - notifier.call(exception, options) + notifier.call(exception, options, &block) rescue Exception => e raise e if @@testing_mode @@ -126,5 +141,12 @@ def create_and_register_notifier(name, options) rescue NameError => e raise UndefinedNotifierError, "No notifier named '#{name}' was found. Please, revise your configuration options. Cause: #{e.message}" end + + def from_crawler(env, ignored_crawlers) + agent = env['HTTP_USER_AGENT'] + Array(ignored_crawlers).any? do |crawler| + agent =~ Regexp.new(crawler) + end + end end end diff --git a/lib/exception_notifier/base_notifier.rb b/lib/exception_notifier/base_notifier.rb index 2de9c05e..1276ada0 100644 --- a/lib/exception_notifier/base_notifier.rb +++ b/lib/exception_notifier/base_notifier.rb @@ -2,11 +2,11 @@ module ExceptionNotifier class BaseNotifier attr_accessor :base_options - def initialize(options={}) + def initialize(options = {}) @base_options = options end - def send_notice(exception, options, message, message_opts=nil) + def send_notice(exception, options, message, message_opts = nil) _pre_callback(exception, options, message, message_opts) result = yield(message, message_opts) _post_callback(exception, options, message, message_opts) @@ -20,6 +20,5 @@ def _pre_callback(exception, options, message, message_opts) def _post_callback(exception, options, message, message_opts) @base_options[:post_callback].call(options, self, exception.backtrace, message, message_opts) if @base_options[:post_callback].respond_to?(:call) end - end end diff --git a/lib/exception_notifier/campfire_notifier.rb b/lib/exception_notifier/campfire_notifier.rb index 27b3ccf1..2f40d9b8 100644 --- a/lib/exception_notifier/campfire_notifier.rb +++ b/lib/exception_notifier/campfire_notifier.rb @@ -1,6 +1,5 @@ module ExceptionNotifier class CampfireNotifier < BaseNotifier - attr_accessor :subdomain attr_accessor :token attr_accessor :room @@ -12,22 +11,22 @@ def initialize(options) room_name = options.delete(:room_name) @campfire = Tinder::Campfire.new subdomain, options @room = @campfire.find_room_by_name room_name - rescue + rescue StandardError @campfire = @room = nil end end - def call(exception, options={}) - if active? - message = if options[:accumulated_errors_count].to_i > 1 - "The exception occurred #{options[:accumulated_errors_count]} times: '#{exception.message}'" - else - "A new exception occurred: '#{exception.message}'" - end - message += " on '#{exception.backtrace.first}'" if exception.backtrace - send_notice(exception, options, message) do |msg, _| - @room.paste msg - end + def call(exception, options = {}) + return unless active? + + message = if options[:accumulated_errors_count].to_i > 1 + "The exception occurred #{options[:accumulated_errors_count]} times: '#{exception.message}'" + else + "A new exception occurred: '#{exception.message}'" + end + message += " on '#{exception.backtrace.first}'" if exception.backtrace + send_notice(exception, options, message) do |msg, _| + @room.paste msg end end diff --git a/lib/exception_notifier/datadog_notifier.rb b/lib/exception_notifier/datadog_notifier.rb new file mode 100644 index 00000000..d8b810a5 --- /dev/null +++ b/lib/exception_notifier/datadog_notifier.rb @@ -0,0 +1,153 @@ +require 'action_dispatch' + +module ExceptionNotifier + class DatadogNotifier < BaseNotifier + attr_reader :client, + :default_options + + def initialize(options) + super + @client = options.fetch(:client) + @default_options = options + end + + def call(exception, options = {}) + client.emit_event( + datadog_event(exception, options) + ) + end + + def datadog_event(exception, options = {}) + DatadogExceptionEvent.new( + exception, + options.reverse_merge(default_options) + ).event + end + + class DatadogExceptionEvent + include ExceptionNotifier::BacktraceCleaner + + MAX_TITLE_LENGTH = 120 + MAX_VALUE_LENGTH = 300 + MAX_BACKTRACE_SIZE = 3 + ALERT_TYPE = 'error'.freeze + + attr_reader :exception, + :options + + def initialize(exception, options) + @exception = exception + @options = options + end + + def request + @request ||= ActionDispatch::Request.new(options[:env]) if options[:env] + end + + def controller + @controller ||= options[:env] && options[:env]['action_controller.instance'] + end + + def backtrace + @backtrace ||= exception.backtrace ? clean_backtrace(exception) : [] + end + + def tags + options[:tags] || [] + end + + def title_prefix + options[:title_prefix] || '' + end + + def event + title = formatted_title + body = formatted_body + + Dogapi::Event.new( + body, + msg_title: title, + alert_type: ALERT_TYPE, + tags: tags, + aggregation_key: [title] + ) + end + + def formatted_title + title = '' + title << title_prefix + title << "#{controller.controller_name} #{controller.action_name}" if controller + title << " (#{exception.class})" + title << " #{exception.message.inspect}" + + truncate(title, MAX_TITLE_LENGTH) + end + + def formatted_body + text = [] + + text << '%%%' + text << formatted_request if request + text << formatted_session if request + text << formatted_backtrace + text << '%%%' + + text.join("\n") + end + + def formatted_key_value(key, value) + "**#{key}:** #{value}" + end + + def formatted_request + text = [] + text << '### **Request**' + text << formatted_key_value('URL', request.url) + text << formatted_key_value('HTTP Method', request.request_method) + text << formatted_key_value('IP Address', request.remote_ip) + text << formatted_key_value('Parameters', request.filtered_parameters.inspect) + text << formatted_key_value('Timestamp', Time.current) + text << formatted_key_value('Server', Socket.gethostname) + if defined?(Rails) && Rails.respond_to?(:root) + text << formatted_key_value('Rails root', Rails.root) + end + text << formatted_key_value('Process', $PROCESS_ID) + text << '___' + text.join("\n") + end + + def formatted_session + text = [] + text << '### **Session**' + text << formatted_key_value('Data', request.session.to_hash) + text << '___' + text.join("\n") + end + + def formatted_backtrace + size = [backtrace.size, MAX_BACKTRACE_SIZE].min + + text = [] + text << '### **Backtrace**' + text << '````' + size.times { |i| text << backtrace[i] } + text << '````' + text << '___' + text.join("\n") + end + + def truncate(string, max) + string.length > max ? "#{string[0...max]}..." : string + end + + def inspect_object(object) + case object + when Hash, Array + truncate(object.inspect, MAX_VALUE_LENGTH) + else + object.to_s + end + end + end + end +end diff --git a/lib/exception_notifier/email_notifier.rb b/lib/exception_notifier/email_notifier.rb index fb63f372..394172ec 100644 --- a/lib/exception_notifier/email_notifier.rb +++ b/lib/exception_notifier/email_notifier.rb @@ -1,4 +1,3 @@ -require "active_support/core_ext/hash/reverse_merge" require 'active_support/core_ext/time' require 'action_mailer' require 'action_dispatch' @@ -6,47 +5,61 @@ module ExceptionNotifier class EmailNotifier < BaseNotifier - attr_accessor(:sender_address, :exception_recipients, - :pre_callback, :post_callback, - :email_prefix, :email_format, :sections, :background_sections, - :verbose_subject, :normalize_subject, :include_controller_and_action_names_in_subject, - :delivery_method, :mailer_settings, :email_headers, :mailer_parent, :template_path, :deliver_with) + DEFAULT_OPTIONS = { + sender_address: %("Exception Notifier" ), + exception_recipients: [], + email_prefix: '[ERROR] ', + email_format: :text, + sections: %w[request session environment backtrace], + background_sections: %w[backtrace data], + verbose_subject: true, + normalize_subject: false, + include_controller_and_action_names_in_subject: true, + delivery_method: nil, + mailer_settings: nil, + email_headers: {}, + mailer_parent: 'ActionMailer::Base', + template_path: 'exception_notifier', + deliver_with: nil + }.freeze module Mailer class MissingController - def method_missing(*args, &block) - end + def method_missing(*args, &block); end end def self.extended(base) base.class_eval do - self.send(:include, ExceptionNotifier::BacktraceCleaner) + send(:include, ExceptionNotifier::BacktraceCleaner) # Append application view path to the ExceptionNotifier lookup context. - self.append_view_path "#{File.dirname(__FILE__)}/views" + append_view_path "#{File.dirname(__FILE__)}/views" - def exception_notification(env, exception, options={}, default_options={}) + def exception_notification(env, exception, options = {}, default_options = {}) load_custom_views @env = env @exception = exception - @options = options.reverse_merge(env['exception_notifier.options'] || {}).reverse_merge(default_options) + + env_options = env['exception_notifier.options'] || {} + @options = default_options.merge(env_options).merge(options) + @kontroller = env['action_controller.instance'] || MissingController.new @request = ActionDispatch::Request.new(env) @backtrace = exception.backtrace ? clean_backtrace(exception) : [] @timestamp = Time.current @sections = @options[:sections] @data = (env['exception_notifier.exception_data'] || {}).merge(options[:data] || {}) - @sections = @sections + %w(data) unless @data.empty? + @sections += %w[data] unless @data.empty? compose_email end - def background_exception_notification(exception, options={}, default_options={}) + def background_exception_notification(exception, options = {}, default_options = {}) load_custom_views @exception = exception - @options = options.reverse_merge(default_options).symbolize_keys + @options = default_options.merge(options).symbolize_keys @backtrace = exception.backtrace || [] @timestamp = Time.current @sections = @options[:background_sections] @@ -59,13 +72,13 @@ def background_exception_notification(exception, options={}, default_options={}) private def compose_subject - subject = "#{@options[:email_prefix]}" - subject << "(#{@options[:accumulated_errors_count]} times) " if @options[:accumulated_errors_count].to_i > 1 + subject = @options[:email_prefix].to_s.dup + subject << "(#{@options[:accumulated_errors_count]} times)" if @options[:accumulated_errors_count].to_i > 1 subject << "#{@kontroller.controller_name} #{@kontroller.action_name}" if @kontroller && @options[:include_controller_and_action_names_in_subject] subject << " (#{@exception.class})" subject << " #{@exception.message.inspect}" if @options[:verbose_subject] subject = EmailNotifier.normalize_digits(subject) if @options[:normalize_subject] - subject.length > 120 ? subject[0...120] + "..." : subject + subject.length > 120 ? subject[0...120] + '...' : subject end def set_data_variables @@ -75,24 +88,24 @@ def set_data_variables end helper_method :inspect_object - + def truncate(string, max) string.length > max ? "#{string[0...max]}..." : string end - + def inspect_object(object) case object - when Hash, Array - truncate(object.inspect, 300) - else - object.to_s + when Hash, Array + truncate(object.inspect, 300) + else + object.to_s end end helper_method :safe_encode def safe_encode(value) - value.encode("utf-8", invalid: :replace, undef: :replace, replace: "_") + value.encode('utf-8', invalid: :replace, undef: :replace, replace: '_') end def html_mail? @@ -106,11 +119,11 @@ def compose_email exception_recipients = maybe_call(@options[:exception_recipients]) headers = { - :delivery_method => @options[:delivery_method], - :to => exception_recipients, - :from => @options[:sender_address], - :subject => subject, - :template_name => name + delivery_method: @options[:delivery_method], + to: exception_recipients, + from: @options[:sender_address], + subject: subject, + template_name: name }.merge(@options[:email_headers]) mail = mail(headers) do |format| @@ -124,9 +137,7 @@ def compose_email end def load_custom_views - if defined?(Rails) && Rails.respond_to?(:root) - self.prepend_view_path Rails.root.nil? ? "app/views" : "#{Rails.root}/app/views" - end + prepend_view_path Rails.root.nil? ? 'app/views' : "#{Rails.root}/app/views" if defined?(Rails) && Rails.respond_to?(:root) end def maybe_call(maybe_proc) @@ -138,82 +149,48 @@ def maybe_call(maybe_proc) def initialize(options) super + delivery_method = (options[:delivery_method] || :smtp) mailer_settings_key = "#{delivery_method}_settings".to_sym options[:mailer_settings] = options.delete(mailer_settings_key) - options.reverse_merge(EmailNotifier.default_options).select{|k,v|[ - :sender_address, :exception_recipients, :pre_callback, - :post_callback, :email_prefix, :email_format, - :sections, :background_sections, :verbose_subject, :normalize_subject, - :include_controller_and_action_names_in_subject, :delivery_method, :mailer_settings, - :email_headers, :mailer_parent, :template_path, :deliver_with].include?(k)}.each{|k,v| send("#{k}=", v)} + @base_options = DEFAULT_OPTIONS.merge(options) end - def options - @options ||= {}.tap do |opts| - self.instance_variables.each { |var| opts[var[1..-1].to_sym] = self.instance_variable_get(var) } - end - end - - def mailer - @mailer ||= Class.new(mailer_parent.constantize).tap do |mailer| - mailer.extend(EmailNotifier::Mailer) - mailer.mailer_name = template_path - end - end - - def call(exception, options={}) + def call(exception, options = {}) message = create_email(exception, options) - # FIXME: use `if Gem::Version.new(ActionMailer::VERSION::STRING) < Gem::Version.new('4.1')` - if deliver_with == :default - if message.respond_to?(:deliver_now) - message.deliver_now - else - message.deliver - end - else - message.send(deliver_with) - end + message.send(base_options[:deliver_with] || default_deliver_with(message)) end - def create_email(exception, options={}) + def create_email(exception, options = {}) env = options[:env] - default_options = self.options - if env.nil? - send_notice(exception, options, nil, default_options) do |_, default_opts| + + send_notice(exception, options, nil, base_options) do |_, default_opts| + if env.nil? mailer.background_exception_notification(exception, options, default_opts) - end - else - send_notice(exception, options, nil, default_options) do |_, default_opts| + else mailer.exception_notification(env, exception, options, default_opts) end end end - def self.default_options - { - :sender_address => %("Exception Notifier" ), - :exception_recipients => [], - :email_prefix => "[ERROR] ", - :email_format => :text, - :sections => %w(request session environment backtrace), - :background_sections => %w(backtrace data), - :verbose_subject => true, - :normalize_subject => false, - :include_controller_and_action_names_in_subject => true, - :delivery_method => nil, - :mailer_settings => nil, - :email_headers => {}, - :mailer_parent => 'ActionMailer::Base', - :template_path => 'exception_notifier', - :deliver_with => :default - } - end - def self.normalize_digits(string) string.gsub(/[0-9]+/, 'N') end + + private + + def mailer + @mailer ||= Class.new(base_options[:mailer_parent].constantize).tap do |mailer| + mailer.extend(EmailNotifier::Mailer) + mailer.mailer_name = base_options[:template_path] + end + end + + def default_deliver_with(message) + # FIXME: use `if Gem::Version.new(ActionMailer::VERSION::STRING) < Gem::Version.new('4.1')` + message.respond_to?(:deliver_now) ? :deliver_now : :deliver + end end end diff --git a/lib/exception_notifier/google_chat_notifier.rb b/lib/exception_notifier/google_chat_notifier.rb new file mode 100644 index 00000000..94ce8480 --- /dev/null +++ b/lib/exception_notifier/google_chat_notifier.rb @@ -0,0 +1,42 @@ +require 'httparty' + +module ExceptionNotifier + class GoogleChatNotifier < BaseNotifier + def call(exception, opts = {}) + options = base_options.merge(opts) + formatter = Formatter.new(exception, options) + + HTTParty.post( + options[:webhook_url], + body: { text: body(exception, formatter) }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + private + + def body(exception, formatter) + text = [ + "\nApplication: *#{formatter.app_name}*", + formatter.subtitle, + '', + formatter.title, + "*#{exception.message.tr('`', "'")}*" + ] + + if (request = formatter.request_message.presence) + text << '' + text << '*Request:*' + text << request + end + + if (backtrace = formatter.backtrace_message.presence) + text << '' + text << '*Backtrace:*' + text << backtrace + end + + text.compact.join("\n") + end + end +end diff --git a/lib/exception_notifier/hipchat_notifier.rb b/lib/exception_notifier/hipchat_notifier.rb index 68ebd984..b45cc588 100644 --- a/lib/exception_notifier/hipchat_notifier.rb +++ b/lib/exception_notifier/hipchat_notifier.rb @@ -1,6 +1,5 @@ module ExceptionNotifier class HipchatNotifier < BaseNotifier - attr_accessor :from attr_accessor :room attr_accessor :message_options @@ -11,29 +10,29 @@ def initialize(options) api_token = options.delete(:api_token) room_name = options.delete(:room_name) opts = { - :api_version => options.delete(:api_version) || 'v1' - } + api_version: options.delete(:api_version) || 'v1' + } opts[:server_url] = options.delete(:server_url) if options[:server_url] @from = options.delete(:from) || 'Exception' @room = HipChat::Client.new(api_token, opts)[room_name] - @message_template = options.delete(:message_template) || ->(exception, errors_count) { + @message_template = options.delete(:message_template) || lambda { |exception, errors_count| msg = if errors_count > 1 - "The exception occurred #{errors_count} times: '#{Rack::Utils.escape_html(exception.message)}'" - else - "A new exception occurred: '#{Rack::Utils.escape_html(exception.message)}'" - end + "The exception occurred #{errors_count} times: '#{Rack::Utils.escape_html(exception.message)}'" + else + "A new exception occurred: '#{Rack::Utils.escape_html(exception.message)}'" + end msg += " on '#{exception.backtrace.first}'" if exception.backtrace msg } - @message_options = options + @message_options = options @message_options[:color] ||= 'red' - rescue + rescue StandardError @room = nil end end - def call(exception, options={}) - return if !active? + def call(exception, options = {}) + return unless active? message = @message_template.call(exception, options[:accumulated_errors_count].to_i) send_notice(exception, options, message, @message_options) do |msg, message_opts| diff --git a/lib/exception_notifier/irc_notifier.rb b/lib/exception_notifier/irc_notifier.rb index 122c22be..23a1ba0c 100644 --- a/lib/exception_notifier/irc_notifier.rb +++ b/lib/exception_notifier/irc_notifier.rb @@ -6,50 +6,52 @@ def initialize(options) parse_options(options) end - def call(exception, options={}) + def call(exception, options = {}) errors_count = options[:accumulated_errors_count].to_i message = "'#{exception.message}'" message.prepend("(#{errors_count} times)") if errors_count > 1 message += " on '#{exception.backtrace.first}'" if exception.backtrace - if active? - send_notice(exception, options, message) do |msg, _| - send_message([*@config.prefix, *msg].join(' ')) - end + + return unless active? + + send_notice(exception, options, message) do |msg, _| + send_message([*@config.prefix, *msg].join(' ')) end end def send_message(message) - CarrierPigeon.send @config.irc.merge({message: message}) + CarrierPigeon.send @config.irc.merge(message: message) end private - def parse_options(options) - nick = options.fetch(:nick, 'ExceptionNotifierBot') - password = options[:password] ? ":#{options[:password]}" : nil - domain = options.fetch(:domain, nil) - port = options[:port] ? ":#{options[:port]}" : nil - channel = options.fetch(:channel, '#log') - notice = options.fetch(:notice, false) - ssl = options.fetch(:ssl, false) - join = options.fetch(:join, false) - uri = "irc://#{nick}#{password}@#{domain}#{port}/#{channel}" - prefix = options.fetch(:prefix, nil) - recipients = options[:recipients] ? options[:recipients].join(', ') + ':' : nil - - @config.prefix = [*prefix, *recipients].join(' ') - @config.irc = { uri: uri, ssl: ssl, notice: notice, join: join } - end - def active? - valid_uri? @config.irc[:uri] - end + def parse_options(options) + nick = options.fetch(:nick, 'ExceptionNotifierBot') + password = options[:password] ? ":#{options[:password]}" : nil + domain = options.fetch(:domain, nil) + port = options[:port] ? ":#{options[:port]}" : nil + channel = options.fetch(:channel, '#log') + notice = options.fetch(:notice, false) + ssl = options.fetch(:ssl, false) + join = options.fetch(:join, false) + uri = "irc://#{nick}#{password}@#{domain}#{port}/#{channel}" + prefix = options.fetch(:prefix, nil) + recipients = options[:recipients] ? options[:recipients].join(', ') + ':' : nil + + @config.prefix = [*prefix, *recipients].join(' ') + @config.irc = { uri: uri, ssl: ssl, notice: notice, join: join } + end - def valid_uri?(uri) - !!URI.parse(uri) - rescue URI::InvalidURIError - false - end + def active? + valid_uri? @config.irc[:uri] + end + + def valid_uri?(uri) + URI.parse(uri) + rescue URI::InvalidURIError + false + end end end diff --git a/lib/exception_notifier/mattermost_notifier.rb b/lib/exception_notifier/mattermost_notifier.rb index b331b6c7..63f9ef3c 100644 --- a/lib/exception_notifier/mattermost_notifier.rb +++ b/lib/exception_notifier/mattermost_notifier.rb @@ -1,160 +1,72 @@ -require 'action_dispatch' -require 'active_support/core_ext/time' +require 'httparty' module ExceptionNotifier - class MattermostNotifier - include ExceptionNotifier::BacktraceCleaner - - attr_accessor :httparty - - def initialize(options = {}) - super() - @default_options = options - @httparty = HTTParty - end - - def call(exception, options = {}) - @options = options.merge(@default_options) + class MattermostNotifier < BaseNotifier + def call(exception, opts = {}) + options = opts.merge(base_options) @exception = exception - @backtrace = exception.backtrace ? clean_backtrace(exception) : nil - @env = @options.delete(:env) + @formatter = Formatter.new(exception, options) - @application_name = @options.delete(:app_name) || Rails.application.class.parent_name.underscore - @gitlab_url = @options.delete(:git_url) - @username = @options.delete(:username) || "Exception Notifier" - @avatar = @options.delete(:avatar) + @gitlab_url = options[:git_url] - @channel = @options.delete(:channel) - @webhook_url = @options.delete(:webhook_url) - raise ArgumentError.new "You must provide 'webhook_url' parameter." unless @webhook_url + payload = { + text: message_text.compact.join("\n"), + username: options[:username] || 'Exception Notifier' + } - unless @env.nil? - @controller = @env['action_controller.instance'] || MissingController.new + payload[:icon_url] = options[:avatar] if options[:avatar] + payload[:channel] = options[:channel] if options[:channel] - request = ActionDispatch::Request.new(@env) + httparty_options = options.except( + :avatar, :channel, :username, :git_url, :webhook_url, + :env, :accumulated_errors_count, :app_name + ) - @request_items = { url: request.original_url, - http_method: request.method, - ip_address: request.remote_ip, - parameters: request.filtered_parameters, - timestamp: Time.current } + httparty_options[:body] = payload.to_json + httparty_options[:headers] ||= {} + httparty_options[:headers]['Content-Type'] = 'application/json' - if request.session["warden.user.user.key"] - current_user = User.find(request.session["warden.user.user.key"][0][0]) - @request_items.merge!({ current_user: { id: current_user.id, email: current_user.email } }) - end - else - @controller = @request_items = nil - end - - payload = message_text.merge(user_info).merge(channel_info) - - @options[:body] = payload.to_json - @options[:headers] ||= {} - @options[:headers].merge!({ 'Content-Type' => 'application/json' }) - - @httparty.post(@webhook_url, @options) + HTTParty.post(options[:webhook_url], httparty_options) end private - def channel_info - if @channel - { channel: @channel } - else - {} - end - end - - def user_info - infos = {} - - infos.merge!({ username: @username }) if @username - infos.merge!({ icon_url: @avatar }) if @avatar - - infos - end - - def message_text - text = [] - - text += ["@channel"] - text += message_header - text += message_request if @request_items - text += message_backtrace if @backtrace - text += message_issue_link if @gitlab_url - - { text: text.join("\n") } - end - - def message_header - text = [] + attr_reader :formatter - errors_count = @options[:accumulated_errors_count].to_i - text << "### :warning: Error 500 in #{Rails.env} :warning:" - text << "#{errors_count > 1 ? errors_count : 'An'} *#{@exception.class}* occured" + if @controller then " in *#{controller_and_method}*." else "." end - text << "*#{@exception.message}*" + def message_text + text = [ + '@channel', + "### #{formatter.title}", + formatter.subtitle, + "*#{@exception.message}*" + ] - text + if (request = formatter.request_message.presence) + text << '### Request' + text << request end - def message_request - text = [] - - text << "### Request" - text << "```" - text << hash_presentation(@request_items) - text << "```" - - text + if (backtrace = formatter.backtrace_message.presence) + text << '### Backtrace' + text << backtrace end - def message_backtrace(size = 3) - text = [] - - size = @backtrace.size < size ? @backtrace.size : size - text << "### Backtrace" - text << "```" - size.times { |i| text << "* " + @backtrace[i] } - text << "```" - - text - end - - def message_issue_link - text = [] - - link = [@gitlab_url, @application_name, "issues", "new"].join("/") - params = { - "issue[title]" => ["[BUG] Error 500 :", - controller_and_method, - "(#{@exception.class})", - @exception.message].compact.join(" ") - }.to_query - - text << "[Create an issue](#{link}/?#{params})" + text << message_issue_link if @gitlab_url - text - end - - def controller_and_method - if @controller - "#{@controller.controller_name}##{@controller.action_name}" - else - "" - end - end - - def hash_presentation(hash) - text = [] - - hash.each do |key, value| - text << "* #{key} : #{value}" - end + text + end - text.join("\n") - end + def message_issue_link + link = [@gitlab_url, formatter.app_name, 'issues', 'new'].join('/') + params = { + 'issue[title]' => ['[BUG] Error 500 :', + formatter.controller_and_action || '', + "(#{@exception.class})", + @exception.message].compact.join(' ') + }.to_query + "[Create an issue](#{link}/?#{params})" + end end end diff --git a/lib/exception_notifier/modules/backtrace_cleaner.rb b/lib/exception_notifier/modules/backtrace_cleaner.rb index a2053a20..5fcbdd06 100644 --- a/lib/exception_notifier/modules/backtrace_cleaner.rb +++ b/lib/exception_notifier/modules/backtrace_cleaner.rb @@ -1,6 +1,5 @@ module ExceptionNotifier module BacktraceCleaner - def clean_backtrace(exception) if defined?(Rails) && Rails.respond_to?(:backtrace_cleaner) Rails.backtrace_cleaner.send(:filter, exception.backtrace) @@ -8,6 +7,5 @@ def clean_backtrace(exception) exception.backtrace end end - end end diff --git a/lib/exception_notifier/modules/error_grouping.rb b/lib/exception_notifier/modules/error_grouping.rb index 85dbda4c..3aefff0e 100644 --- a/lib/exception_notifier/modules/error_grouping.rb +++ b/lib/exception_notifier/modules/error_grouping.rb @@ -27,7 +27,7 @@ def fallback_cache_store def error_count(error_key) count = begin error_grouping_cache.read(error_key) - rescue => e + rescue StandardError => e ExceptionNotifier.logger.warn("#{error_grouping_cache.inspect} failed to read, reason: #{e.message}. Falling back to memory cache store.") fallback_cache_store.read(error_key) end @@ -37,7 +37,7 @@ def error_count(error_key) def save_error_count(error_key, count) error_grouping_cache.write(error_key, count, expires_in: error_grouping_period) - rescue => e + rescue StandardError => e ExceptionNotifier.logger.warn("#{error_grouping_cache.inspect} failed to write, reason: #{e.message}. Falling back to memory cache store.") fallback_cache_store.write(error_key, count, expires_in: error_grouping_period) end @@ -46,13 +46,13 @@ def group_error!(exception, options) message_based_key = "exception:#{Zlib.crc32("#{exception.class.name}\nmessage:#{exception.message}")}" accumulated_errors_count = 1 - if count = error_count(message_based_key) + if (count = error_count(message_based_key)) accumulated_errors_count = count + 1 save_error_count(message_based_key, accumulated_errors_count) else backtrace_based_key = "exception:#{Zlib.crc32("#{exception.class.name}\npath:#{exception.backtrace.try(:first)}")}" - if count = Rails.cache.read(backtrace_based_key) + if (count = error_grouping_cache.read(backtrace_based_key)) accumulated_errors_count = count + 1 save_error_count(backtrace_based_key, accumulated_errors_count) else @@ -74,4 +74,4 @@ def send_notification?(exception, count) end end end -end \ No newline at end of file +end diff --git a/lib/exception_notifier/modules/formatter.rb b/lib/exception_notifier/modules/formatter.rb new file mode 100644 index 00000000..a583745b --- /dev/null +++ b/lib/exception_notifier/modules/formatter.rb @@ -0,0 +1,118 @@ +require 'active_support/core_ext/time' +require 'action_dispatch' + +module ExceptionNotifier + class Formatter + include ExceptionNotifier::BacktraceCleaner + + attr_reader :app_name + + def initialize(exception, opts = {}) + @exception = exception + + @env = opts[:env] + @errors_count = opts[:accumulated_errors_count].to_i + @app_name = opts[:app_name] || rails_app_name + end + + # + # :warning: Error occurred in production :warning: + # :warning: Error occurred :warning: + # + def title + env = Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env) + + if env + "⚠️ Error occurred in #{env} ⚠️" + else + '⚠️ Error occurred ⚠️' + end + end + + # + # A *NoMethodError* occurred. + # 3 *NoMethodError* occurred. + # A *NoMethodError* occurred in *home#index*. + # + def subtitle + errors_text = if errors_count > 1 + errors_count + else + exception.class.to_s =~ /^[aeiou]/i ? 'An' : 'A' + end + + in_action = " in *#{controller_and_action}*" if controller + + "#{errors_text} *#{exception.class}* occurred#{in_action}." + end + + # + # + # *Request:* + # ``` + # * url : https://www.example.com/ + # * http_method : GET + # * ip_address : 127.0.0.1 + # * parameters : {"controller"=>"home", "action"=>"index"} + # * timestamp : 2019-01-01 00:00:00 UTC + # ``` + # + def request_message + request = ActionDispatch::Request.new(env) if env + return unless request + + [ + '```', + "* url : #{request.original_url}", + "* http_method : #{request.method}", + "* ip_address : #{request.remote_ip}", + "* parameters : #{request.filtered_parameters}", + "* timestamp : #{Time.current}", + '```' + ].join("\n") + end + + # + # + # *Backtrace:* + # ``` + # * app/controllers/my_controller.rb:99:in `specific_function' + # * app/controllers/my_controller.rb:70:in `specific_param' + # * app/controllers/my_controller.rb:53:in `my_controller_params' + # ``` + # + def backtrace_message + backtrace = exception.backtrace ? clean_backtrace(exception) : nil + + return unless backtrace + + text = [] + + text << '```' + backtrace.first(3).each { |line| text << "* #{line}" } + text << '```' + + text.join("\n") + end + + # + # home#index + # + def controller_and_action + "#{controller.controller_name}##{controller.action_name}" if controller + end + + private + + attr_reader :exception, :env, :errors_count + + def rails_app_name + return unless defined?(::Rails) && ::Rails.respond_to?(:application) + Rails.application.class.parent_name.underscore + end + + def controller + env['action_controller.instance'] if env + end + end +end diff --git a/lib/exception_notifier/notifier.rb b/lib/exception_notifier/notifier.rb index 5a468dc7..6ac829fa 100644 --- a/lib/exception_notifier/notifier.rb +++ b/lib/exception_notifier/notifier.rb @@ -2,14 +2,13 @@ module ExceptionNotifier class Notifier - - def self.exception_notification(env, exception, options={}) - ActiveSupport::Deprecation.warn "Please use ExceptionNotifier.notify_exception(exception, options.merge(:env => env))." - ExceptionNotifier.registered_exception_notifier(:email).create_email(exception, options.merge(:env => env)) + def self.exception_notification(env, exception, options = {}) + ActiveSupport::Deprecation.warn 'Please use ExceptionNotifier.notify_exception(exception, options.merge(env: env)).' + ExceptionNotifier.registered_exception_notifier(:email).create_email(exception, options.merge(env: env)) end - def self.background_exception_notification(exception, options={}) - ActiveSupport::Deprecation.warn "Please use ExceptionNotifier.notify_exception(exception, options)." + def self.background_exception_notification(exception, options = {}) + ActiveSupport::Deprecation.warn 'Please use ExceptionNotifier.notify_exception(exception, options).' ExceptionNotifier.registered_exception_notifier(:email).create_email(exception, options) end end diff --git a/lib/exception_notifier/slack_notifier.rb b/lib/exception_notifier/slack_notifier.rb index 6f6447e2..8b03e512 100644 --- a/lib/exception_notifier/slack_notifier.rb +++ b/lib/exception_notifier/slack_notifier.rb @@ -15,21 +15,65 @@ def initialize(options) @message_opts = options.fetch(:additional_parameters, {}) @color = @message_opts.delete(:color) { 'danger' } @notifier = Slack::Notifier.new webhook_url, options - rescue + rescue StandardError @notifier = nil end end - def call(exception, options={}) + def call(exception, options = {}) + clean_message = exception.message.tr('`', "'") + attchs = attchs(exception, clean_message, options) + + return unless valid? + + args = [exception, options, clean_message, @message_opts.merge(attachments: attchs)] + send_notice(*args) do |_msg, message_opts| + message_opts[:channel] = options[:channel] if options.key?(:channel) + + @notifier.ping '', message_opts + end + end + + protected + + def valid? + !@notifier.nil? + end + + def deep_reject(hash, block) + hash.each do |k, v| + deep_reject(v, block) if v.is_a?(Hash) + + hash.delete(k) if block.call(k, v) + end + end + + private + + def attchs(exception, clean_message, options) + text, data = information_from_options(exception.class, options) + backtrace = clean_backtrace(exception) if exception.backtrace + fields = fields(clean_message, backtrace, data) + + [color: @color, text: text, fields: fields, mrkdwn_in: %w[text fields]] + end + + def information_from_options(exception_class, options) errors_count = options[:accumulated_errors_count].to_i - measure_word = errors_count > 1 ? errors_count : (exception.class.to_s =~ /^[aeiou]/i ? 'An' : 'A') - exception_name = "*#{measure_word}* `#{exception.class.to_s}`" - if options[:env].nil? + measure_word = if errors_count > 1 + errors_count + else + exception_class.to_s =~ /^[aeiou]/i ? 'An' : 'A' + end + + exception_name = "*#{measure_word}* `#{exception_class}`" + env = options[:env] + + if env.nil? data = options[:data] || {} text = "#{exception_name} *occured in background*\n" else - env = options[:env] data = (env['exception_notifier.exception_data'] || {}).merge(options[:data] || {}) kontroller = env['action_controller.instance'] @@ -39,50 +83,29 @@ def call(exception, options={}) text += "\n" end - clean_message = exception.message.gsub("`", "'") - fields = [ { title: 'Exception', value: clean_message } ] + [text, data] + end - fields.push({ title: 'Hostname', value: Socket.gethostname }) + def fields(clean_message, backtrace, data) + fields = [ + { title: 'Exception', value: clean_message }, + { title: 'Hostname', value: Socket.gethostname } + ] - if exception.backtrace - formatted_backtrace = "```#{exception.backtrace.first(@backtrace_lines).join("\n")}```" - fields.push({ title: 'Backtrace', value: formatted_backtrace }) + if backtrace + formatted_backtrace = "```#{backtrace.first(@backtrace_lines).join("\n")}```" + fields << { title: 'Backtrace', value: formatted_backtrace } end unless data.empty? deep_reject(data, @ignore_data_if) if @ignore_data_if.is_a?(Proc) - data_string = data.map{|k,v| "#{k}: #{v}"}.join("\n") - fields.push({ title: 'Data', value: "```#{data_string}```" }) + data_string = data.map { |k, v| "#{k}: #{v}" }.join("\n") + fields << { title: 'Data', value: "```#{data_string}```" } end fields.concat(@additional_fields) if @additional_fields - attchs = [color: @color, text: text, fields: fields, mrkdwn_in: %w(text fields)] - - if valid? - send_notice(exception, options, clean_message, @message_opts.merge(attachments: attchs)) do |msg, message_opts| - @notifier.ping '', message_opts - end - end - end - - protected - - def valid? - !@notifier.nil? - end - - def deep_reject(hash, block) - hash.each do |k, v| - if v.is_a?(Hash) - deep_reject(v, block) - end - - if block.call(k, v) - hash.delete(k) - end - end + fields end - end end diff --git a/lib/exception_notifier/sns_notifier.rb b/lib/exception_notifier/sns_notifier.rb new file mode 100644 index 00000000..d40ab6e3 --- /dev/null +++ b/lib/exception_notifier/sns_notifier.rb @@ -0,0 +1,85 @@ +module ExceptionNotifier + class SnsNotifier < BaseNotifier + def initialize(options) + super + + raise ArgumentError, "You must provide 'region' option" unless options[:region] + raise ArgumentError, "You must provide 'access_key_id' option" unless options[:access_key_id] + raise ArgumentError, "You must provide 'secret_access_key' option" unless options[:secret_access_key] + + @notifier = Aws::SNS::Client.new( + region: options[:region], + access_key_id: options[:access_key_id], + secret_access_key: options[:secret_access_key] + ) + @options = default_options.merge(options) + end + + def call(exception, custom_opts = {}) + custom_options = options.merge(custom_opts) + + subject = build_subject(exception, custom_options) + message = build_message(exception, custom_options) + + notifier.publish( + topic_arn: custom_options[:topic_arn], + message: message, + subject: subject + ) + end + + private + + attr_reader :notifier, :options + + def build_subject(exception, options) + subject = "#{options[:sns_prefix]} - " + subject << accumulated_exception_name(exception, options) + subject << ' occurred' + subject.length > 120 ? subject[0...120] + '...' : subject + end + + def build_message(exception, options) + exception_name = accumulated_exception_name(exception, options) + + if options[:env].nil? + text = "#{exception_name} occured in background\n" + else + env = options[:env] + + kontroller = env['action_controller.instance'] + request = "#{env['REQUEST_METHOD']} <#{env['REQUEST_URI']}>" + + text = "#{exception_name} occurred while #{request}" + text += " was processed by #{kontroller.controller_name}##{kontroller.action_name}\n" if kontroller + end + + text += "Exception: #{exception.message}\n" + text += "Hostname: #{Socket.gethostname}\n" + + return unless exception.backtrace + + formatted_backtrace = exception.backtrace.first(options[:backtrace_lines]).join("\n").to_s + text + "Backtrace:\n#{formatted_backtrace}\n" + end + + def accumulated_exception_name(exception, options) + errors_count = options[:accumulated_errors_count].to_i + + measure_word = if errors_count > 1 + errors_count + else + exception.class.to_s =~ /^[aeiou]/i ? 'An' : 'A' + end + + "#{measure_word} #{exception.class}" + end + + def default_options + { + sns_prefix: '[ERROR]', + backtrace_lines: 10 + } + end + end +end diff --git a/lib/exception_notifier/teams_notifier.rb b/lib/exception_notifier/teams_notifier.rb new file mode 100644 index 00000000..e32cdd42 --- /dev/null +++ b/lib/exception_notifier/teams_notifier.rb @@ -0,0 +1,193 @@ +require 'action_dispatch' +require 'active_support/core_ext/time' +require 'json' + +module ExceptionNotifier + class TeamsNotifier < BaseNotifier + include ExceptionNotifier::BacktraceCleaner + + class MissingController + def method_missing(*args, &block); end + end + + attr_accessor :httparty + + def initialize(options = {}) + super + @default_options = options + @httparty = HTTParty + end + + def call(exception, options = {}) + @options = options.merge(@default_options) + @exception = exception + @backtrace = exception.backtrace ? clean_backtrace(exception) : nil + + @env = @options.delete(:env) + + @application_name = @options.delete(:app_name) || rails_app_name + @gitlab_url = @options.delete(:git_url) + @jira_url = @options.delete(:jira_url) + + @webhook_url = @options.delete(:webhook_url) + raise ArgumentError, "You must provide 'webhook_url' parameter." unless @webhook_url + + if @env.nil? + @controller = @request_items = nil + else + @controller = @env['action_controller.instance'] || MissingController.new + + request = ActionDispatch::Request.new(@env) + + @request_items = { url: request.original_url, + http_method: request.method, + ip_address: request.remote_ip, + parameters: request.filtered_parameters, + timestamp: Time.current } + + if request.session['warden.user.user.key'] + current_user = User.find(request.session['warden.user.user.key'][0][0]) + @request_items[:current_user] = { id: current_user.id, email: current_user.email } + end + end + + payload = message_text + + @options[:body] = payload.to_json + @options[:headers] ||= {} + @options[:headers]['Content-Type'] = 'application/json' + @options[:debug_output] = $stdout + + @httparty.post(@webhook_url, @options) + end + + private + + def message_text + errors_count = @options[:accumulated_errors_count].to_i + + text = { + '@type' => 'MessageCard', + '@context' => 'http://schema.org/extensions', + 'summary' => "#{@application_name} Exception Alert", + 'title' => "⚠️ Exception Occurred in #{env_name} ⚠️", + 'sections' => [ + { + 'activityTitle' => "#{errors_count > 1 ? errors_count : 'A'} *#{@exception.class}* occurred" + (@controller ? " in *#{controller_and_method}*." : '.'), + 'activitySubtitle' => @exception.message.to_s + } + ], + 'potentialAction' => [] + } + + text['sections'].push details + text['potentialAction'].push gitlab_view_link unless @gitlab_url.nil? + text['potentialAction'].push gitlab_issue_link unless @gitlab_url.nil? + text['potentialAction'].push jira_issue_link unless @jira_url.nil? + + text + end + + def details + details = { + 'title' => 'Details', + 'facts' => [] + } + + details['facts'].push message_request unless @request_items.nil? + details['facts'].push message_backtrace unless @backtrace.nil? + + details + end + + def message_request + { + 'name' => 'Request', + 'value' => "#{hash_presentation(@request_items)}\n " + } + end + + def message_backtrace(size = 3) + text = [] + size = @backtrace.size < size ? @backtrace.size : size + text << '```' + size.times { |i| text << '* ' + @backtrace[i] } + text << '```' + + { + 'name' => 'Backtrace', + 'value' => text.join(" \n").to_s + } + end + + def gitlab_view_link + { + '@type' => 'ViewAction', + 'name' => "\u{1F98A} View in GitLab", + 'target' => [ + "#{@gitlab_url}/#{@application_name}" + ] + } + end + + def gitlab_issue_link + link = [@gitlab_url, @application_name, 'issues', 'new'].join('/') + params = { + 'issue[title]' => ['[BUG] Error 500 :', + controller_and_method, + "(#{@exception.class})", + @exception.message].compact.join(' ') + }.to_query + + { + '@type' => 'ViewAction', + 'name' => "\u{1F98A} Create Issue in GitLab", + 'target' => [ + "#{link}/?#{params}" + ] + } + end + + def jira_issue_link + { + '@type' => 'ViewAction', + 'name' => '🐞 Create Issue in Jira', + 'target' => [ + "#{@jira_url}/secure/CreateIssue!default.jspa" + ] + } + end + + def controller_and_method + if @controller + "#{@controller.controller_name}##{@controller.action_name}" + else + '' + end + end + + def hash_presentation(hash) + text = [] + + hash.each do |key, value| + text << "* **#{key}** : `#{value}`" + end + + text.join(" \n") + end + + def rails_app_name + return unless defined?(Rails) && Rails.respond_to?(:application) + + if ::Gem::Version.new(Rails.version) >= ::Gem::Version.new('6.0') + Rails.application.class.module_parent_name.underscore + else + Rails.application.class.parent_name.underscore + end + end + + def env_name + Rails.env if defined?(Rails) && Rails.respond_to?(:env) + end + end +end diff --git a/lib/exception_notifier/views/exception_notifier/_backtrace.html.erb b/lib/exception_notifier/views/exception_notifier/_backtrace.html.erb index 5a4fa966..c4f68ae0 100644 --- a/lib/exception_notifier/views/exception_notifier/_backtrace.html.erb +++ b/lib/exception_notifier/views/exception_notifier/_backtrace.html.erb @@ -1,3 +1,3 @@
-<%= @backtrace.join("\n") %>
+  <%= @backtrace.join("\n") %>
 
diff --git a/lib/exception_notifier/views/exception_notifier/_environment.text.erb b/lib/exception_notifier/views/exception_notifier/_environment.text.erb index a2db7cbc..cf92a1b8 100644 --- a/lib/exception_notifier/views/exception_notifier/_environment.text.erb +++ b/lib/exception_notifier/views/exception_notifier/_environment.text.erb @@ -1,5 +1,5 @@ <% filtered_env = @request.filtered_env -%> <% max = filtered_env.keys.map(&:to_s).max { |a, b| a.length <=> b.length } -%> <% filtered_env.keys.map(&:to_s).sort.each do |key| -%> -* <%= raw safe_encode("%-*s: %s" % [max.length, key, inspect_object(filtered_env[key])]) %> + * <%= raw safe_encode("%-*s: %s" % [max.length, key, inspect_object(filtered_env[key])]).strip %> <% end -%> diff --git a/lib/exception_notifier/views/exception_notifier/_request.text.erb b/lib/exception_notifier/views/exception_notifier/_request.text.erb index 4150d84b..b06ef736 100644 --- a/lib/exception_notifier/views/exception_notifier/_request.text.erb +++ b/lib/exception_notifier/views/exception_notifier/_request.text.erb @@ -5,6 +5,6 @@ * Timestamp : <%= raw @timestamp %> * Server : <%= raw Socket.gethostname %> <% if defined?(Rails) && Rails.respond_to?(:root) %> -* Rails root : <%= raw Rails.root %> + * Rails root : <%= raw Rails.root %> <% end %> * Process: <%= raw $$ %> diff --git a/lib/exception_notifier/views/exception_notifier/background_exception_notification.text.erb b/lib/exception_notifier/views/exception_notifier/background_exception_notification.text.erb index f484f6fd..7428de0e 100644 --- a/lib/exception_notifier/views/exception_notifier/background_exception_notification.text.erb +++ b/lib/exception_notifier/views/exception_notifier/background_exception_notification.text.erb @@ -3,12 +3,12 @@ <%= @exception.message %> <%= @backtrace.first %> - <% sections = @sections.map do |section| - summary = render(section).strip - unless summary.blank? - title = render("title", :title => section).strip - "#{title}\n\n#{summary.gsub(/^/, " ")}\n\n" - end - end.join - %> - <%= raw sections %> +<% sections = @sections.map do |section| + summary = render(section).strip + unless summary.blank? + title = render("title", :title => section).strip + "#{title}\n\n#{summary.gsub(/^/, " ")}\n\n" + end + end.join +%> +<%= raw sections %> diff --git a/lib/exception_notifier/views/exception_notifier/exception_notification.html.erb b/lib/exception_notifier/views/exception_notifier/exception_notification.html.erb index b4b9e82b..3e050ae2 100644 --- a/lib/exception_notifier/views/exception_notifier/exception_notification.html.erb +++ b/lib/exception_notifier/views/exception_notifier/exception_notification.html.erb @@ -11,11 +11,11 @@ begin summary = render(section).strip unless summary.blank? - title = render("title", :title => section).strip + title = render("title", title: section).strip [title, summary] end rescue Exception => e - title = render("title", :title => section).strip + title = render("title", title: section).strip summary = ["ERROR: Failed to generate exception summary:", [e.class.to_s, e.message].join(": "), e.backtrace && e.backtrace.join("\n")].compact.join("\n\n") [title, summary] end diff --git a/lib/exception_notifier/views/exception_notifier/exception_notification.text.erb b/lib/exception_notifier/views/exception_notifier/exception_notification.text.erb index cc8d4dc6..09f70868 100644 --- a/lib/exception_notifier/views/exception_notifier/exception_notification.text.erb +++ b/lib/exception_notifier/views/exception_notifier/exception_notification.text.erb @@ -8,12 +8,12 @@ begin summary = render(section).strip unless summary.blank? - title = render("title", :title => section).strip + title = render("title", title: section).strip "#{title}\n\n#{summary.gsub(/^/, " ")}\n\n" end rescue Exception => e - title = render("title", :title => section).strip + title = render("title", title: section).strip summary = ["ERROR: Failed to generate exception summary:", [e.class.to_s, e.message].join(": "), e.backtrace && e.backtrace.join("\n")].compact.join("\n\n") [title, summary.gsub(/^/, " "), nil].join("\n\n") diff --git a/lib/exception_notifier/webhook_notifier.rb b/lib/exception_notifier/webhook_notifier.rb index 7573cf56..76e949b7 100644 --- a/lib/exception_notifier/webhook_notifier.rb +++ b/lib/exception_notifier/webhook_notifier.rb @@ -3,13 +3,12 @@ module ExceptionNotifier class WebhookNotifier < BaseNotifier - def initialize(options) super @default_options = options end - def call(exception, options={}) + def call(exception, options = {}) env = options[:env] options = options.reverse_merge(@default_options) @@ -18,23 +17,27 @@ def call(exception, options={}) options[:body] ||= {} options[:body][:server] = Socket.gethostname - options[:body][:process] = $$ + options[:body][:process] = $PROCESS_ID if defined?(Rails) && Rails.respond_to?(:root) options[:body][:rails_root] = Rails.root end - options[:body][:exception] = {:error_class => exception.class.to_s, - :message => exception.message.inspect, - :backtrace => exception.backtrace} + options[:body][:exception] = { + error_class: exception.class.to_s, + message: exception.message.inspect, + backtrace: exception.backtrace + } options[:body][:data] = (env && env['exception_notifier.exception_data'] || {}).merge(options[:data] || {}) unless env.nil? request = ActionDispatch::Request.new(env) - request_items = {:url => request.original_url, - :http_method => request.method, - :ip_address => request.remote_ip, - :parameters => request.filtered_parameters, - :timestamp => Time.current } + request_items = { + url: request.original_url, + http_method: request.method, + ip_address: request.remote_ip, + parameters: request.filtered_parameters, + timestamp: Time.current + } options[:body][:request] = request_items options[:body][:session] = request.session diff --git a/lib/generators/exception_notification/install_generator.rb b/lib/generators/exception_notification/install_generator.rb index 81fdcc57..52d5b2f0 100644 --- a/lib/generators/exception_notification/install_generator.rb +++ b/lib/generators/exception_notification/install_generator.rb @@ -1,14 +1,14 @@ module ExceptionNotification module Generators class InstallGenerator < Rails::Generators::Base - desc "Creates a ExceptionNotification initializer." + desc 'Creates a ExceptionNotification initializer.' - source_root File.expand_path('../templates', __FILE__) - class_option :resque, :type => :boolean, :desc => 'Add support for sending notifications when errors occur in Resque jobs.' - class_option :sidekiq, :type => :boolean, :desc => 'Add support for sending notifications when errors occur in Sidekiq jobs.' + source_root File.expand_path('templates', __dir__) + class_option :resque, type: :boolean, desc: 'Add support for sending notifications when errors occur in Resque jobs.' + class_option :sidekiq, type: :boolean, desc: 'Add support for sending notifications when errors occur in Sidekiq jobs.' def copy_initializer - template 'exception_notification.rb', 'config/initializers/exception_notification.rb' + template 'exception_notification.rb.erb', 'config/initializers/exception_notification.rb' end end end diff --git a/lib/generators/exception_notification/templates/exception_notification.rb b/lib/generators/exception_notification/templates/exception_notification.rb.erb similarity index 77% rename from lib/generators/exception_notification/templates/exception_notification.rb rename to lib/generators/exception_notification/templates/exception_notification.rb.erb index 1e932a80..964c8e5e 100644 --- a/lib/generators/exception_notification/templates/exception_notification.rb +++ b/lib/generators/exception_notification/templates/exception_notification.rb.erb @@ -22,32 +22,34 @@ # not Rails.env.production? # end + # Ignore exceptions generated by crawlers + # config.ignore_crawlers %w{Googlebot bingbot} + # Notifiers ================================================================= # Email notifier sends notifications by email. config.add_notifier :email, { - :email_prefix => "[ERROR] ", - :sender_address => %{"Notifier" }, - :exception_recipients => %w{exceptions@example.com} + email_prefix: '[ERROR] ', + sender_address: %{"Notifier" }, + exception_recipients: %w{exceptions@example.com} } # Campfire notifier sends notifications to your Campfire room. Requires 'tinder' gem. # config.add_notifier :campfire, { - # :subdomain => 'my_subdomain', - # :token => 'my_token', - # :room_name => 'my_room' + # subdomain: 'my_subdomain', + # token: 'my_token', + # room_name: 'my_room' # } # HipChat notifier sends notifications to your HipChat room. Requires 'hipchat' gem. # config.add_notifier :hipchat, { - # :api_token => 'my_token', - # :room_name => 'my_room' + # api_token: 'my_token', + # room_name: 'my_room' # } # Webhook notifier sends notifications over HTTP protocol. Requires 'httparty' gem. # config.add_notifier :webhook, { - # :url => 'http://example.com:5555/hubot/path', - # :http_method => :post + # url: 'http://example.com:5555/hubot/path', + # http_method: :post # } - end diff --git a/test/dummy/.gitignore b/test/dummy/.gitignore deleted file mode 100644 index f0fa30c5..00000000 --- a/test/dummy/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.bundle -db/*.sqlite3 -log/*.log -tmp/ diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile deleted file mode 100644 index 9724472e..00000000 --- a/test/dummy/Rakefile +++ /dev/null @@ -1,7 +0,0 @@ -# Add your own tasks in files placed in lib/tasks ending in .rake, -# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. - -require File.expand_path('../config/application', __FILE__) -require 'rake' - -Dummy::Application.load_tasks diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb deleted file mode 100644 index e8065d95..00000000 --- a/test/dummy/app/controllers/application_controller.rb +++ /dev/null @@ -1,3 +0,0 @@ -class ApplicationController < ActionController::Base - protect_from_forgery -end diff --git a/test/dummy/app/controllers/posts_controller.rb b/test/dummy/app/controllers/posts_controller.rb deleted file mode 100644 index 8fa192aa..00000000 --- a/test/dummy/app/controllers/posts_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -class PostsController < ApplicationController - # GET /posts/1 - # GET /posts/1.xml - def show - @post = Post.find(params[:id]) - - respond_to do |format| - format.html # show.html.erb - format.xml { render :xml => @post } - end - end - - # POST /posts - # POST /posts.xml - def create - @sections = Object.new - # Have this line raise an exception - @post = Post.nw(params[:post]) - - respond_to do |format| - if @post.save - format.html { redirect_to(post_path(@post), :notice => 'Post was successfully created.') } - format.xml { render :xml => @post, :status => :created, :location => @post } - else - format.html { render :action => "new" } - format.xml { render :xml => @post.errors, :status => :unprocessable_entity } - end - end - end -end diff --git a/test/dummy/app/helpers/application_helper.rb b/test/dummy/app/helpers/application_helper.rb deleted file mode 100644 index de6be794..00000000 --- a/test/dummy/app/helpers/application_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module ApplicationHelper -end diff --git a/test/dummy/app/helpers/posts_helper.rb b/test/dummy/app/helpers/posts_helper.rb deleted file mode 100644 index a7b8cec8..00000000 --- a/test/dummy/app/helpers/posts_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module PostsHelper -end diff --git a/test/dummy/app/models/post.rb b/test/dummy/app/models/post.rb deleted file mode 100644 index 791dcb56..00000000 --- a/test/dummy/app/models/post.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Post < ActiveRecord::Base -end diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb deleted file mode 100644 index a3b5a225..00000000 --- a/test/dummy/app/views/layouts/application.html.erb +++ /dev/null @@ -1,14 +0,0 @@ - - - - Dummy - <%= stylesheet_link_tag :all %> - <%= javascript_include_tag :defaults %> - <%= csrf_meta_tag %> - - - -<%= yield %> - - - diff --git a/test/dummy/app/views/posts/_form.html.erb b/test/dummy/app/views/posts/_form.html.erb deleted file mode 100644 index e69de29b..00000000 diff --git a/test/dummy/app/views/posts/new.html.erb b/test/dummy/app/views/posts/new.html.erb deleted file mode 100644 index e69de29b..00000000 diff --git a/test/dummy/app/views/posts/show.html.erb b/test/dummy/app/views/posts/show.html.erb deleted file mode 100644 index e69de29b..00000000 diff --git a/test/dummy/config.ru b/test/dummy/config.ru deleted file mode 100644 index 1989ed8d..00000000 --- a/test/dummy/config.ru +++ /dev/null @@ -1,4 +0,0 @@ -# This file is used by Rack-based servers to start the application. - -require ::File.expand_path('../config/environment', __FILE__) -run Dummy::Application diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb deleted file mode 100644 index 989b0789..00000000 --- a/test/dummy/config/application.rb +++ /dev/null @@ -1,42 +0,0 @@ -require File.expand_path('../boot', __FILE__) - -require 'rails/all' - -# If you have a Gemfile, require the gems listed there, including any gems -# you've limited to :test, :development, or :production. -Bundler.require(:default, Rails.env) if defined?(Bundler) - -module Dummy - class Application < Rails::Application - # Settings in config/environments/* take precedence over those specified here. - # Application configuration should go into files in config/initializers - # -- all .rb files in that directory are automatically loaded. - - # Custom directories with classes and modules you want to be autoloadable. - # config.autoload_paths += %W(#{config.root}/extras) - - # Only load the plugins named here, in the order given (default is alphabetical). - # :all can be used as a placeholder for all plugins not explicitly named. - # config.plugins = [ :exception_notification, :ssl_requirement, :all ] - - # Activate observers that should always be running. - # config.active_record.observers = :cacher, :garbage_collector, :forum_observer - - # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. - # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. - # config.time_zone = 'Central Time (US & Canada)' - - # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. - # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] - # config.i18n.default_locale = :de - - # JavaScript files you want as :defaults (application.js is always included). - # config.action_view.javascript_expansions[:defaults] = %w(jquery rails) - - # Configure the default encoding used in templates for Ruby 1.9. - config.encoding = "utf-8" - - # Configure sensitive parameters which will be filtered from the log file. - config.filter_parameters += [:password, :secret] - end -end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb deleted file mode 100644 index f2830ae3..00000000 --- a/test/dummy/config/boot.rb +++ /dev/null @@ -1,6 +0,0 @@ -require 'rubygems' - -# Set up gems listed in the Gemfile. -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) - -require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml deleted file mode 100644 index 90d87cc2..00000000 --- a/test/dummy/config/database.yml +++ /dev/null @@ -1,22 +0,0 @@ -# SQLite version 3.x -# gem install sqlite3 -development: - adapter: sqlite3 - database: db/development.sqlite3 - pool: 5 - timeout: 5000 - -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. -test: - adapter: sqlite3 - database: db/test.sqlite3 - pool: 5 - timeout: 5000 - -production: - adapter: sqlite3 - database: db/production.sqlite3 - pool: 5 - timeout: 5000 diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb deleted file mode 100644 index 843b60c6..00000000 --- a/test/dummy/config/environment.rb +++ /dev/null @@ -1,17 +0,0 @@ -# Load the rails application -require File.expand_path('../application', __FILE__) - -Dummy::Application.config.middleware.use ExceptionNotification::Rack, - :email => { - :email_prefix => "[Dummy ERROR] ", - :sender_address => %{"Dummy Notifier" }, - :exception_recipients => %w{dummyexceptions@example.com}, - :email_headers => { "X-Custom-Header" => "foobar" }, - :sections => ['new_section', 'request', 'session', 'environment', 'backtrace'], - :background_sections => %w(new_bkg_section backtrace data), - :pre_callback => proc { |opts, notifier, backtrace, message, message_opts| message_opts[:pre_callback_called] = 1 }, - :post_callback => proc { |opts, notifier, backtrace, message, message_opts| message_opts[:post_callback_called] = 1 } - } - -# Initialize the rails application -Dummy::Application.initialize! diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb deleted file mode 100644 index afc8d99d..00000000 --- a/test/dummy/config/environments/development.rb +++ /dev/null @@ -1,25 +0,0 @@ -Dummy::Application.configure do - # Settings specified here will take precedence over those in config/application.rb - - config.eager_load = false - # In the development environment your application's code is reloaded on - # every request. This slows down response time but is perfect for development - # since you don't have to restart the webserver when you make code changes. - config.cache_classes = false - - # Log error messages when you accidentally call methods on nil. - config.whiny_nils = true - - # Show full error reports and disable caching - config.consider_all_requests_local = true - config.action_controller.perform_caching = false - - # Don't care if the mailer can't send - config.action_mailer.raise_delivery_errors = false - - # Print deprecation notices to the Rails logger - config.active_support.deprecation = :log - - # Only use best-standards-support built into browsers - config.action_dispatch.best_standards_support = :builtin -end diff --git a/test/dummy/config/environments/production.rb b/test/dummy/config/environments/production.rb deleted file mode 100644 index e758d3d0..00000000 --- a/test/dummy/config/environments/production.rb +++ /dev/null @@ -1,50 +0,0 @@ -Dummy::Application.configure do - # Settings specified here will take precedence over those in config/application.rb - - config.eager_load = true - # The production environment is meant for finished, "live" apps. - # Code is not reloaded between requests - config.cache_classes = true - - # Full error reports are disabled and caching is turned on - config.consider_all_requests_local = false - config.action_controller.perform_caching = true - - # Specifies the header that your server uses for sending files - config.action_dispatch.x_sendfile_header = "X-Sendfile" - - # For nginx: - # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' - - # If you have no front-end server that supports something like X-Sendfile, - # just comment this out and Rails will serve the files - - # See everything in the log (default is :info) - # config.log_level = :debug - - # Use a different logger for distributed setups - # config.logger = SyslogLogger.new - - # Use a different cache store in production - # config.cache_store = :mem_cache_store - - # Disable Rails's static asset server - # In production, Apache or nginx will already do this - config.serve_static_assets = false - - # Enable serving of images, stylesheets, and javascripts from an asset server - # config.action_controller.asset_host = "http://assets.example.com" - - # Disable delivery errors, bad email addresses will be ignored - # config.action_mailer.raise_delivery_errors = false - - # Enable threaded mode - # config.threadsafe! - - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation can not be found) - config.i18n.fallbacks = true - - # Send deprecation notices to registered listeners - config.active_support.deprecation = :notify -end diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb deleted file mode 100644 index 40fe27d5..00000000 --- a/test/dummy/config/environments/test.rb +++ /dev/null @@ -1,35 +0,0 @@ -Dummy::Application.configure do - # Settings specified here will take precedence over those in config/application.rb - - config.eager_load = false - # The test environment is used exclusively to run your application's - # test suite. You never need to work with it otherwise. Remember that - # your test database is "scratch space" for the test suite and is wiped - # and recreated between test runs. Don't rely on the data there! - config.cache_classes = true - - # Show full error reports and disable caching - config.consider_all_requests_local = true - config.action_controller.perform_caching = false - - # Raise exceptions instead of rendering exception templates - config.action_dispatch.show_exceptions = false - - # Disable request forgery protection in test environment - config.action_controller.allow_forgery_protection = false - - # Tell Action Mailer not to deliver emails to the real world. - # The :test delivery method accumulates sent emails in the - # ActionMailer::Base.deliveries array. - config.action_mailer.delivery_method = :test - - # Use SQL instead of Active Record's schema dumper when creating the test database. - # This is necessary if your schema can't be completely dumped by the schema dumper, - # like if you have constraints or database-specific column types - # config.active_record.schema_format = :sql - - # Print deprecation notices to the stderr - config.active_support.deprecation = :stderr - - config.active_support.test_order = :random -end diff --git a/test/dummy/config/initializers/backtrace_silencers.rb b/test/dummy/config/initializers/backtrace_silencers.rb deleted file mode 100644 index 59385cdf..00000000 --- a/test/dummy/config/initializers/backtrace_silencers.rb +++ /dev/null @@ -1,7 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. -# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } - -# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. -# Rails.backtrace_cleaner.remove_silencers! diff --git a/test/dummy/config/initializers/inflections.rb b/test/dummy/config/initializers/inflections.rb deleted file mode 100644 index 9e8b0131..00000000 --- a/test/dummy/config/initializers/inflections.rb +++ /dev/null @@ -1,10 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Add new inflection rules using the following format -# (all these examples are active by default): -# ActiveSupport::Inflector.inflections do |inflect| -# inflect.plural /^(ox)$/i, '\1en' -# inflect.singular /^(ox)en/i, '\1' -# inflect.irregular 'person', 'people' -# inflect.uncountable %w( fish sheep ) -# end diff --git a/test/dummy/config/initializers/mime_types.rb b/test/dummy/config/initializers/mime_types.rb deleted file mode 100644 index 72aca7e4..00000000 --- a/test/dummy/config/initializers/mime_types.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Add new mime types for use in respond_to blocks: -# Mime::Type.register "text/richtext", :rtf -# Mime::Type.register_alias "text/html", :iphone diff --git a/test/dummy/config/initializers/secret_token.rb b/test/dummy/config/initializers/secret_token.rb deleted file mode 100644 index ff5c7bfc..00000000 --- a/test/dummy/config/initializers/secret_token.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Your secret key for verifying the integrity of signed cookies. -# If you change this key, all old signed cookies will become invalid! -# Make sure the secret is at least 30 characters and all random, -# no regular words or you'll be exposed to dictionary attacks. -Dummy::Application.config.secret_token = 'cfdf538142b0b383e722e8e7ea839b8ce6c3dc94a57856b343a2d13be66f5b690a55c991cec6e98ed60ea9b7e58265af23cb40cbadee02f13f1c45c2625f482b' -Dummy::Application.config.secret_key_base = 'my new secret' diff --git a/test/dummy/config/initializers/session_store.rb b/test/dummy/config/initializers/session_store.rb deleted file mode 100644 index aa2f5129..00000000 --- a/test/dummy/config/initializers/session_store.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Be sure to restart your server when you modify this file. - -Dummy::Application.config.session_store :cookie_store, :key => '_dummy_session' - -# Use the database for sessions instead of the cookie-based default, -# which shouldn't be used to store highly confidential information -# (create the session table with "rails generate session_migration") -# Dummy::Application.config.session_store :active_record_store diff --git a/test/dummy/config/locales/en.yml b/test/dummy/config/locales/en.yml deleted file mode 100644 index a747bfa6..00000000 --- a/test/dummy/config/locales/en.yml +++ /dev/null @@ -1,5 +0,0 @@ -# Sample localization file for English. Add more files in this directory for other locales. -# See http://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. - -en: - hello: "Hello world" diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb deleted file mode 100644 index 7588f3bf..00000000 --- a/test/dummy/config/routes.rb +++ /dev/null @@ -1,3 +0,0 @@ -Dummy::Application.routes.draw do - resources :posts, :only => [:create, :show] -end diff --git a/test/dummy/db/migrate/20110729022608_create_posts.rb b/test/dummy/db/migrate/20110729022608_create_posts.rb deleted file mode 100644 index b55e917b..00000000 --- a/test/dummy/db/migrate/20110729022608_create_posts.rb +++ /dev/null @@ -1,15 +0,0 @@ -class CreatePosts < ActiveRecord::Migration - def self.up - create_table :posts do |t| - t.string :title - t.text :body - t.string :secret - - t.timestamps - end - end - - def self.down - drop_table :posts - end -end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb deleted file mode 100644 index d4de93a0..00000000 --- a/test/dummy/db/schema.rb +++ /dev/null @@ -1,24 +0,0 @@ -# encoding: UTF-8 -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). -# -# It's strongly recommended that you check this file into your version control system. - -ActiveRecord::Schema.define(version: 20110729022608) do - - create_table "posts", force: true do |t| - t.string "title" - t.text "body" - t.string "secret" - t.datetime "created_at" - t.datetime "updated_at" - end - -end diff --git a/test/dummy/db/seeds.rb b/test/dummy/db/seeds.rb deleted file mode 100644 index 664d8c74..00000000 --- a/test/dummy/db/seeds.rb +++ /dev/null @@ -1,7 +0,0 @@ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). -# -# Examples: -# -# cities = City.create([{ :name => 'Chicago' }, { :name => 'Copenhagen' }]) -# Mayor.create(:name => 'Daley', :city => cities.first) diff --git a/test/dummy/lib/tasks/.gitkeep b/test/dummy/lib/tasks/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/dummy/public/404.html b/test/dummy/public/404.html deleted file mode 100644 index 9a48320a..00000000 --- a/test/dummy/public/404.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - The page you were looking for doesn't exist (404) - - - - - -
-

The page you were looking for doesn't exist.

-

You may have mistyped the address or the page may have moved.

-
- - diff --git a/test/dummy/public/422.html b/test/dummy/public/422.html deleted file mode 100644 index 83660ab1..00000000 --- a/test/dummy/public/422.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - The change you wanted was rejected (422) - - - - - -
-

The change you wanted was rejected.

-

Maybe you tried to change something you didn't have access to.

-
- - diff --git a/test/dummy/public/500.html b/test/dummy/public/500.html deleted file mode 100644 index b80307fc..00000000 --- a/test/dummy/public/500.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - We're sorry, but something went wrong (500) - - - - - -
-

We're sorry, but something went wrong.

-

We've been notified about this issue and we'll take a look at it shortly.

-
- - diff --git a/test/dummy/public/favicon.ico b/test/dummy/public/favicon.ico deleted file mode 100644 index e69de29b..00000000 diff --git a/test/dummy/public/images/rails.png b/test/dummy/public/images/rails.png deleted file mode 100644 index d5edc04e..00000000 Binary files a/test/dummy/public/images/rails.png and /dev/null differ diff --git a/test/dummy/public/index.html b/test/dummy/public/index.html deleted file mode 100644 index 75d5edd0..00000000 --- a/test/dummy/public/index.html +++ /dev/null @@ -1,239 +0,0 @@ - - - - Ruby on Rails: Welcome aboard - - - - -
- - -
- - - - -
-

Getting started

-

Here’s how to get rolling:

- -
    -
  1. -

    Use rails generate to create your models and controllers

    -

    To see all available options, run it without parameters.

    -
  2. - -
  3. -

    Set up a default route and remove or rename this file

    -

    Routes are set up in config/routes.rb.

    -
  4. - -
  5. -

    Create your database

    -

    Run rake db:migrate to create your database. If you're not using SQLite (the default), edit config/database.yml with your username and password.

    -
  6. -
-
-
- - -
- - diff --git a/test/dummy/public/javascripts/application.js b/test/dummy/public/javascripts/application.js deleted file mode 100644 index fe457769..00000000 --- a/test/dummy/public/javascripts/application.js +++ /dev/null @@ -1,2 +0,0 @@ -// Place your application-specific JavaScript functions and classes here -// This file is automatically included by javascript_include_tag :defaults diff --git a/test/dummy/public/javascripts/controls.js b/test/dummy/public/javascripts/controls.js deleted file mode 100644 index 7392fb66..00000000 --- a/test/dummy/public/javascripts/controls.js +++ /dev/null @@ -1,965 +0,0 @@ -// script.aculo.us controls.js v1.8.3, Thu Oct 08 11:23:33 +0200 2009 - -// Copyright (c) 2005-2009 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) -// (c) 2005-2009 Ivan Krstic (http://blogs.law.harvard.edu/ivan) -// (c) 2005-2009 Jon Tirsen (http://www.tirsen.com) -// Contributors: -// Richard Livsey -// Rahul Bhargava -// Rob Wills -// -// script.aculo.us is freely distributable under the terms of an MIT-style license. -// For details, see the script.aculo.us web site: http://script.aculo.us/ - -// Autocompleter.Base handles all the autocompletion functionality -// that's independent of the data source for autocompletion. This -// includes drawing the autocompletion menu, observing keyboard -// and mouse events, and similar. -// -// Specific autocompleters need to provide, at the very least, -// a getUpdatedChoices function that will be invoked every time -// the text inside the monitored textbox changes. This method -// should get the text for which to provide autocompletion by -// invoking this.getToken(), NOT by directly accessing -// this.element.value. This is to allow incremental tokenized -// autocompletion. Specific auto-completion logic (AJAX, etc) -// belongs in getUpdatedChoices. -// -// Tokenized incremental autocompletion is enabled automatically -// when an autocompleter is instantiated with the 'tokens' option -// in the options parameter, e.g.: -// new Ajax.Autocompleter('id','upd', '/url/', { tokens: ',' }); -// will incrementally autocomplete with a comma as the token. -// Additionally, ',' in the above example can be replaced with -// a token array, e.g. { tokens: [',', '\n'] } which -// enables autocompletion on multiple tokens. This is most -// useful when one of the tokens is \n (a newline), as it -// allows smart autocompletion after linebreaks. - -if(typeof Effect == 'undefined') - throw("controls.js requires including script.aculo.us' effects.js library"); - -var Autocompleter = { }; -Autocompleter.Base = Class.create({ - baseInitialize: function(element, update, options) { - element = $(element); - this.element = element; - this.update = $(update); - this.hasFocus = false; - this.changed = false; - this.active = false; - this.index = 0; - this.entryCount = 0; - this.oldElementValue = this.element.value; - - if(this.setOptions) - this.setOptions(options); - else - this.options = options || { }; - - this.options.paramName = this.options.paramName || this.element.name; - this.options.tokens = this.options.tokens || []; - this.options.frequency = this.options.frequency || 0.4; - this.options.minChars = this.options.minChars || 1; - this.options.onShow = this.options.onShow || - function(element, update){ - if(!update.style.position || update.style.position=='absolute') { - update.style.position = 'absolute'; - Position.clone(element, update, { - setHeight: false, - offsetTop: element.offsetHeight - }); - } - Effect.Appear(update,{duration:0.15}); - }; - this.options.onHide = this.options.onHide || - function(element, update){ new Effect.Fade(update,{duration:0.15}) }; - - if(typeof(this.options.tokens) == 'string') - this.options.tokens = new Array(this.options.tokens); - // Force carriage returns as token delimiters anyway - if (!this.options.tokens.include('\n')) - this.options.tokens.push('\n'); - - this.observer = null; - - this.element.setAttribute('autocomplete','off'); - - Element.hide(this.update); - - Event.observe(this.element, 'blur', this.onBlur.bindAsEventListener(this)); - Event.observe(this.element, 'keydown', this.onKeyPress.bindAsEventListener(this)); - }, - - show: function() { - if(Element.getStyle(this.update, 'display')=='none') this.options.onShow(this.element, this.update); - if(!this.iefix && - (Prototype.Browser.IE) && - (Element.getStyle(this.update, 'position')=='absolute')) { - new Insertion.After(this.update, - ''); - this.iefix = $(this.update.id+'_iefix'); - } - if(this.iefix) setTimeout(this.fixIEOverlapping.bind(this), 50); - }, - - fixIEOverlapping: function() { - Position.clone(this.update, this.iefix, {setTop:(!this.update.style.height)}); - this.iefix.style.zIndex = 1; - this.update.style.zIndex = 2; - Element.show(this.iefix); - }, - - hide: function() { - this.stopIndicator(); - if(Element.getStyle(this.update, 'display')!='none') this.options.onHide(this.element, this.update); - if(this.iefix) Element.hide(this.iefix); - }, - - startIndicator: function() { - if(this.options.indicator) Element.show(this.options.indicator); - }, - - stopIndicator: function() { - if(this.options.indicator) Element.hide(this.options.indicator); - }, - - onKeyPress: function(event) { - if(this.active) - switch(event.keyCode) { - case Event.KEY_TAB: - case Event.KEY_RETURN: - this.selectEntry(); - Event.stop(event); - case Event.KEY_ESC: - this.hide(); - this.active = false; - Event.stop(event); - return; - case Event.KEY_LEFT: - case Event.KEY_RIGHT: - return; - case Event.KEY_UP: - this.markPrevious(); - this.render(); - Event.stop(event); - return; - case Event.KEY_DOWN: - this.markNext(); - this.render(); - Event.stop(event); - return; - } - else - if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN || - (Prototype.Browser.WebKit > 0 && event.keyCode == 0)) return; - - this.changed = true; - this.hasFocus = true; - - if(this.observer) clearTimeout(this.observer); - this.observer = - setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000); - }, - - activate: function() { - this.changed = false; - this.hasFocus = true; - this.getUpdatedChoices(); - }, - - onHover: function(event) { - var element = Event.findElement(event, 'LI'); - if(this.index != element.autocompleteIndex) - { - this.index = element.autocompleteIndex; - this.render(); - } - Event.stop(event); - }, - - onClick: function(event) { - var element = Event.findElement(event, 'LI'); - this.index = element.autocompleteIndex; - this.selectEntry(); - this.hide(); - }, - - onBlur: function(event) { - // needed to make click events working - setTimeout(this.hide.bind(this), 250); - this.hasFocus = false; - this.active = false; - }, - - render: function() { - if(this.entryCount > 0) { - for (var i = 0; i < this.entryCount; i++) - this.index==i ? - Element.addClassName(this.getEntry(i),"selected") : - Element.removeClassName(this.getEntry(i),"selected"); - if(this.hasFocus) { - this.show(); - this.active = true; - } - } else { - this.active = false; - this.hide(); - } - }, - - markPrevious: function() { - if(this.index > 0) this.index--; - else this.index = this.entryCount-1; - this.getEntry(this.index).scrollIntoView(true); - }, - - markNext: function() { - if(this.index < this.entryCount-1) this.index++; - else this.index = 0; - this.getEntry(this.index).scrollIntoView(false); - }, - - getEntry: function(index) { - return this.update.firstChild.childNodes[index]; - }, - - getCurrentEntry: function() { - return this.getEntry(this.index); - }, - - selectEntry: function() { - this.active = false; - this.updateElement(this.getCurrentEntry()); - }, - - updateElement: function(selectedElement) { - if (this.options.updateElement) { - this.options.updateElement(selectedElement); - return; - } - var value = ''; - if (this.options.select) { - var nodes = $(selectedElement).select('.' + this.options.select) || []; - if(nodes.length>0) value = Element.collectTextNodes(nodes[0], this.options.select); - } else - value = Element.collectTextNodesIgnoreClass(selectedElement, 'informal'); - - var bounds = this.getTokenBounds(); - if (bounds[0] != -1) { - var newValue = this.element.value.substr(0, bounds[0]); - var whitespace = this.element.value.substr(bounds[0]).match(/^\s+/); - if (whitespace) - newValue += whitespace[0]; - this.element.value = newValue + value + this.element.value.substr(bounds[1]); - } else { - this.element.value = value; - } - this.oldElementValue = this.element.value; - this.element.focus(); - - if (this.options.afterUpdateElement) - this.options.afterUpdateElement(this.element, selectedElement); - }, - - updateChoices: function(choices) { - if(!this.changed && this.hasFocus) { - this.update.innerHTML = choices; - Element.cleanWhitespace(this.update); - Element.cleanWhitespace(this.update.down()); - - if(this.update.firstChild && this.update.down().childNodes) { - this.entryCount = - this.update.down().childNodes.length; - for (var i = 0; i < this.entryCount; i++) { - var entry = this.getEntry(i); - entry.autocompleteIndex = i; - this.addObservers(entry); - } - } else { - this.entryCount = 0; - } - - this.stopIndicator(); - this.index = 0; - - if(this.entryCount==1 && this.options.autoSelect) { - this.selectEntry(); - this.hide(); - } else { - this.render(); - } - } - }, - - addObservers: function(element) { - Event.observe(element, "mouseover", this.onHover.bindAsEventListener(this)); - Event.observe(element, "click", this.onClick.bindAsEventListener(this)); - }, - - onObserverEvent: function() { - this.changed = false; - this.tokenBounds = null; - if(this.getToken().length>=this.options.minChars) { - this.getUpdatedChoices(); - } else { - this.active = false; - this.hide(); - } - this.oldElementValue = this.element.value; - }, - - getToken: function() { - var bounds = this.getTokenBounds(); - return this.element.value.substring(bounds[0], bounds[1]).strip(); - }, - - getTokenBounds: function() { - if (null != this.tokenBounds) return this.tokenBounds; - var value = this.element.value; - if (value.strip().empty()) return [-1, 0]; - var diff = arguments.callee.getFirstDifferencePos(value, this.oldElementValue); - var offset = (diff == this.oldElementValue.length ? 1 : 0); - var prevTokenPos = -1, nextTokenPos = value.length; - var tp; - for (var index = 0, l = this.options.tokens.length; index < l; ++index) { - tp = value.lastIndexOf(this.options.tokens[index], diff + offset - 1); - if (tp > prevTokenPos) prevTokenPos = tp; - tp = value.indexOf(this.options.tokens[index], diff + offset); - if (-1 != tp && tp < nextTokenPos) nextTokenPos = tp; - } - return (this.tokenBounds = [prevTokenPos + 1, nextTokenPos]); - } -}); - -Autocompleter.Base.prototype.getTokenBounds.getFirstDifferencePos = function(newS, oldS) { - var boundary = Math.min(newS.length, oldS.length); - for (var index = 0; index < boundary; ++index) - if (newS[index] != oldS[index]) - return index; - return boundary; -}; - -Ajax.Autocompleter = Class.create(Autocompleter.Base, { - initialize: function(element, update, url, options) { - this.baseInitialize(element, update, options); - this.options.asynchronous = true; - this.options.onComplete = this.onComplete.bind(this); - this.options.defaultParams = this.options.parameters || null; - this.url = url; - }, - - getUpdatedChoices: function() { - this.startIndicator(); - - var entry = encodeURIComponent(this.options.paramName) + '=' + - encodeURIComponent(this.getToken()); - - this.options.parameters = this.options.callback ? - this.options.callback(this.element, entry) : entry; - - if(this.options.defaultParams) - this.options.parameters += '&' + this.options.defaultParams; - - new Ajax.Request(this.url, this.options); - }, - - onComplete: function(request) { - this.updateChoices(request.responseText); - } -}); - -// The local array autocompleter. Used when you'd prefer to -// inject an array of autocompletion options into the page, rather -// than sending out Ajax queries, which can be quite slow sometimes. -// -// The constructor takes four parameters. The first two are, as usual, -// the id of the monitored textbox, and id of the autocompletion menu. -// The third is the array you want to autocomplete from, and the fourth -// is the options block. -// -// Extra local autocompletion options: -// - choices - How many autocompletion choices to offer -// -// - partialSearch - If false, the autocompleter will match entered -// text only at the beginning of strings in the -// autocomplete array. Defaults to true, which will -// match text at the beginning of any *word* in the -// strings in the autocomplete array. If you want to -// search anywhere in the string, additionally set -// the option fullSearch to true (default: off). -// -// - fullSsearch - Search anywhere in autocomplete array strings. -// -// - partialChars - How many characters to enter before triggering -// a partial match (unlike minChars, which defines -// how many characters are required to do any match -// at all). Defaults to 2. -// -// - ignoreCase - Whether to ignore case when autocompleting. -// Defaults to true. -// -// It's possible to pass in a custom function as the 'selector' -// option, if you prefer to write your own autocompletion logic. -// In that case, the other options above will not apply unless -// you support them. - -Autocompleter.Local = Class.create(Autocompleter.Base, { - initialize: function(element, update, array, options) { - this.baseInitialize(element, update, options); - this.options.array = array; - }, - - getUpdatedChoices: function() { - this.updateChoices(this.options.selector(this)); - }, - - setOptions: function(options) { - this.options = Object.extend({ - choices: 10, - partialSearch: true, - partialChars: 2, - ignoreCase: true, - fullSearch: false, - selector: function(instance) { - var ret = []; // Beginning matches - var partial = []; // Inside matches - var entry = instance.getToken(); - var count = 0; - - for (var i = 0; i < instance.options.array.length && - ret.length < instance.options.choices ; i++) { - - var elem = instance.options.array[i]; - var foundPos = instance.options.ignoreCase ? - elem.toLowerCase().indexOf(entry.toLowerCase()) : - elem.indexOf(entry); - - while (foundPos != -1) { - if (foundPos == 0 && elem.length != entry.length) { - ret.push("
  • " + elem.substr(0, entry.length) + "" + - elem.substr(entry.length) + "
  • "); - break; - } else if (entry.length >= instance.options.partialChars && - instance.options.partialSearch && foundPos != -1) { - if (instance.options.fullSearch || /\s/.test(elem.substr(foundPos-1,1))) { - partial.push("
  • " + elem.substr(0, foundPos) + "" + - elem.substr(foundPos, entry.length) + "" + elem.substr( - foundPos + entry.length) + "
  • "); - break; - } - } - - foundPos = instance.options.ignoreCase ? - elem.toLowerCase().indexOf(entry.toLowerCase(), foundPos + 1) : - elem.indexOf(entry, foundPos + 1); - - } - } - if (partial.length) - ret = ret.concat(partial.slice(0, instance.options.choices - ret.length)); - return "
      " + ret.join('') + "
    "; - } - }, options || { }); - } -}); - -// AJAX in-place editor and collection editor -// Full rewrite by Christophe Porteneuve (April 2007). - -// Use this if you notice weird scrolling problems on some browsers, -// the DOM might be a bit confused when this gets called so do this -// waits 1 ms (with setTimeout) until it does the activation -Field.scrollFreeActivate = function(field) { - setTimeout(function() { - Field.activate(field); - }, 1); -}; - -Ajax.InPlaceEditor = Class.create({ - initialize: function(element, url, options) { - this.url = url; - this.element = element = $(element); - this.prepareOptions(); - this._controls = { }; - arguments.callee.dealWithDeprecatedOptions(options); // DEPRECATION LAYER!!! - Object.extend(this.options, options || { }); - if (!this.options.formId && this.element.id) { - this.options.formId = this.element.id + '-inplaceeditor'; - if ($(this.options.formId)) - this.options.formId = ''; - } - if (this.options.externalControl) - this.options.externalControl = $(this.options.externalControl); - if (!this.options.externalControl) - this.options.externalControlOnly = false; - this._originalBackground = this.element.getStyle('background-color') || 'transparent'; - this.element.title = this.options.clickToEditText; - this._boundCancelHandler = this.handleFormCancellation.bind(this); - this._boundComplete = (this.options.onComplete || Prototype.emptyFunction).bind(this); - this._boundFailureHandler = this.handleAJAXFailure.bind(this); - this._boundSubmitHandler = this.handleFormSubmission.bind(this); - this._boundWrapperHandler = this.wrapUp.bind(this); - this.registerListeners(); - }, - checkForEscapeOrReturn: function(e) { - if (!this._editing || e.ctrlKey || e.altKey || e.shiftKey) return; - if (Event.KEY_ESC == e.keyCode) - this.handleFormCancellation(e); - else if (Event.KEY_RETURN == e.keyCode) - this.handleFormSubmission(e); - }, - createControl: function(mode, handler, extraClasses) { - var control = this.options[mode + 'Control']; - var text = this.options[mode + 'Text']; - if ('button' == control) { - var btn = document.createElement('input'); - btn.type = 'submit'; - btn.value = text; - btn.className = 'editor_' + mode + '_button'; - if ('cancel' == mode) - btn.onclick = this._boundCancelHandler; - this._form.appendChild(btn); - this._controls[mode] = btn; - } else if ('link' == control) { - var link = document.createElement('a'); - link.href = '#'; - link.appendChild(document.createTextNode(text)); - link.onclick = 'cancel' == mode ? this._boundCancelHandler : this._boundSubmitHandler; - link.className = 'editor_' + mode + '_link'; - if (extraClasses) - link.className += ' ' + extraClasses; - this._form.appendChild(link); - this._controls[mode] = link; - } - }, - createEditField: function() { - var text = (this.options.loadTextURL ? this.options.loadingText : this.getText()); - var fld; - if (1 >= this.options.rows && !/\r|\n/.test(this.getText())) { - fld = document.createElement('input'); - fld.type = 'text'; - var size = this.options.size || this.options.cols || 0; - if (0 < size) fld.size = size; - } else { - fld = document.createElement('textarea'); - fld.rows = (1 >= this.options.rows ? this.options.autoRows : this.options.rows); - fld.cols = this.options.cols || 40; - } - fld.name = this.options.paramName; - fld.value = text; // No HTML breaks conversion anymore - fld.className = 'editor_field'; - if (this.options.submitOnBlur) - fld.onblur = this._boundSubmitHandler; - this._controls.editor = fld; - if (this.options.loadTextURL) - this.loadExternalText(); - this._form.appendChild(this._controls.editor); - }, - createForm: function() { - var ipe = this; - function addText(mode, condition) { - var text = ipe.options['text' + mode + 'Controls']; - if (!text || condition === false) return; - ipe._form.appendChild(document.createTextNode(text)); - }; - this._form = $(document.createElement('form')); - this._form.id = this.options.formId; - this._form.addClassName(this.options.formClassName); - this._form.onsubmit = this._boundSubmitHandler; - this.createEditField(); - if ('textarea' == this._controls.editor.tagName.toLowerCase()) - this._form.appendChild(document.createElement('br')); - if (this.options.onFormCustomization) - this.options.onFormCustomization(this, this._form); - addText('Before', this.options.okControl || this.options.cancelControl); - this.createControl('ok', this._boundSubmitHandler); - addText('Between', this.options.okControl && this.options.cancelControl); - this.createControl('cancel', this._boundCancelHandler, 'editor_cancel'); - addText('After', this.options.okControl || this.options.cancelControl); - }, - destroy: function() { - if (this._oldInnerHTML) - this.element.innerHTML = this._oldInnerHTML; - this.leaveEditMode(); - this.unregisterListeners(); - }, - enterEditMode: function(e) { - if (this._saving || this._editing) return; - this._editing = true; - this.triggerCallback('onEnterEditMode'); - if (this.options.externalControl) - this.options.externalControl.hide(); - this.element.hide(); - this.createForm(); - this.element.parentNode.insertBefore(this._form, this.element); - if (!this.options.loadTextURL) - this.postProcessEditField(); - if (e) Event.stop(e); - }, - enterHover: function(e) { - if (this.options.hoverClassName) - this.element.addClassName(this.options.hoverClassName); - if (this._saving) return; - this.triggerCallback('onEnterHover'); - }, - getText: function() { - return this.element.innerHTML.unescapeHTML(); - }, - handleAJAXFailure: function(transport) { - this.triggerCallback('onFailure', transport); - if (this._oldInnerHTML) { - this.element.innerHTML = this._oldInnerHTML; - this._oldInnerHTML = null; - } - }, - handleFormCancellation: function(e) { - this.wrapUp(); - if (e) Event.stop(e); - }, - handleFormSubmission: function(e) { - var form = this._form; - var value = $F(this._controls.editor); - this.prepareSubmission(); - var params = this.options.callback(form, value) || ''; - if (Object.isString(params)) - params = params.toQueryParams(); - params.editorId = this.element.id; - if (this.options.htmlResponse) { - var options = Object.extend({ evalScripts: true }, this.options.ajaxOptions); - Object.extend(options, { - parameters: params, - onComplete: this._boundWrapperHandler, - onFailure: this._boundFailureHandler - }); - new Ajax.Updater({ success: this.element }, this.url, options); - } else { - var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); - Object.extend(options, { - parameters: params, - onComplete: this._boundWrapperHandler, - onFailure: this._boundFailureHandler - }); - new Ajax.Request(this.url, options); - } - if (e) Event.stop(e); - }, - leaveEditMode: function() { - this.element.removeClassName(this.options.savingClassName); - this.removeForm(); - this.leaveHover(); - this.element.style.backgroundColor = this._originalBackground; - this.element.show(); - if (this.options.externalControl) - this.options.externalControl.show(); - this._saving = false; - this._editing = false; - this._oldInnerHTML = null; - this.triggerCallback('onLeaveEditMode'); - }, - leaveHover: function(e) { - if (this.options.hoverClassName) - this.element.removeClassName(this.options.hoverClassName); - if (this._saving) return; - this.triggerCallback('onLeaveHover'); - }, - loadExternalText: function() { - this._form.addClassName(this.options.loadingClassName); - this._controls.editor.disabled = true; - var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); - Object.extend(options, { - parameters: 'editorId=' + encodeURIComponent(this.element.id), - onComplete: Prototype.emptyFunction, - onSuccess: function(transport) { - this._form.removeClassName(this.options.loadingClassName); - var text = transport.responseText; - if (this.options.stripLoadedTextTags) - text = text.stripTags(); - this._controls.editor.value = text; - this._controls.editor.disabled = false; - this.postProcessEditField(); - }.bind(this), - onFailure: this._boundFailureHandler - }); - new Ajax.Request(this.options.loadTextURL, options); - }, - postProcessEditField: function() { - var fpc = this.options.fieldPostCreation; - if (fpc) - $(this._controls.editor)['focus' == fpc ? 'focus' : 'activate'](); - }, - prepareOptions: function() { - this.options = Object.clone(Ajax.InPlaceEditor.DefaultOptions); - Object.extend(this.options, Ajax.InPlaceEditor.DefaultCallbacks); - [this._extraDefaultOptions].flatten().compact().each(function(defs) { - Object.extend(this.options, defs); - }.bind(this)); - }, - prepareSubmission: function() { - this._saving = true; - this.removeForm(); - this.leaveHover(); - this.showSaving(); - }, - registerListeners: function() { - this._listeners = { }; - var listener; - $H(Ajax.InPlaceEditor.Listeners).each(function(pair) { - listener = this[pair.value].bind(this); - this._listeners[pair.key] = listener; - if (!this.options.externalControlOnly) - this.element.observe(pair.key, listener); - if (this.options.externalControl) - this.options.externalControl.observe(pair.key, listener); - }.bind(this)); - }, - removeForm: function() { - if (!this._form) return; - this._form.remove(); - this._form = null; - this._controls = { }; - }, - showSaving: function() { - this._oldInnerHTML = this.element.innerHTML; - this.element.innerHTML = this.options.savingText; - this.element.addClassName(this.options.savingClassName); - this.element.style.backgroundColor = this._originalBackground; - this.element.show(); - }, - triggerCallback: function(cbName, arg) { - if ('function' == typeof this.options[cbName]) { - this.options[cbName](this, arg); - } - }, - unregisterListeners: function() { - $H(this._listeners).each(function(pair) { - if (!this.options.externalControlOnly) - this.element.stopObserving(pair.key, pair.value); - if (this.options.externalControl) - this.options.externalControl.stopObserving(pair.key, pair.value); - }.bind(this)); - }, - wrapUp: function(transport) { - this.leaveEditMode(); - // Can't use triggerCallback due to backward compatibility: requires - // binding + direct element - this._boundComplete(transport, this.element); - } -}); - -Object.extend(Ajax.InPlaceEditor.prototype, { - dispose: Ajax.InPlaceEditor.prototype.destroy -}); - -Ajax.InPlaceCollectionEditor = Class.create(Ajax.InPlaceEditor, { - initialize: function($super, element, url, options) { - this._extraDefaultOptions = Ajax.InPlaceCollectionEditor.DefaultOptions; - $super(element, url, options); - }, - - createEditField: function() { - var list = document.createElement('select'); - list.name = this.options.paramName; - list.size = 1; - this._controls.editor = list; - this._collection = this.options.collection || []; - if (this.options.loadCollectionURL) - this.loadCollection(); - else - this.checkForExternalText(); - this._form.appendChild(this._controls.editor); - }, - - loadCollection: function() { - this._form.addClassName(this.options.loadingClassName); - this.showLoadingText(this.options.loadingCollectionText); - var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); - Object.extend(options, { - parameters: 'editorId=' + encodeURIComponent(this.element.id), - onComplete: Prototype.emptyFunction, - onSuccess: function(transport) { - var js = transport.responseText.strip(); - if (!/^\[.*\]$/.test(js)) // TODO: improve sanity check - throw('Server returned an invalid collection representation.'); - this._collection = eval(js); - this.checkForExternalText(); - }.bind(this), - onFailure: this.onFailure - }); - new Ajax.Request(this.options.loadCollectionURL, options); - }, - - showLoadingText: function(text) { - this._controls.editor.disabled = true; - var tempOption = this._controls.editor.firstChild; - if (!tempOption) { - tempOption = document.createElement('option'); - tempOption.value = ''; - this._controls.editor.appendChild(tempOption); - tempOption.selected = true; - } - tempOption.update((text || '').stripScripts().stripTags()); - }, - - checkForExternalText: function() { - this._text = this.getText(); - if (this.options.loadTextURL) - this.loadExternalText(); - else - this.buildOptionList(); - }, - - loadExternalText: function() { - this.showLoadingText(this.options.loadingText); - var options = Object.extend({ method: 'get' }, this.options.ajaxOptions); - Object.extend(options, { - parameters: 'editorId=' + encodeURIComponent(this.element.id), - onComplete: Prototype.emptyFunction, - onSuccess: function(transport) { - this._text = transport.responseText.strip(); - this.buildOptionList(); - }.bind(this), - onFailure: this.onFailure - }); - new Ajax.Request(this.options.loadTextURL, options); - }, - - buildOptionList: function() { - this._form.removeClassName(this.options.loadingClassName); - this._collection = this._collection.map(function(entry) { - return 2 === entry.length ? entry : [entry, entry].flatten(); - }); - var marker = ('value' in this.options) ? this.options.value : this._text; - var textFound = this._collection.any(function(entry) { - return entry[0] == marker; - }.bind(this)); - this._controls.editor.update(''); - var option; - this._collection.each(function(entry, index) { - option = document.createElement('option'); - option.value = entry[0]; - option.selected = textFound ? entry[0] == marker : 0 == index; - option.appendChild(document.createTextNode(entry[1])); - this._controls.editor.appendChild(option); - }.bind(this)); - this._controls.editor.disabled = false; - Field.scrollFreeActivate(this._controls.editor); - } -}); - -//**** DEPRECATION LAYER FOR InPlace[Collection]Editor! **** -//**** This only exists for a while, in order to let **** -//**** users adapt to the new API. Read up on the new **** -//**** API and convert your code to it ASAP! **** - -Ajax.InPlaceEditor.prototype.initialize.dealWithDeprecatedOptions = function(options) { - if (!options) return; - function fallback(name, expr) { - if (name in options || expr === undefined) return; - options[name] = expr; - }; - fallback('cancelControl', (options.cancelLink ? 'link' : (options.cancelButton ? 'button' : - options.cancelLink == options.cancelButton == false ? false : undefined))); - fallback('okControl', (options.okLink ? 'link' : (options.okButton ? 'button' : - options.okLink == options.okButton == false ? false : undefined))); - fallback('highlightColor', options.highlightcolor); - fallback('highlightEndColor', options.highlightendcolor); -}; - -Object.extend(Ajax.InPlaceEditor, { - DefaultOptions: { - ajaxOptions: { }, - autoRows: 3, // Use when multi-line w/ rows == 1 - cancelControl: 'link', // 'link'|'button'|false - cancelText: 'cancel', - clickToEditText: 'Click to edit', - externalControl: null, // id|elt - externalControlOnly: false, - fieldPostCreation: 'activate', // 'activate'|'focus'|false - formClassName: 'inplaceeditor-form', - formId: null, // id|elt - highlightColor: '#ffff99', - highlightEndColor: '#ffffff', - hoverClassName: '', - htmlResponse: true, - loadingClassName: 'inplaceeditor-loading', - loadingText: 'Loading...', - okControl: 'button', // 'link'|'button'|false - okText: 'ok', - paramName: 'value', - rows: 1, // If 1 and multi-line, uses autoRows - savingClassName: 'inplaceeditor-saving', - savingText: 'Saving...', - size: 0, - stripLoadedTextTags: false, - submitOnBlur: false, - textAfterControls: '', - textBeforeControls: '', - textBetweenControls: '' - }, - DefaultCallbacks: { - callback: function(form) { - return Form.serialize(form); - }, - onComplete: function(transport, element) { - // For backward compatibility, this one is bound to the IPE, and passes - // the element directly. It was too often customized, so we don't break it. - new Effect.Highlight(element, { - startcolor: this.options.highlightColor, keepBackgroundImage: true }); - }, - onEnterEditMode: null, - onEnterHover: function(ipe) { - ipe.element.style.backgroundColor = ipe.options.highlightColor; - if (ipe._effect) - ipe._effect.cancel(); - }, - onFailure: function(transport, ipe) { - alert('Error communication with the server: ' + transport.responseText.stripTags()); - }, - onFormCustomization: null, // Takes the IPE and its generated form, after editor, before controls. - onLeaveEditMode: null, - onLeaveHover: function(ipe) { - ipe._effect = new Effect.Highlight(ipe.element, { - startcolor: ipe.options.highlightColor, endcolor: ipe.options.highlightEndColor, - restorecolor: ipe._originalBackground, keepBackgroundImage: true - }); - } - }, - Listeners: { - click: 'enterEditMode', - keydown: 'checkForEscapeOrReturn', - mouseover: 'enterHover', - mouseout: 'leaveHover' - } -}); - -Ajax.InPlaceCollectionEditor.DefaultOptions = { - loadingCollectionText: 'Loading options...' -}; - -// Delayed observer, like Form.Element.Observer, -// but waits for delay after last key input -// Ideal for live-search fields - -Form.Element.DelayedObserver = Class.create({ - initialize: function(element, delay, callback) { - this.delay = delay || 0.5; - this.element = $(element); - this.callback = callback; - this.timer = null; - this.lastValue = $F(this.element); - Event.observe(this.element,'keyup',this.delayedListener.bindAsEventListener(this)); - }, - delayedListener: function(event) { - if(this.lastValue == $F(this.element)) return; - if(this.timer) clearTimeout(this.timer); - this.timer = setTimeout(this.onTimerEvent.bind(this), this.delay * 1000); - this.lastValue = $F(this.element); - }, - onTimerEvent: function() { - this.timer = null; - this.callback(this.element, $F(this.element)); - } -}); \ No newline at end of file diff --git a/test/dummy/public/javascripts/dragdrop.js b/test/dummy/public/javascripts/dragdrop.js deleted file mode 100644 index 15c6dbca..00000000 --- a/test/dummy/public/javascripts/dragdrop.js +++ /dev/null @@ -1,974 +0,0 @@ -// script.aculo.us dragdrop.js v1.8.3, Thu Oct 08 11:23:33 +0200 2009 - -// Copyright (c) 2005-2009 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) -// -// script.aculo.us is freely distributable under the terms of an MIT-style license. -// For details, see the script.aculo.us web site: http://script.aculo.us/ - -if(Object.isUndefined(Effect)) - throw("dragdrop.js requires including script.aculo.us' effects.js library"); - -var Droppables = { - drops: [], - - remove: function(element) { - this.drops = this.drops.reject(function(d) { return d.element==$(element) }); - }, - - add: function(element) { - element = $(element); - var options = Object.extend({ - greedy: true, - hoverclass: null, - tree: false - }, arguments[1] || { }); - - // cache containers - if(options.containment) { - options._containers = []; - var containment = options.containment; - if(Object.isArray(containment)) { - containment.each( function(c) { options._containers.push($(c)) }); - } else { - options._containers.push($(containment)); - } - } - - if(options.accept) options.accept = [options.accept].flatten(); - - Element.makePositioned(element); // fix IE - options.element = element; - - this.drops.push(options); - }, - - findDeepestChild: function(drops) { - deepest = drops[0]; - - for (i = 1; i < drops.length; ++i) - if (Element.isParent(drops[i].element, deepest.element)) - deepest = drops[i]; - - return deepest; - }, - - isContained: function(element, drop) { - var containmentNode; - if(drop.tree) { - containmentNode = element.treeNode; - } else { - containmentNode = element.parentNode; - } - return drop._containers.detect(function(c) { return containmentNode == c }); - }, - - isAffected: function(point, element, drop) { - return ( - (drop.element!=element) && - ((!drop._containers) || - this.isContained(element, drop)) && - ((!drop.accept) || - (Element.classNames(element).detect( - function(v) { return drop.accept.include(v) } ) )) && - Position.within(drop.element, point[0], point[1]) ); - }, - - deactivate: function(drop) { - if(drop.hoverclass) - Element.removeClassName(drop.element, drop.hoverclass); - this.last_active = null; - }, - - activate: function(drop) { - if(drop.hoverclass) - Element.addClassName(drop.element, drop.hoverclass); - this.last_active = drop; - }, - - show: function(point, element) { - if(!this.drops.length) return; - var drop, affected = []; - - this.drops.each( function(drop) { - if(Droppables.isAffected(point, element, drop)) - affected.push(drop); - }); - - if(affected.length>0) - drop = Droppables.findDeepestChild(affected); - - if(this.last_active && this.last_active != drop) this.deactivate(this.last_active); - if (drop) { - Position.within(drop.element, point[0], point[1]); - if(drop.onHover) - drop.onHover(element, drop.element, Position.overlap(drop.overlap, drop.element)); - - if (drop != this.last_active) Droppables.activate(drop); - } - }, - - fire: function(event, element) { - if(!this.last_active) return; - Position.prepare(); - - if (this.isAffected([Event.pointerX(event), Event.pointerY(event)], element, this.last_active)) - if (this.last_active.onDrop) { - this.last_active.onDrop(element, this.last_active.element, event); - return true; - } - }, - - reset: function() { - if(this.last_active) - this.deactivate(this.last_active); - } -}; - -var Draggables = { - drags: [], - observers: [], - - register: function(draggable) { - if(this.drags.length == 0) { - this.eventMouseUp = this.endDrag.bindAsEventListener(this); - this.eventMouseMove = this.updateDrag.bindAsEventListener(this); - this.eventKeypress = this.keyPress.bindAsEventListener(this); - - Event.observe(document, "mouseup", this.eventMouseUp); - Event.observe(document, "mousemove", this.eventMouseMove); - Event.observe(document, "keypress", this.eventKeypress); - } - this.drags.push(draggable); - }, - - unregister: function(draggable) { - this.drags = this.drags.reject(function(d) { return d==draggable }); - if(this.drags.length == 0) { - Event.stopObserving(document, "mouseup", this.eventMouseUp); - Event.stopObserving(document, "mousemove", this.eventMouseMove); - Event.stopObserving(document, "keypress", this.eventKeypress); - } - }, - - activate: function(draggable) { - if(draggable.options.delay) { - this._timeout = setTimeout(function() { - Draggables._timeout = null; - window.focus(); - Draggables.activeDraggable = draggable; - }.bind(this), draggable.options.delay); - } else { - window.focus(); // allows keypress events if window isn't currently focused, fails for Safari - this.activeDraggable = draggable; - } - }, - - deactivate: function() { - this.activeDraggable = null; - }, - - updateDrag: function(event) { - if(!this.activeDraggable) return; - var pointer = [Event.pointerX(event), Event.pointerY(event)]; - // Mozilla-based browsers fire successive mousemove events with - // the same coordinates, prevent needless redrawing (moz bug?) - if(this._lastPointer && (this._lastPointer.inspect() == pointer.inspect())) return; - this._lastPointer = pointer; - - this.activeDraggable.updateDrag(event, pointer); - }, - - endDrag: function(event) { - if(this._timeout) { - clearTimeout(this._timeout); - this._timeout = null; - } - if(!this.activeDraggable) return; - this._lastPointer = null; - this.activeDraggable.endDrag(event); - this.activeDraggable = null; - }, - - keyPress: function(event) { - if(this.activeDraggable) - this.activeDraggable.keyPress(event); - }, - - addObserver: function(observer) { - this.observers.push(observer); - this._cacheObserverCallbacks(); - }, - - removeObserver: function(element) { // element instead of observer fixes mem leaks - this.observers = this.observers.reject( function(o) { return o.element==element }); - this._cacheObserverCallbacks(); - }, - - notify: function(eventName, draggable, event) { // 'onStart', 'onEnd', 'onDrag' - if(this[eventName+'Count'] > 0) - this.observers.each( function(o) { - if(o[eventName]) o[eventName](eventName, draggable, event); - }); - if(draggable.options[eventName]) draggable.options[eventName](draggable, event); - }, - - _cacheObserverCallbacks: function() { - ['onStart','onEnd','onDrag'].each( function(eventName) { - Draggables[eventName+'Count'] = Draggables.observers.select( - function(o) { return o[eventName]; } - ).length; - }); - } -}; - -/*--------------------------------------------------------------------------*/ - -var Draggable = Class.create({ - initialize: function(element) { - var defaults = { - handle: false, - reverteffect: function(element, top_offset, left_offset) { - var dur = Math.sqrt(Math.abs(top_offset^2)+Math.abs(left_offset^2))*0.02; - new Effect.Move(element, { x: -left_offset, y: -top_offset, duration: dur, - queue: {scope:'_draggable', position:'end'} - }); - }, - endeffect: function(element) { - var toOpacity = Object.isNumber(element._opacity) ? element._opacity : 1.0; - new Effect.Opacity(element, {duration:0.2, from:0.7, to:toOpacity, - queue: {scope:'_draggable', position:'end'}, - afterFinish: function(){ - Draggable._dragging[element] = false - } - }); - }, - zindex: 1000, - revert: false, - quiet: false, - scroll: false, - scrollSensitivity: 20, - scrollSpeed: 15, - snap: false, // false, or xy or [x,y] or function(x,y){ return [x,y] } - delay: 0 - }; - - if(!arguments[1] || Object.isUndefined(arguments[1].endeffect)) - Object.extend(defaults, { - starteffect: function(element) { - element._opacity = Element.getOpacity(element); - Draggable._dragging[element] = true; - new Effect.Opacity(element, {duration:0.2, from:element._opacity, to:0.7}); - } - }); - - var options = Object.extend(defaults, arguments[1] || { }); - - this.element = $(element); - - if(options.handle && Object.isString(options.handle)) - this.handle = this.element.down('.'+options.handle, 0); - - if(!this.handle) this.handle = $(options.handle); - if(!this.handle) this.handle = this.element; - - if(options.scroll && !options.scroll.scrollTo && !options.scroll.outerHTML) { - options.scroll = $(options.scroll); - this._isScrollChild = Element.childOf(this.element, options.scroll); - } - - Element.makePositioned(this.element); // fix IE - - this.options = options; - this.dragging = false; - - this.eventMouseDown = this.initDrag.bindAsEventListener(this); - Event.observe(this.handle, "mousedown", this.eventMouseDown); - - Draggables.register(this); - }, - - destroy: function() { - Event.stopObserving(this.handle, "mousedown", this.eventMouseDown); - Draggables.unregister(this); - }, - - currentDelta: function() { - return([ - parseInt(Element.getStyle(this.element,'left') || '0'), - parseInt(Element.getStyle(this.element,'top') || '0')]); - }, - - initDrag: function(event) { - if(!Object.isUndefined(Draggable._dragging[this.element]) && - Draggable._dragging[this.element]) return; - if(Event.isLeftClick(event)) { - // abort on form elements, fixes a Firefox issue - var src = Event.element(event); - if((tag_name = src.tagName.toUpperCase()) && ( - tag_name=='INPUT' || - tag_name=='SELECT' || - tag_name=='OPTION' || - tag_name=='BUTTON' || - tag_name=='TEXTAREA')) return; - - var pointer = [Event.pointerX(event), Event.pointerY(event)]; - var pos = this.element.cumulativeOffset(); - this.offset = [0,1].map( function(i) { return (pointer[i] - pos[i]) }); - - Draggables.activate(this); - Event.stop(event); - } - }, - - startDrag: function(event) { - this.dragging = true; - if(!this.delta) - this.delta = this.currentDelta(); - - if(this.options.zindex) { - this.originalZ = parseInt(Element.getStyle(this.element,'z-index') || 0); - this.element.style.zIndex = this.options.zindex; - } - - if(this.options.ghosting) { - this._clone = this.element.cloneNode(true); - this._originallyAbsolute = (this.element.getStyle('position') == 'absolute'); - if (!this._originallyAbsolute) - Position.absolutize(this.element); - this.element.parentNode.insertBefore(this._clone, this.element); - } - - if(this.options.scroll) { - if (this.options.scroll == window) { - var where = this._getWindowScroll(this.options.scroll); - this.originalScrollLeft = where.left; - this.originalScrollTop = where.top; - } else { - this.originalScrollLeft = this.options.scroll.scrollLeft; - this.originalScrollTop = this.options.scroll.scrollTop; - } - } - - Draggables.notify('onStart', this, event); - - if(this.options.starteffect) this.options.starteffect(this.element); - }, - - updateDrag: function(event, pointer) { - if(!this.dragging) this.startDrag(event); - - if(!this.options.quiet){ - Position.prepare(); - Droppables.show(pointer, this.element); - } - - Draggables.notify('onDrag', this, event); - - this.draw(pointer); - if(this.options.change) this.options.change(this); - - if(this.options.scroll) { - this.stopScrolling(); - - var p; - if (this.options.scroll == window) { - with(this._getWindowScroll(this.options.scroll)) { p = [ left, top, left+width, top+height ]; } - } else { - p = Position.page(this.options.scroll); - p[0] += this.options.scroll.scrollLeft + Position.deltaX; - p[1] += this.options.scroll.scrollTop + Position.deltaY; - p.push(p[0]+this.options.scroll.offsetWidth); - p.push(p[1]+this.options.scroll.offsetHeight); - } - var speed = [0,0]; - if(pointer[0] < (p[0]+this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[0]+this.options.scrollSensitivity); - if(pointer[1] < (p[1]+this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[1]+this.options.scrollSensitivity); - if(pointer[0] > (p[2]-this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[2]-this.options.scrollSensitivity); - if(pointer[1] > (p[3]-this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[3]-this.options.scrollSensitivity); - this.startScrolling(speed); - } - - // fix AppleWebKit rendering - if(Prototype.Browser.WebKit) window.scrollBy(0,0); - - Event.stop(event); - }, - - finishDrag: function(event, success) { - this.dragging = false; - - if(this.options.quiet){ - Position.prepare(); - var pointer = [Event.pointerX(event), Event.pointerY(event)]; - Droppables.show(pointer, this.element); - } - - if(this.options.ghosting) { - if (!this._originallyAbsolute) - Position.relativize(this.element); - delete this._originallyAbsolute; - Element.remove(this._clone); - this._clone = null; - } - - var dropped = false; - if(success) { - dropped = Droppables.fire(event, this.element); - if (!dropped) dropped = false; - } - if(dropped && this.options.onDropped) this.options.onDropped(this.element); - Draggables.notify('onEnd', this, event); - - var revert = this.options.revert; - if(revert && Object.isFunction(revert)) revert = revert(this.element); - - var d = this.currentDelta(); - if(revert && this.options.reverteffect) { - if (dropped == 0 || revert != 'failure') - this.options.reverteffect(this.element, - d[1]-this.delta[1], d[0]-this.delta[0]); - } else { - this.delta = d; - } - - if(this.options.zindex) - this.element.style.zIndex = this.originalZ; - - if(this.options.endeffect) - this.options.endeffect(this.element); - - Draggables.deactivate(this); - Droppables.reset(); - }, - - keyPress: function(event) { - if(event.keyCode!=Event.KEY_ESC) return; - this.finishDrag(event, false); - Event.stop(event); - }, - - endDrag: function(event) { - if(!this.dragging) return; - this.stopScrolling(); - this.finishDrag(event, true); - Event.stop(event); - }, - - draw: function(point) { - var pos = this.element.cumulativeOffset(); - if(this.options.ghosting) { - var r = Position.realOffset(this.element); - pos[0] += r[0] - Position.deltaX; pos[1] += r[1] - Position.deltaY; - } - - var d = this.currentDelta(); - pos[0] -= d[0]; pos[1] -= d[1]; - - if(this.options.scroll && (this.options.scroll != window && this._isScrollChild)) { - pos[0] -= this.options.scroll.scrollLeft-this.originalScrollLeft; - pos[1] -= this.options.scroll.scrollTop-this.originalScrollTop; - } - - var p = [0,1].map(function(i){ - return (point[i]-pos[i]-this.offset[i]) - }.bind(this)); - - if(this.options.snap) { - if(Object.isFunction(this.options.snap)) { - p = this.options.snap(p[0],p[1],this); - } else { - if(Object.isArray(this.options.snap)) { - p = p.map( function(v, i) { - return (v/this.options.snap[i]).round()*this.options.snap[i] }.bind(this)); - } else { - p = p.map( function(v) { - return (v/this.options.snap).round()*this.options.snap }.bind(this)); - } - }} - - var style = this.element.style; - if((!this.options.constraint) || (this.options.constraint=='horizontal')) - style.left = p[0] + "px"; - if((!this.options.constraint) || (this.options.constraint=='vertical')) - style.top = p[1] + "px"; - - if(style.visibility=="hidden") style.visibility = ""; // fix gecko rendering - }, - - stopScrolling: function() { - if(this.scrollInterval) { - clearInterval(this.scrollInterval); - this.scrollInterval = null; - Draggables._lastScrollPointer = null; - } - }, - - startScrolling: function(speed) { - if(!(speed[0] || speed[1])) return; - this.scrollSpeed = [speed[0]*this.options.scrollSpeed,speed[1]*this.options.scrollSpeed]; - this.lastScrolled = new Date(); - this.scrollInterval = setInterval(this.scroll.bind(this), 10); - }, - - scroll: function() { - var current = new Date(); - var delta = current - this.lastScrolled; - this.lastScrolled = current; - if(this.options.scroll == window) { - with (this._getWindowScroll(this.options.scroll)) { - if (this.scrollSpeed[0] || this.scrollSpeed[1]) { - var d = delta / 1000; - this.options.scroll.scrollTo( left + d*this.scrollSpeed[0], top + d*this.scrollSpeed[1] ); - } - } - } else { - this.options.scroll.scrollLeft += this.scrollSpeed[0] * delta / 1000; - this.options.scroll.scrollTop += this.scrollSpeed[1] * delta / 1000; - } - - Position.prepare(); - Droppables.show(Draggables._lastPointer, this.element); - Draggables.notify('onDrag', this); - if (this._isScrollChild) { - Draggables._lastScrollPointer = Draggables._lastScrollPointer || $A(Draggables._lastPointer); - Draggables._lastScrollPointer[0] += this.scrollSpeed[0] * delta / 1000; - Draggables._lastScrollPointer[1] += this.scrollSpeed[1] * delta / 1000; - if (Draggables._lastScrollPointer[0] < 0) - Draggables._lastScrollPointer[0] = 0; - if (Draggables._lastScrollPointer[1] < 0) - Draggables._lastScrollPointer[1] = 0; - this.draw(Draggables._lastScrollPointer); - } - - if(this.options.change) this.options.change(this); - }, - - _getWindowScroll: function(w) { - var T, L, W, H; - with (w.document) { - if (w.document.documentElement && documentElement.scrollTop) { - T = documentElement.scrollTop; - L = documentElement.scrollLeft; - } else if (w.document.body) { - T = body.scrollTop; - L = body.scrollLeft; - } - if (w.innerWidth) { - W = w.innerWidth; - H = w.innerHeight; - } else if (w.document.documentElement && documentElement.clientWidth) { - W = documentElement.clientWidth; - H = documentElement.clientHeight; - } else { - W = body.offsetWidth; - H = body.offsetHeight; - } - } - return { top: T, left: L, width: W, height: H }; - } -}); - -Draggable._dragging = { }; - -/*--------------------------------------------------------------------------*/ - -var SortableObserver = Class.create({ - initialize: function(element, observer) { - this.element = $(element); - this.observer = observer; - this.lastValue = Sortable.serialize(this.element); - }, - - onStart: function() { - this.lastValue = Sortable.serialize(this.element); - }, - - onEnd: function() { - Sortable.unmark(); - if(this.lastValue != Sortable.serialize(this.element)) - this.observer(this.element) - } -}); - -var Sortable = { - SERIALIZE_RULE: /^[^_\-](?:[A-Za-z0-9\-\_]*)[_](.*)$/, - - sortables: { }, - - _findRootElement: function(element) { - while (element.tagName.toUpperCase() != "BODY") { - if(element.id && Sortable.sortables[element.id]) return element; - element = element.parentNode; - } - }, - - options: function(element) { - element = Sortable._findRootElement($(element)); - if(!element) return; - return Sortable.sortables[element.id]; - }, - - destroy: function(element){ - element = $(element); - var s = Sortable.sortables[element.id]; - - if(s) { - Draggables.removeObserver(s.element); - s.droppables.each(function(d){ Droppables.remove(d) }); - s.draggables.invoke('destroy'); - - delete Sortable.sortables[s.element.id]; - } - }, - - create: function(element) { - element = $(element); - var options = Object.extend({ - element: element, - tag: 'li', // assumes li children, override with tag: 'tagname' - dropOnEmpty: false, - tree: false, - treeTag: 'ul', - overlap: 'vertical', // one of 'vertical', 'horizontal' - constraint: 'vertical', // one of 'vertical', 'horizontal', false - containment: element, // also takes array of elements (or id's); or false - handle: false, // or a CSS class - only: false, - delay: 0, - hoverclass: null, - ghosting: false, - quiet: false, - scroll: false, - scrollSensitivity: 20, - scrollSpeed: 15, - format: this.SERIALIZE_RULE, - - // these take arrays of elements or ids and can be - // used for better initialization performance - elements: false, - handles: false, - - onChange: Prototype.emptyFunction, - onUpdate: Prototype.emptyFunction - }, arguments[1] || { }); - - // clear any old sortable with same element - this.destroy(element); - - // build options for the draggables - var options_for_draggable = { - revert: true, - quiet: options.quiet, - scroll: options.scroll, - scrollSpeed: options.scrollSpeed, - scrollSensitivity: options.scrollSensitivity, - delay: options.delay, - ghosting: options.ghosting, - constraint: options.constraint, - handle: options.handle }; - - if(options.starteffect) - options_for_draggable.starteffect = options.starteffect; - - if(options.reverteffect) - options_for_draggable.reverteffect = options.reverteffect; - else - if(options.ghosting) options_for_draggable.reverteffect = function(element) { - element.style.top = 0; - element.style.left = 0; - }; - - if(options.endeffect) - options_for_draggable.endeffect = options.endeffect; - - if(options.zindex) - options_for_draggable.zindex = options.zindex; - - // build options for the droppables - var options_for_droppable = { - overlap: options.overlap, - containment: options.containment, - tree: options.tree, - hoverclass: options.hoverclass, - onHover: Sortable.onHover - }; - - var options_for_tree = { - onHover: Sortable.onEmptyHover, - overlap: options.overlap, - containment: options.containment, - hoverclass: options.hoverclass - }; - - // fix for gecko engine - Element.cleanWhitespace(element); - - options.draggables = []; - options.droppables = []; - - // drop on empty handling - if(options.dropOnEmpty || options.tree) { - Droppables.add(element, options_for_tree); - options.droppables.push(element); - } - - (options.elements || this.findElements(element, options) || []).each( function(e,i) { - var handle = options.handles ? $(options.handles[i]) : - (options.handle ? $(e).select('.' + options.handle)[0] : e); - options.draggables.push( - new Draggable(e, Object.extend(options_for_draggable, { handle: handle }))); - Droppables.add(e, options_for_droppable); - if(options.tree) e.treeNode = element; - options.droppables.push(e); - }); - - if(options.tree) { - (Sortable.findTreeElements(element, options) || []).each( function(e) { - Droppables.add(e, options_for_tree); - e.treeNode = element; - options.droppables.push(e); - }); - } - - // keep reference - this.sortables[element.identify()] = options; - - // for onupdate - Draggables.addObserver(new SortableObserver(element, options.onUpdate)); - - }, - - // return all suitable-for-sortable elements in a guaranteed order - findElements: function(element, options) { - return Element.findChildren( - element, options.only, options.tree ? true : false, options.tag); - }, - - findTreeElements: function(element, options) { - return Element.findChildren( - element, options.only, options.tree ? true : false, options.treeTag); - }, - - onHover: function(element, dropon, overlap) { - if(Element.isParent(dropon, element)) return; - - if(overlap > .33 && overlap < .66 && Sortable.options(dropon).tree) { - return; - } else if(overlap>0.5) { - Sortable.mark(dropon, 'before'); - if(dropon.previousSibling != element) { - var oldParentNode = element.parentNode; - element.style.visibility = "hidden"; // fix gecko rendering - dropon.parentNode.insertBefore(element, dropon); - if(dropon.parentNode!=oldParentNode) - Sortable.options(oldParentNode).onChange(element); - Sortable.options(dropon.parentNode).onChange(element); - } - } else { - Sortable.mark(dropon, 'after'); - var nextElement = dropon.nextSibling || null; - if(nextElement != element) { - var oldParentNode = element.parentNode; - element.style.visibility = "hidden"; // fix gecko rendering - dropon.parentNode.insertBefore(element, nextElement); - if(dropon.parentNode!=oldParentNode) - Sortable.options(oldParentNode).onChange(element); - Sortable.options(dropon.parentNode).onChange(element); - } - } - }, - - onEmptyHover: function(element, dropon, overlap) { - var oldParentNode = element.parentNode; - var droponOptions = Sortable.options(dropon); - - if(!Element.isParent(dropon, element)) { - var index; - - var children = Sortable.findElements(dropon, {tag: droponOptions.tag, only: droponOptions.only}); - var child = null; - - if(children) { - var offset = Element.offsetSize(dropon, droponOptions.overlap) * (1.0 - overlap); - - for (index = 0; index < children.length; index += 1) { - if (offset - Element.offsetSize (children[index], droponOptions.overlap) >= 0) { - offset -= Element.offsetSize (children[index], droponOptions.overlap); - } else if (offset - (Element.offsetSize (children[index], droponOptions.overlap) / 2) >= 0) { - child = index + 1 < children.length ? children[index + 1] : null; - break; - } else { - child = children[index]; - break; - } - } - } - - dropon.insertBefore(element, child); - - Sortable.options(oldParentNode).onChange(element); - droponOptions.onChange(element); - } - }, - - unmark: function() { - if(Sortable._marker) Sortable._marker.hide(); - }, - - mark: function(dropon, position) { - // mark on ghosting only - var sortable = Sortable.options(dropon.parentNode); - if(sortable && !sortable.ghosting) return; - - if(!Sortable._marker) { - Sortable._marker = - ($('dropmarker') || Element.extend(document.createElement('DIV'))). - hide().addClassName('dropmarker').setStyle({position:'absolute'}); - document.getElementsByTagName("body").item(0).appendChild(Sortable._marker); - } - var offsets = dropon.cumulativeOffset(); - Sortable._marker.setStyle({left: offsets[0]+'px', top: offsets[1] + 'px'}); - - if(position=='after') - if(sortable.overlap == 'horizontal') - Sortable._marker.setStyle({left: (offsets[0]+dropon.clientWidth) + 'px'}); - else - Sortable._marker.setStyle({top: (offsets[1]+dropon.clientHeight) + 'px'}); - - Sortable._marker.show(); - }, - - _tree: function(element, options, parent) { - var children = Sortable.findElements(element, options) || []; - - for (var i = 0; i < children.length; ++i) { - var match = children[i].id.match(options.format); - - if (!match) continue; - - var child = { - id: encodeURIComponent(match ? match[1] : null), - element: element, - parent: parent, - children: [], - position: parent.children.length, - container: $(children[i]).down(options.treeTag) - }; - - /* Get the element containing the children and recurse over it */ - if (child.container) - this._tree(child.container, options, child); - - parent.children.push (child); - } - - return parent; - }, - - tree: function(element) { - element = $(element); - var sortableOptions = this.options(element); - var options = Object.extend({ - tag: sortableOptions.tag, - treeTag: sortableOptions.treeTag, - only: sortableOptions.only, - name: element.id, - format: sortableOptions.format - }, arguments[1] || { }); - - var root = { - id: null, - parent: null, - children: [], - container: element, - position: 0 - }; - - return Sortable._tree(element, options, root); - }, - - /* Construct a [i] index for a particular node */ - _constructIndex: function(node) { - var index = ''; - do { - if (node.id) index = '[' + node.position + ']' + index; - } while ((node = node.parent) != null); - return index; - }, - - sequence: function(element) { - element = $(element); - var options = Object.extend(this.options(element), arguments[1] || { }); - - return $(this.findElements(element, options) || []).map( function(item) { - return item.id.match(options.format) ? item.id.match(options.format)[1] : ''; - }); - }, - - setSequence: function(element, new_sequence) { - element = $(element); - var options = Object.extend(this.options(element), arguments[2] || { }); - - var nodeMap = { }; - this.findElements(element, options).each( function(n) { - if (n.id.match(options.format)) - nodeMap[n.id.match(options.format)[1]] = [n, n.parentNode]; - n.parentNode.removeChild(n); - }); - - new_sequence.each(function(ident) { - var n = nodeMap[ident]; - if (n) { - n[1].appendChild(n[0]); - delete nodeMap[ident]; - } - }); - }, - - serialize: function(element) { - element = $(element); - var options = Object.extend(Sortable.options(element), arguments[1] || { }); - var name = encodeURIComponent( - (arguments[1] && arguments[1].name) ? arguments[1].name : element.id); - - if (options.tree) { - return Sortable.tree(element, arguments[1]).children.map( function (item) { - return [name + Sortable._constructIndex(item) + "[id]=" + - encodeURIComponent(item.id)].concat(item.children.map(arguments.callee)); - }).flatten().join('&'); - } else { - return Sortable.sequence(element, arguments[1]).map( function(item) { - return name + "[]=" + encodeURIComponent(item); - }).join('&'); - } - } -}; - -// Returns true if child is contained within element -Element.isParent = function(child, element) { - if (!child.parentNode || child == element) return false; - if (child.parentNode == element) return true; - return Element.isParent(child.parentNode, element); -}; - -Element.findChildren = function(element, only, recursive, tagName) { - if(!element.hasChildNodes()) return null; - tagName = tagName.toUpperCase(); - if(only) only = [only].flatten(); - var elements = []; - $A(element.childNodes).each( function(e) { - if(e.tagName && e.tagName.toUpperCase()==tagName && - (!only || (Element.classNames(e).detect(function(v) { return only.include(v) })))) - elements.push(e); - if(recursive) { - var grandchildren = Element.findChildren(e, only, recursive, tagName); - if(grandchildren) elements.push(grandchildren); - } - }); - - return (elements.length>0 ? elements.flatten() : []); -}; - -Element.offsetSize = function (element, type) { - return element['offset' + ((type=='vertical' || type=='height') ? 'Height' : 'Width')]; -}; \ No newline at end of file diff --git a/test/dummy/public/javascripts/effects.js b/test/dummy/public/javascripts/effects.js deleted file mode 100644 index c81e6c7d..00000000 --- a/test/dummy/public/javascripts/effects.js +++ /dev/null @@ -1,1123 +0,0 @@ -// script.aculo.us effects.js v1.8.3, Thu Oct 08 11:23:33 +0200 2009 - -// Copyright (c) 2005-2009 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) -// Contributors: -// Justin Palmer (http://encytemedia.com/) -// Mark Pilgrim (http://diveintomark.org/) -// Martin Bialasinki -// -// script.aculo.us is freely distributable under the terms of an MIT-style license. -// For details, see the script.aculo.us web site: http://script.aculo.us/ - -// converts rgb() and #xxx to #xxxxxx format, -// returns self (or first argument) if not convertable -String.prototype.parseColor = function() { - var color = '#'; - if (this.slice(0,4) == 'rgb(') { - var cols = this.slice(4,this.length-1).split(','); - var i=0; do { color += parseInt(cols[i]).toColorPart() } while (++i<3); - } else { - if (this.slice(0,1) == '#') { - if (this.length==4) for(var i=1;i<4;i++) color += (this.charAt(i) + this.charAt(i)).toLowerCase(); - if (this.length==7) color = this.toLowerCase(); - } - } - return (color.length==7 ? color : (arguments[0] || this)); -}; - -/*--------------------------------------------------------------------------*/ - -Element.collectTextNodes = function(element) { - return $A($(element).childNodes).collect( function(node) { - return (node.nodeType==3 ? node.nodeValue : - (node.hasChildNodes() ? Element.collectTextNodes(node) : '')); - }).flatten().join(''); -}; - -Element.collectTextNodesIgnoreClass = function(element, className) { - return $A($(element).childNodes).collect( function(node) { - return (node.nodeType==3 ? node.nodeValue : - ((node.hasChildNodes() && !Element.hasClassName(node,className)) ? - Element.collectTextNodesIgnoreClass(node, className) : '')); - }).flatten().join(''); -}; - -Element.setContentZoom = function(element, percent) { - element = $(element); - element.setStyle({fontSize: (percent/100) + 'em'}); - if (Prototype.Browser.WebKit) window.scrollBy(0,0); - return element; -}; - -Element.getInlineOpacity = function(element){ - return $(element).style.opacity || ''; -}; - -Element.forceRerendering = function(element) { - try { - element = $(element); - var n = document.createTextNode(' '); - element.appendChild(n); - element.removeChild(n); - } catch(e) { } -}; - -/*--------------------------------------------------------------------------*/ - -var Effect = { - _elementDoesNotExistError: { - name: 'ElementDoesNotExistError', - message: 'The specified DOM element does not exist, but is required for this effect to operate' - }, - Transitions: { - linear: Prototype.K, - sinoidal: function(pos) { - return (-Math.cos(pos*Math.PI)/2) + .5; - }, - reverse: function(pos) { - return 1-pos; - }, - flicker: function(pos) { - var pos = ((-Math.cos(pos*Math.PI)/4) + .75) + Math.random()/4; - return pos > 1 ? 1 : pos; - }, - wobble: function(pos) { - return (-Math.cos(pos*Math.PI*(9*pos))/2) + .5; - }, - pulse: function(pos, pulses) { - return (-Math.cos((pos*((pulses||5)-.5)*2)*Math.PI)/2) + .5; - }, - spring: function(pos) { - return 1 - (Math.cos(pos * 4.5 * Math.PI) * Math.exp(-pos * 6)); - }, - none: function(pos) { - return 0; - }, - full: function(pos) { - return 1; - } - }, - DefaultOptions: { - duration: 1.0, // seconds - fps: 100, // 100= assume 66fps max. - sync: false, // true for combining - from: 0.0, - to: 1.0, - delay: 0.0, - queue: 'parallel' - }, - tagifyText: function(element) { - var tagifyStyle = 'position:relative'; - if (Prototype.Browser.IE) tagifyStyle += ';zoom:1'; - - element = $(element); - $A(element.childNodes).each( function(child) { - if (child.nodeType==3) { - child.nodeValue.toArray().each( function(character) { - element.insertBefore( - new Element('span', {style: tagifyStyle}).update( - character == ' ' ? String.fromCharCode(160) : character), - child); - }); - Element.remove(child); - } - }); - }, - multiple: function(element, effect) { - var elements; - if (((typeof element == 'object') || - Object.isFunction(element)) && - (element.length)) - elements = element; - else - elements = $(element).childNodes; - - var options = Object.extend({ - speed: 0.1, - delay: 0.0 - }, arguments[2] || { }); - var masterDelay = options.delay; - - $A(elements).each( function(element, index) { - new effect(element, Object.extend(options, { delay: index * options.speed + masterDelay })); - }); - }, - PAIRS: { - 'slide': ['SlideDown','SlideUp'], - 'blind': ['BlindDown','BlindUp'], - 'appear': ['Appear','Fade'] - }, - toggle: function(element, effect, options) { - element = $(element); - effect = (effect || 'appear').toLowerCase(); - - return Effect[ Effect.PAIRS[ effect ][ element.visible() ? 1 : 0 ] ](element, Object.extend({ - queue: { position:'end', scope:(element.id || 'global'), limit: 1 } - }, options || {})); - } -}; - -Effect.DefaultOptions.transition = Effect.Transitions.sinoidal; - -/* ------------- core effects ------------- */ - -Effect.ScopedQueue = Class.create(Enumerable, { - initialize: function() { - this.effects = []; - this.interval = null; - }, - _each: function(iterator) { - this.effects._each(iterator); - }, - add: function(effect) { - var timestamp = new Date().getTime(); - - var position = Object.isString(effect.options.queue) ? - effect.options.queue : effect.options.queue.position; - - switch(position) { - case 'front': - // move unstarted effects after this effect - this.effects.findAll(function(e){ return e.state=='idle' }).each( function(e) { - e.startOn += effect.finishOn; - e.finishOn += effect.finishOn; - }); - break; - case 'with-last': - timestamp = this.effects.pluck('startOn').max() || timestamp; - break; - case 'end': - // start effect after last queued effect has finished - timestamp = this.effects.pluck('finishOn').max() || timestamp; - break; - } - - effect.startOn += timestamp; - effect.finishOn += timestamp; - - if (!effect.options.queue.limit || (this.effects.length < effect.options.queue.limit)) - this.effects.push(effect); - - if (!this.interval) - this.interval = setInterval(this.loop.bind(this), 15); - }, - remove: function(effect) { - this.effects = this.effects.reject(function(e) { return e==effect }); - if (this.effects.length == 0) { - clearInterval(this.interval); - this.interval = null; - } - }, - loop: function() { - var timePos = new Date().getTime(); - for(var i=0, len=this.effects.length;i= this.startOn) { - if (timePos >= this.finishOn) { - this.render(1.0); - this.cancel(); - this.event('beforeFinish'); - if (this.finish) this.finish(); - this.event('afterFinish'); - return; - } - var pos = (timePos - this.startOn) / this.totalTime, - frame = (pos * this.totalFrames).round(); - if (frame > this.currentFrame) { - this.render(pos); - this.currentFrame = frame; - } - } - }, - cancel: function() { - if (!this.options.sync) - Effect.Queues.get(Object.isString(this.options.queue) ? - 'global' : this.options.queue.scope).remove(this); - this.state = 'finished'; - }, - event: function(eventName) { - if (this.options[eventName + 'Internal']) this.options[eventName + 'Internal'](this); - if (this.options[eventName]) this.options[eventName](this); - }, - inspect: function() { - var data = $H(); - for(property in this) - if (!Object.isFunction(this[property])) data.set(property, this[property]); - return '#'; - } -}); - -Effect.Parallel = Class.create(Effect.Base, { - initialize: function(effects) { - this.effects = effects || []; - this.start(arguments[1]); - }, - update: function(position) { - this.effects.invoke('render', position); - }, - finish: function(position) { - this.effects.each( function(effect) { - effect.render(1.0); - effect.cancel(); - effect.event('beforeFinish'); - if (effect.finish) effect.finish(position); - effect.event('afterFinish'); - }); - } -}); - -Effect.Tween = Class.create(Effect.Base, { - initialize: function(object, from, to) { - object = Object.isString(object) ? $(object) : object; - var args = $A(arguments), method = args.last(), - options = args.length == 5 ? args[3] : null; - this.method = Object.isFunction(method) ? method.bind(object) : - Object.isFunction(object[method]) ? object[method].bind(object) : - function(value) { object[method] = value }; - this.start(Object.extend({ from: from, to: to }, options || { })); - }, - update: function(position) { - this.method(position); - } -}); - -Effect.Event = Class.create(Effect.Base, { - initialize: function() { - this.start(Object.extend({ duration: 0 }, arguments[0] || { })); - }, - update: Prototype.emptyFunction -}); - -Effect.Opacity = Class.create(Effect.Base, { - initialize: function(element) { - this.element = $(element); - if (!this.element) throw(Effect._elementDoesNotExistError); - // make this work on IE on elements without 'layout' - if (Prototype.Browser.IE && (!this.element.currentStyle.hasLayout)) - this.element.setStyle({zoom: 1}); - var options = Object.extend({ - from: this.element.getOpacity() || 0.0, - to: 1.0 - }, arguments[1] || { }); - this.start(options); - }, - update: function(position) { - this.element.setOpacity(position); - } -}); - -Effect.Move = Class.create(Effect.Base, { - initialize: function(element) { - this.element = $(element); - if (!this.element) throw(Effect._elementDoesNotExistError); - var options = Object.extend({ - x: 0, - y: 0, - mode: 'relative' - }, arguments[1] || { }); - this.start(options); - }, - setup: function() { - this.element.makePositioned(); - this.originalLeft = parseFloat(this.element.getStyle('left') || '0'); - this.originalTop = parseFloat(this.element.getStyle('top') || '0'); - if (this.options.mode == 'absolute') { - this.options.x = this.options.x - this.originalLeft; - this.options.y = this.options.y - this.originalTop; - } - }, - update: function(position) { - this.element.setStyle({ - left: (this.options.x * position + this.originalLeft).round() + 'px', - top: (this.options.y * position + this.originalTop).round() + 'px' - }); - } -}); - -// for backwards compatibility -Effect.MoveBy = function(element, toTop, toLeft) { - return new Effect.Move(element, - Object.extend({ x: toLeft, y: toTop }, arguments[3] || { })); -}; - -Effect.Scale = Class.create(Effect.Base, { - initialize: function(element, percent) { - this.element = $(element); - if (!this.element) throw(Effect._elementDoesNotExistError); - var options = Object.extend({ - scaleX: true, - scaleY: true, - scaleContent: true, - scaleFromCenter: false, - scaleMode: 'box', // 'box' or 'contents' or { } with provided values - scaleFrom: 100.0, - scaleTo: percent - }, arguments[2] || { }); - this.start(options); - }, - setup: function() { - this.restoreAfterFinish = this.options.restoreAfterFinish || false; - this.elementPositioning = this.element.getStyle('position'); - - this.originalStyle = { }; - ['top','left','width','height','fontSize'].each( function(k) { - this.originalStyle[k] = this.element.style[k]; - }.bind(this)); - - this.originalTop = this.element.offsetTop; - this.originalLeft = this.element.offsetLeft; - - var fontSize = this.element.getStyle('font-size') || '100%'; - ['em','px','%','pt'].each( function(fontSizeType) { - if (fontSize.indexOf(fontSizeType)>0) { - this.fontSize = parseFloat(fontSize); - this.fontSizeType = fontSizeType; - } - }.bind(this)); - - this.factor = (this.options.scaleTo - this.options.scaleFrom)/100; - - this.dims = null; - if (this.options.scaleMode=='box') - this.dims = [this.element.offsetHeight, this.element.offsetWidth]; - if (/^content/.test(this.options.scaleMode)) - this.dims = [this.element.scrollHeight, this.element.scrollWidth]; - if (!this.dims) - this.dims = [this.options.scaleMode.originalHeight, - this.options.scaleMode.originalWidth]; - }, - update: function(position) { - var currentScale = (this.options.scaleFrom/100.0) + (this.factor * position); - if (this.options.scaleContent && this.fontSize) - this.element.setStyle({fontSize: this.fontSize * currentScale + this.fontSizeType }); - this.setDimensions(this.dims[0] * currentScale, this.dims[1] * currentScale); - }, - finish: function(position) { - if (this.restoreAfterFinish) this.element.setStyle(this.originalStyle); - }, - setDimensions: function(height, width) { - var d = { }; - if (this.options.scaleX) d.width = width.round() + 'px'; - if (this.options.scaleY) d.height = height.round() + 'px'; - if (this.options.scaleFromCenter) { - var topd = (height - this.dims[0])/2; - var leftd = (width - this.dims[1])/2; - if (this.elementPositioning == 'absolute') { - if (this.options.scaleY) d.top = this.originalTop-topd + 'px'; - if (this.options.scaleX) d.left = this.originalLeft-leftd + 'px'; - } else { - if (this.options.scaleY) d.top = -topd + 'px'; - if (this.options.scaleX) d.left = -leftd + 'px'; - } - } - this.element.setStyle(d); - } -}); - -Effect.Highlight = Class.create(Effect.Base, { - initialize: function(element) { - this.element = $(element); - if (!this.element) throw(Effect._elementDoesNotExistError); - var options = Object.extend({ startcolor: '#ffff99' }, arguments[1] || { }); - this.start(options); - }, - setup: function() { - // Prevent executing on elements not in the layout flow - if (this.element.getStyle('display')=='none') { this.cancel(); return; } - // Disable background image during the effect - this.oldStyle = { }; - if (!this.options.keepBackgroundImage) { - this.oldStyle.backgroundImage = this.element.getStyle('background-image'); - this.element.setStyle({backgroundImage: 'none'}); - } - if (!this.options.endcolor) - this.options.endcolor = this.element.getStyle('background-color').parseColor('#ffffff'); - if (!this.options.restorecolor) - this.options.restorecolor = this.element.getStyle('background-color'); - // init color calculations - this._base = $R(0,2).map(function(i){ return parseInt(this.options.startcolor.slice(i*2+1,i*2+3),16) }.bind(this)); - this._delta = $R(0,2).map(function(i){ return parseInt(this.options.endcolor.slice(i*2+1,i*2+3),16)-this._base[i] }.bind(this)); - }, - update: function(position) { - this.element.setStyle({backgroundColor: $R(0,2).inject('#',function(m,v,i){ - return m+((this._base[i]+(this._delta[i]*position)).round().toColorPart()); }.bind(this)) }); - }, - finish: function() { - this.element.setStyle(Object.extend(this.oldStyle, { - backgroundColor: this.options.restorecolor - })); - } -}); - -Effect.ScrollTo = function(element) { - var options = arguments[1] || { }, - scrollOffsets = document.viewport.getScrollOffsets(), - elementOffsets = $(element).cumulativeOffset(); - - if (options.offset) elementOffsets[1] += options.offset; - - return new Effect.Tween(null, - scrollOffsets.top, - elementOffsets[1], - options, - function(p){ scrollTo(scrollOffsets.left, p.round()); } - ); -}; - -/* ------------- combination effects ------------- */ - -Effect.Fade = function(element) { - element = $(element); - var oldOpacity = element.getInlineOpacity(); - var options = Object.extend({ - from: element.getOpacity() || 1.0, - to: 0.0, - afterFinishInternal: function(effect) { - if (effect.options.to!=0) return; - effect.element.hide().setStyle({opacity: oldOpacity}); - } - }, arguments[1] || { }); - return new Effect.Opacity(element,options); -}; - -Effect.Appear = function(element) { - element = $(element); - var options = Object.extend({ - from: (element.getStyle('display') == 'none' ? 0.0 : element.getOpacity() || 0.0), - to: 1.0, - // force Safari to render floated elements properly - afterFinishInternal: function(effect) { - effect.element.forceRerendering(); - }, - beforeSetup: function(effect) { - effect.element.setOpacity(effect.options.from).show(); - }}, arguments[1] || { }); - return new Effect.Opacity(element,options); -}; - -Effect.Puff = function(element) { - element = $(element); - var oldStyle = { - opacity: element.getInlineOpacity(), - position: element.getStyle('position'), - top: element.style.top, - left: element.style.left, - width: element.style.width, - height: element.style.height - }; - return new Effect.Parallel( - [ new Effect.Scale(element, 200, - { sync: true, scaleFromCenter: true, scaleContent: true, restoreAfterFinish: true }), - new Effect.Opacity(element, { sync: true, to: 0.0 } ) ], - Object.extend({ duration: 1.0, - beforeSetupInternal: function(effect) { - Position.absolutize(effect.effects[0].element); - }, - afterFinishInternal: function(effect) { - effect.effects[0].element.hide().setStyle(oldStyle); } - }, arguments[1] || { }) - ); -}; - -Effect.BlindUp = function(element) { - element = $(element); - element.makeClipping(); - return new Effect.Scale(element, 0, - Object.extend({ scaleContent: false, - scaleX: false, - restoreAfterFinish: true, - afterFinishInternal: function(effect) { - effect.element.hide().undoClipping(); - } - }, arguments[1] || { }) - ); -}; - -Effect.BlindDown = function(element) { - element = $(element); - var elementDimensions = element.getDimensions(); - return new Effect.Scale(element, 100, Object.extend({ - scaleContent: false, - scaleX: false, - scaleFrom: 0, - scaleMode: {originalHeight: elementDimensions.height, originalWidth: elementDimensions.width}, - restoreAfterFinish: true, - afterSetup: function(effect) { - effect.element.makeClipping().setStyle({height: '0px'}).show(); - }, - afterFinishInternal: function(effect) { - effect.element.undoClipping(); - } - }, arguments[1] || { })); -}; - -Effect.SwitchOff = function(element) { - element = $(element); - var oldOpacity = element.getInlineOpacity(); - return new Effect.Appear(element, Object.extend({ - duration: 0.4, - from: 0, - transition: Effect.Transitions.flicker, - afterFinishInternal: function(effect) { - new Effect.Scale(effect.element, 1, { - duration: 0.3, scaleFromCenter: true, - scaleX: false, scaleContent: false, restoreAfterFinish: true, - beforeSetup: function(effect) { - effect.element.makePositioned().makeClipping(); - }, - afterFinishInternal: function(effect) { - effect.element.hide().undoClipping().undoPositioned().setStyle({opacity: oldOpacity}); - } - }); - } - }, arguments[1] || { })); -}; - -Effect.DropOut = function(element) { - element = $(element); - var oldStyle = { - top: element.getStyle('top'), - left: element.getStyle('left'), - opacity: element.getInlineOpacity() }; - return new Effect.Parallel( - [ new Effect.Move(element, {x: 0, y: 100, sync: true }), - new Effect.Opacity(element, { sync: true, to: 0.0 }) ], - Object.extend( - { duration: 0.5, - beforeSetup: function(effect) { - effect.effects[0].element.makePositioned(); - }, - afterFinishInternal: function(effect) { - effect.effects[0].element.hide().undoPositioned().setStyle(oldStyle); - } - }, arguments[1] || { })); -}; - -Effect.Shake = function(element) { - element = $(element); - var options = Object.extend({ - distance: 20, - duration: 0.5 - }, arguments[1] || {}); - var distance = parseFloat(options.distance); - var split = parseFloat(options.duration) / 10.0; - var oldStyle = { - top: element.getStyle('top'), - left: element.getStyle('left') }; - return new Effect.Move(element, - { x: distance, y: 0, duration: split, afterFinishInternal: function(effect) { - new Effect.Move(effect.element, - { x: -distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) { - new Effect.Move(effect.element, - { x: distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) { - new Effect.Move(effect.element, - { x: -distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) { - new Effect.Move(effect.element, - { x: distance*2, y: 0, duration: split*2, afterFinishInternal: function(effect) { - new Effect.Move(effect.element, - { x: -distance, y: 0, duration: split, afterFinishInternal: function(effect) { - effect.element.undoPositioned().setStyle(oldStyle); - }}); }}); }}); }}); }}); }}); -}; - -Effect.SlideDown = function(element) { - element = $(element).cleanWhitespace(); - // SlideDown need to have the content of the element wrapped in a container element with fixed height! - var oldInnerBottom = element.down().getStyle('bottom'); - var elementDimensions = element.getDimensions(); - return new Effect.Scale(element, 100, Object.extend({ - scaleContent: false, - scaleX: false, - scaleFrom: window.opera ? 0 : 1, - scaleMode: {originalHeight: elementDimensions.height, originalWidth: elementDimensions.width}, - restoreAfterFinish: true, - afterSetup: function(effect) { - effect.element.makePositioned(); - effect.element.down().makePositioned(); - if (window.opera) effect.element.setStyle({top: ''}); - effect.element.makeClipping().setStyle({height: '0px'}).show(); - }, - afterUpdateInternal: function(effect) { - effect.element.down().setStyle({bottom: - (effect.dims[0] - effect.element.clientHeight) + 'px' }); - }, - afterFinishInternal: function(effect) { - effect.element.undoClipping().undoPositioned(); - effect.element.down().undoPositioned().setStyle({bottom: oldInnerBottom}); } - }, arguments[1] || { }) - ); -}; - -Effect.SlideUp = function(element) { - element = $(element).cleanWhitespace(); - var oldInnerBottom = element.down().getStyle('bottom'); - var elementDimensions = element.getDimensions(); - return new Effect.Scale(element, window.opera ? 0 : 1, - Object.extend({ scaleContent: false, - scaleX: false, - scaleMode: 'box', - scaleFrom: 100, - scaleMode: {originalHeight: elementDimensions.height, originalWidth: elementDimensions.width}, - restoreAfterFinish: true, - afterSetup: function(effect) { - effect.element.makePositioned(); - effect.element.down().makePositioned(); - if (window.opera) effect.element.setStyle({top: ''}); - effect.element.makeClipping().show(); - }, - afterUpdateInternal: function(effect) { - effect.element.down().setStyle({bottom: - (effect.dims[0] - effect.element.clientHeight) + 'px' }); - }, - afterFinishInternal: function(effect) { - effect.element.hide().undoClipping().undoPositioned(); - effect.element.down().undoPositioned().setStyle({bottom: oldInnerBottom}); - } - }, arguments[1] || { }) - ); -}; - -// Bug in opera makes the TD containing this element expand for a instance after finish -Effect.Squish = function(element) { - return new Effect.Scale(element, window.opera ? 1 : 0, { - restoreAfterFinish: true, - beforeSetup: function(effect) { - effect.element.makeClipping(); - }, - afterFinishInternal: function(effect) { - effect.element.hide().undoClipping(); - } - }); -}; - -Effect.Grow = function(element) { - element = $(element); - var options = Object.extend({ - direction: 'center', - moveTransition: Effect.Transitions.sinoidal, - scaleTransition: Effect.Transitions.sinoidal, - opacityTransition: Effect.Transitions.full - }, arguments[1] || { }); - var oldStyle = { - top: element.style.top, - left: element.style.left, - height: element.style.height, - width: element.style.width, - opacity: element.getInlineOpacity() }; - - var dims = element.getDimensions(); - var initialMoveX, initialMoveY; - var moveX, moveY; - - switch (options.direction) { - case 'top-left': - initialMoveX = initialMoveY = moveX = moveY = 0; - break; - case 'top-right': - initialMoveX = dims.width; - initialMoveY = moveY = 0; - moveX = -dims.width; - break; - case 'bottom-left': - initialMoveX = moveX = 0; - initialMoveY = dims.height; - moveY = -dims.height; - break; - case 'bottom-right': - initialMoveX = dims.width; - initialMoveY = dims.height; - moveX = -dims.width; - moveY = -dims.height; - break; - case 'center': - initialMoveX = dims.width / 2; - initialMoveY = dims.height / 2; - moveX = -dims.width / 2; - moveY = -dims.height / 2; - break; - } - - return new Effect.Move(element, { - x: initialMoveX, - y: initialMoveY, - duration: 0.01, - beforeSetup: function(effect) { - effect.element.hide().makeClipping().makePositioned(); - }, - afterFinishInternal: function(effect) { - new Effect.Parallel( - [ new Effect.Opacity(effect.element, { sync: true, to: 1.0, from: 0.0, transition: options.opacityTransition }), - new Effect.Move(effect.element, { x: moveX, y: moveY, sync: true, transition: options.moveTransition }), - new Effect.Scale(effect.element, 100, { - scaleMode: { originalHeight: dims.height, originalWidth: dims.width }, - sync: true, scaleFrom: window.opera ? 1 : 0, transition: options.scaleTransition, restoreAfterFinish: true}) - ], Object.extend({ - beforeSetup: function(effect) { - effect.effects[0].element.setStyle({height: '0px'}).show(); - }, - afterFinishInternal: function(effect) { - effect.effects[0].element.undoClipping().undoPositioned().setStyle(oldStyle); - } - }, options) - ); - } - }); -}; - -Effect.Shrink = function(element) { - element = $(element); - var options = Object.extend({ - direction: 'center', - moveTransition: Effect.Transitions.sinoidal, - scaleTransition: Effect.Transitions.sinoidal, - opacityTransition: Effect.Transitions.none - }, arguments[1] || { }); - var oldStyle = { - top: element.style.top, - left: element.style.left, - height: element.style.height, - width: element.style.width, - opacity: element.getInlineOpacity() }; - - var dims = element.getDimensions(); - var moveX, moveY; - - switch (options.direction) { - case 'top-left': - moveX = moveY = 0; - break; - case 'top-right': - moveX = dims.width; - moveY = 0; - break; - case 'bottom-left': - moveX = 0; - moveY = dims.height; - break; - case 'bottom-right': - moveX = dims.width; - moveY = dims.height; - break; - case 'center': - moveX = dims.width / 2; - moveY = dims.height / 2; - break; - } - - return new Effect.Parallel( - [ new Effect.Opacity(element, { sync: true, to: 0.0, from: 1.0, transition: options.opacityTransition }), - new Effect.Scale(element, window.opera ? 1 : 0, { sync: true, transition: options.scaleTransition, restoreAfterFinish: true}), - new Effect.Move(element, { x: moveX, y: moveY, sync: true, transition: options.moveTransition }) - ], Object.extend({ - beforeStartInternal: function(effect) { - effect.effects[0].element.makePositioned().makeClipping(); - }, - afterFinishInternal: function(effect) { - effect.effects[0].element.hide().undoClipping().undoPositioned().setStyle(oldStyle); } - }, options) - ); -}; - -Effect.Pulsate = function(element) { - element = $(element); - var options = arguments[1] || { }, - oldOpacity = element.getInlineOpacity(), - transition = options.transition || Effect.Transitions.linear, - reverser = function(pos){ - return 1 - transition((-Math.cos((pos*(options.pulses||5)*2)*Math.PI)/2) + .5); - }; - - return new Effect.Opacity(element, - Object.extend(Object.extend({ duration: 2.0, from: 0, - afterFinishInternal: function(effect) { effect.element.setStyle({opacity: oldOpacity}); } - }, options), {transition: reverser})); -}; - -Effect.Fold = function(element) { - element = $(element); - var oldStyle = { - top: element.style.top, - left: element.style.left, - width: element.style.width, - height: element.style.height }; - element.makeClipping(); - return new Effect.Scale(element, 5, Object.extend({ - scaleContent: false, - scaleX: false, - afterFinishInternal: function(effect) { - new Effect.Scale(element, 1, { - scaleContent: false, - scaleY: false, - afterFinishInternal: function(effect) { - effect.element.hide().undoClipping().setStyle(oldStyle); - } }); - }}, arguments[1] || { })); -}; - -Effect.Morph = Class.create(Effect.Base, { - initialize: function(element) { - this.element = $(element); - if (!this.element) throw(Effect._elementDoesNotExistError); - var options = Object.extend({ - style: { } - }, arguments[1] || { }); - - if (!Object.isString(options.style)) this.style = $H(options.style); - else { - if (options.style.include(':')) - this.style = options.style.parseStyle(); - else { - this.element.addClassName(options.style); - this.style = $H(this.element.getStyles()); - this.element.removeClassName(options.style); - var css = this.element.getStyles(); - this.style = this.style.reject(function(style) { - return style.value == css[style.key]; - }); - options.afterFinishInternal = function(effect) { - effect.element.addClassName(effect.options.style); - effect.transforms.each(function(transform) { - effect.element.style[transform.style] = ''; - }); - }; - } - } - this.start(options); - }, - - setup: function(){ - function parseColor(color){ - if (!color || ['rgba(0, 0, 0, 0)','transparent'].include(color)) color = '#ffffff'; - color = color.parseColor(); - return $R(0,2).map(function(i){ - return parseInt( color.slice(i*2+1,i*2+3), 16 ); - }); - } - this.transforms = this.style.map(function(pair){ - var property = pair[0], value = pair[1], unit = null; - - if (value.parseColor('#zzzzzz') != '#zzzzzz') { - value = value.parseColor(); - unit = 'color'; - } else if (property == 'opacity') { - value = parseFloat(value); - if (Prototype.Browser.IE && (!this.element.currentStyle.hasLayout)) - this.element.setStyle({zoom: 1}); - } else if (Element.CSS_LENGTH.test(value)) { - var components = value.match(/^([\+\-]?[0-9\.]+)(.*)$/); - value = parseFloat(components[1]); - unit = (components.length == 3) ? components[2] : null; - } - - var originalValue = this.element.getStyle(property); - return { - style: property.camelize(), - originalValue: unit=='color' ? parseColor(originalValue) : parseFloat(originalValue || 0), - targetValue: unit=='color' ? parseColor(value) : value, - unit: unit - }; - }.bind(this)).reject(function(transform){ - return ( - (transform.originalValue == transform.targetValue) || - ( - transform.unit != 'color' && - (isNaN(transform.originalValue) || isNaN(transform.targetValue)) - ) - ); - }); - }, - update: function(position) { - var style = { }, transform, i = this.transforms.length; - while(i--) - style[(transform = this.transforms[i]).style] = - transform.unit=='color' ? '#'+ - (Math.round(transform.originalValue[0]+ - (transform.targetValue[0]-transform.originalValue[0])*position)).toColorPart() + - (Math.round(transform.originalValue[1]+ - (transform.targetValue[1]-transform.originalValue[1])*position)).toColorPart() + - (Math.round(transform.originalValue[2]+ - (transform.targetValue[2]-transform.originalValue[2])*position)).toColorPart() : - (transform.originalValue + - (transform.targetValue - transform.originalValue) * position).toFixed(3) + - (transform.unit === null ? '' : transform.unit); - this.element.setStyle(style, true); - } -}); - -Effect.Transform = Class.create({ - initialize: function(tracks){ - this.tracks = []; - this.options = arguments[1] || { }; - this.addTracks(tracks); - }, - addTracks: function(tracks){ - tracks.each(function(track){ - track = $H(track); - var data = track.values().first(); - this.tracks.push($H({ - ids: track.keys().first(), - effect: Effect.Morph, - options: { style: data } - })); - }.bind(this)); - return this; - }, - play: function(){ - return new Effect.Parallel( - this.tracks.map(function(track){ - var ids = track.get('ids'), effect = track.get('effect'), options = track.get('options'); - var elements = [$(ids) || $$(ids)].flatten(); - return elements.map(function(e){ return new effect(e, Object.extend({ sync:true }, options)) }); - }).flatten(), - this.options - ); - } -}); - -Element.CSS_PROPERTIES = $w( - 'backgroundColor backgroundPosition borderBottomColor borderBottomStyle ' + - 'borderBottomWidth borderLeftColor borderLeftStyle borderLeftWidth ' + - 'borderRightColor borderRightStyle borderRightWidth borderSpacing ' + - 'borderTopColor borderTopStyle borderTopWidth bottom clip color ' + - 'fontSize fontWeight height left letterSpacing lineHeight ' + - 'marginBottom marginLeft marginRight marginTop markerOffset maxHeight '+ - 'maxWidth minHeight minWidth opacity outlineColor outlineOffset ' + - 'outlineWidth paddingBottom paddingLeft paddingRight paddingTop ' + - 'right textIndent top width wordSpacing zIndex'); - -Element.CSS_LENGTH = /^(([\+\-]?[0-9\.]+)(em|ex|px|in|cm|mm|pt|pc|\%))|0$/; - -String.__parseStyleElement = document.createElement('div'); -String.prototype.parseStyle = function(){ - var style, styleRules = $H(); - if (Prototype.Browser.WebKit) - style = new Element('div',{style:this}).style; - else { - String.__parseStyleElement.innerHTML = '
    '; - style = String.__parseStyleElement.childNodes[0].style; - } - - Element.CSS_PROPERTIES.each(function(property){ - if (style[property]) styleRules.set(property, style[property]); - }); - - if (Prototype.Browser.IE && this.include('opacity')) - styleRules.set('opacity', this.match(/opacity:\s*((?:0|1)?(?:\.\d*)?)/)[1]); - - return styleRules; -}; - -if (document.defaultView && document.defaultView.getComputedStyle) { - Element.getStyles = function(element) { - var css = document.defaultView.getComputedStyle($(element), null); - return Element.CSS_PROPERTIES.inject({ }, function(styles, property) { - styles[property] = css[property]; - return styles; - }); - }; -} else { - Element.getStyles = function(element) { - element = $(element); - var css = element.currentStyle, styles; - styles = Element.CSS_PROPERTIES.inject({ }, function(results, property) { - results[property] = css[property]; - return results; - }); - if (!styles.opacity) styles.opacity = element.getOpacity(); - return styles; - }; -} - -Effect.Methods = { - morph: function(element, style) { - element = $(element); - new Effect.Morph(element, Object.extend({ style: style }, arguments[2] || { })); - return element; - }, - visualEffect: function(element, effect, options) { - element = $(element); - var s = effect.dasherize().camelize(), klass = s.charAt(0).toUpperCase() + s.substring(1); - new Effect[klass](element, options); - return element; - }, - highlight: function(element, options) { - element = $(element); - new Effect.Highlight(element, options); - return element; - } -}; - -$w('fade appear grow shrink fold blindUp blindDown slideUp slideDown '+ - 'pulsate shake puff squish switchOff dropOut').each( - function(effect) { - Effect.Methods[effect] = function(element, options){ - element = $(element); - Effect[effect.charAt(0).toUpperCase() + effect.substring(1)](element, options); - return element; - }; - } -); - -$w('getInlineOpacity forceRerendering setContentZoom collectTextNodes collectTextNodesIgnoreClass getStyles').each( - function(f) { Effect.Methods[f] = Element[f]; } -); - -Element.addMethods(Effect.Methods); \ No newline at end of file diff --git a/test/dummy/public/javascripts/prototype.js b/test/dummy/public/javascripts/prototype.js deleted file mode 100644 index 06249a6a..00000000 --- a/test/dummy/public/javascripts/prototype.js +++ /dev/null @@ -1,6001 +0,0 @@ -/* Prototype JavaScript framework, version 1.7_rc2 - * (c) 2005-2010 Sam Stephenson - * - * Prototype is freely distributable under the terms of an MIT-style license. - * For details, see the Prototype web site: http://www.prototypejs.org/ - * - *--------------------------------------------------------------------------*/ - -var Prototype = { - - Version: '1.7_rc2', - - Browser: (function(){ - var ua = navigator.userAgent; - var isOpera = Object.prototype.toString.call(window.opera) == '[object Opera]'; - return { - IE: !!window.attachEvent && !isOpera, - Opera: isOpera, - WebKit: ua.indexOf('AppleWebKit/') > -1, - Gecko: ua.indexOf('Gecko') > -1 && ua.indexOf('KHTML') === -1, - MobileSafari: /Apple.*Mobile/.test(ua) - } - })(), - - BrowserFeatures: { - XPath: !!document.evaluate, - - SelectorsAPI: !!document.querySelector, - - ElementExtensions: (function() { - var constructor = window.Element || window.HTMLElement; - return !!(constructor && constructor.prototype); - })(), - SpecificElementExtensions: (function() { - if (typeof window.HTMLDivElement !== 'undefined') - return true; - - var div = document.createElement('div'), - form = document.createElement('form'), - isSupported = false; - - if (div['__proto__'] && (div['__proto__'] !== form['__proto__'])) { - isSupported = true; - } - - div = form = null; - - return isSupported; - })() - }, - - ScriptFragment: ']*>([\\S\\s]*?)<\/script>', - JSONFilter: /^\/\*-secure-([\s\S]*)\*\/\s*$/, - - emptyFunction: function() { }, - - K: function(x) { return x } -}; - -if (Prototype.Browser.MobileSafari) - Prototype.BrowserFeatures.SpecificElementExtensions = false; - - -var Abstract = { }; - - -var Try = { - these: function() { - var returnValue; - - for (var i = 0, length = arguments.length; i < length; i++) { - var lambda = arguments[i]; - try { - returnValue = lambda(); - break; - } catch (e) { } - } - - return returnValue; - } -}; - -/* Based on Alex Arnell's inheritance implementation. */ - -var Class = (function() { - - var IS_DONTENUM_BUGGY = (function(){ - for (var p in { toString: 1 }) { - if (p === 'toString') return false; - } - return true; - })(); - - function subclass() {}; - function create() { - var parent = null, properties = $A(arguments); - if (Object.isFunction(properties[0])) - parent = properties.shift(); - - function klass() { - this.initialize.apply(this, arguments); - } - - Object.extend(klass, Class.Methods); - klass.superclass = parent; - klass.subclasses = []; - - if (parent) { - subclass.prototype = parent.prototype; - klass.prototype = new subclass; - parent.subclasses.push(klass); - } - - for (var i = 0, length = properties.length; i < length; i++) - klass.addMethods(properties[i]); - - if (!klass.prototype.initialize) - klass.prototype.initialize = Prototype.emptyFunction; - - klass.prototype.constructor = klass; - return klass; - } - - function addMethods(source) { - var ancestor = this.superclass && this.superclass.prototype, - properties = Object.keys(source); - - if (IS_DONTENUM_BUGGY) { - if (source.toString != Object.prototype.toString) - properties.push("toString"); - if (source.valueOf != Object.prototype.valueOf) - properties.push("valueOf"); - } - - for (var i = 0, length = properties.length; i < length; i++) { - var property = properties[i], value = source[property]; - if (ancestor && Object.isFunction(value) && - value.argumentNames()[0] == "$super") { - var method = value; - value = (function(m) { - return function() { return ancestor[m].apply(this, arguments); }; - })(property).wrap(method); - - value.valueOf = method.valueOf.bind(method); - value.toString = method.toString.bind(method); - } - this.prototype[property] = value; - } - - return this; - } - - return { - create: create, - Methods: { - addMethods: addMethods - } - }; -})(); -(function() { - - var _toString = Object.prototype.toString, - NULL_TYPE = 'Null', - UNDEFINED_TYPE = 'Undefined', - BOOLEAN_TYPE = 'Boolean', - NUMBER_TYPE = 'Number', - STRING_TYPE = 'String', - OBJECT_TYPE = 'Object', - BOOLEAN_CLASS = '[object Boolean]', - NUMBER_CLASS = '[object Number]', - STRING_CLASS = '[object String]', - ARRAY_CLASS = '[object Array]', - NATIVE_JSON_STRINGIFY_SUPPORT = window.JSON && - typeof JSON.stringify === 'function' && - JSON.stringify(0) === '0' && - typeof JSON.stringify(Prototype.K) === 'undefined'; - - function Type(o) { - switch(o) { - case null: return NULL_TYPE; - case (void 0): return UNDEFINED_TYPE; - } - var type = typeof o; - switch(type) { - case 'boolean': return BOOLEAN_TYPE; - case 'number': return NUMBER_TYPE; - case 'string': return STRING_TYPE; - } - return OBJECT_TYPE; - } - - function extend(destination, source) { - for (var property in source) - destination[property] = source[property]; - return destination; - } - - function inspect(object) { - try { - if (isUndefined(object)) return 'undefined'; - if (object === null) return 'null'; - return object.inspect ? object.inspect() : String(object); - } catch (e) { - if (e instanceof RangeError) return '...'; - throw e; - } - } - - function toJSON(value) { - return Str('', { '': value }, []); - } - - function Str(key, holder, stack) { - var value = holder[key], - type = typeof value; - - if (Type(value) === OBJECT_TYPE && typeof value.toJSON === 'function') { - value = value.toJSON(key); - } - - var _class = _toString.call(value); - - switch (_class) { - case NUMBER_CLASS: - case BOOLEAN_CLASS: - case STRING_CLASS: - value = value.valueOf(); - } - - switch (value) { - case null: return 'null'; - case true: return 'true'; - case false: return 'false'; - } - - type = typeof value; - switch (type) { - case 'string': - return value.inspect(true); - case 'number': - return isFinite(value) ? String(value) : 'null'; - case 'object': - - for (var i = 0, length = stack.length; i < length; i++) { - if (stack[i] === value) { throw new TypeError(); } - } - stack.push(value); - - var partial = []; - if (_class === ARRAY_CLASS) { - for (var i = 0, length = value.length; i < length; i++) { - var str = Str(i, value, stack); - partial.push(typeof str === 'undefined' ? 'null' : str); - } - partial = '[' + partial.join(',') + ']'; - } else { - var keys = Object.keys(value); - for (var i = 0, length = keys.length; i < length; i++) { - var key = keys[i], str = Str(key, value, stack); - if (typeof str !== "undefined") { - partial.push(key.inspect(true)+ ':' + str); - } - } - partial = '{' + partial.join(',') + '}'; - } - stack.pop(); - return partial; - } - } - - function stringify(object) { - return JSON.stringify(object); - } - - function toQueryString(object) { - return $H(object).toQueryString(); - } - - function toHTML(object) { - return object && object.toHTML ? object.toHTML() : String.interpret(object); - } - - function keys(object) { - if (Type(object) !== OBJECT_TYPE) { throw new TypeError(); } - var results = []; - for (var property in object) { - if (object.hasOwnProperty(property)) { - results.push(property); - } - } - return results; - } - - function values(object) { - var results = []; - for (var property in object) - results.push(object[property]); - return results; - } - - function clone(object) { - return extend({ }, object); - } - - function isElement(object) { - return !!(object && object.nodeType == 1); - } - - function isArray(object) { - return _toString.call(object) === ARRAY_CLASS; - } - - var hasNativeIsArray = (typeof Array.isArray == 'function') - && Array.isArray([]) && !Array.isArray({}); - - if (hasNativeIsArray) { - isArray = Array.isArray; - } - - function isHash(object) { - return object instanceof Hash; - } - - function isFunction(object) { - return typeof object === "function"; - } - - function isString(object) { - return _toString.call(object) === STRING_CLASS; - } - - function isNumber(object) { - return _toString.call(object) === NUMBER_CLASS; - } - - function isUndefined(object) { - return typeof object === "undefined"; - } - - extend(Object, { - extend: extend, - inspect: inspect, - toJSON: NATIVE_JSON_STRINGIFY_SUPPORT ? stringify : toJSON, - toQueryString: toQueryString, - toHTML: toHTML, - keys: Object.keys || keys, - values: values, - clone: clone, - isElement: isElement, - isArray: isArray, - isHash: isHash, - isFunction: isFunction, - isString: isString, - isNumber: isNumber, - isUndefined: isUndefined - }); -})(); -Object.extend(Function.prototype, (function() { - var slice = Array.prototype.slice; - - function update(array, args) { - var arrayLength = array.length, length = args.length; - while (length--) array[arrayLength + length] = args[length]; - return array; - } - - function merge(array, args) { - array = slice.call(array, 0); - return update(array, args); - } - - function argumentNames() { - var names = this.toString().match(/^[\s\(]*function[^(]*\(([^)]*)\)/)[1] - .replace(/\/\/.*?[\r\n]|\/\*(?:.|[\r\n])*?\*\//g, '') - .replace(/\s+/g, '').split(','); - return names.length == 1 && !names[0] ? [] : names; - } - - function bind(context) { - if (arguments.length < 2 && Object.isUndefined(arguments[0])) return this; - var __method = this, args = slice.call(arguments, 1); - return function() { - var a = merge(args, arguments); - return __method.apply(context, a); - } - } - - function bindAsEventListener(context) { - var __method = this, args = slice.call(arguments, 1); - return function(event) { - var a = update([event || window.event], args); - return __method.apply(context, a); - } - } - - function curry() { - if (!arguments.length) return this; - var __method = this, args = slice.call(arguments, 0); - return function() { - var a = merge(args, arguments); - return __method.apply(this, a); - } - } - - function delay(timeout) { - var __method = this, args = slice.call(arguments, 1); - timeout = timeout * 1000; - return window.setTimeout(function() { - return __method.apply(__method, args); - }, timeout); - } - - function defer() { - var args = update([0.01], arguments); - return this.delay.apply(this, args); - } - - function wrap(wrapper) { - var __method = this; - return function() { - var a = update([__method.bind(this)], arguments); - return wrapper.apply(this, a); - } - } - - function methodize() { - if (this._methodized) return this._methodized; - var __method = this; - return this._methodized = function() { - var a = update([this], arguments); - return __method.apply(null, a); - }; - } - - return { - argumentNames: argumentNames, - bind: bind, - bindAsEventListener: bindAsEventListener, - curry: curry, - delay: delay, - defer: defer, - wrap: wrap, - methodize: methodize - } -})()); - - - -(function(proto) { - - - function toISOString() { - return this.getUTCFullYear() + '-' + - (this.getUTCMonth() + 1).toPaddedString(2) + '-' + - this.getUTCDate().toPaddedString(2) + 'T' + - this.getUTCHours().toPaddedString(2) + ':' + - this.getUTCMinutes().toPaddedString(2) + ':' + - this.getUTCSeconds().toPaddedString(2) + 'Z'; - } - - - function toJSON() { - return this.toISOString(); - } - - if (!proto.toISOString) proto.toISOString = toISOString; - if (!proto.toJSON) proto.toJSON = toJSON; - -})(Date.prototype); - - -RegExp.prototype.match = RegExp.prototype.test; - -RegExp.escape = function(str) { - return String(str).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); -}; -var PeriodicalExecuter = Class.create({ - initialize: function(callback, frequency) { - this.callback = callback; - this.frequency = frequency; - this.currentlyExecuting = false; - - this.registerCallback(); - }, - - registerCallback: function() { - this.timer = setInterval(this.onTimerEvent.bind(this), this.frequency * 1000); - }, - - execute: function() { - this.callback(this); - }, - - stop: function() { - if (!this.timer) return; - clearInterval(this.timer); - this.timer = null; - }, - - onTimerEvent: function() { - if (!this.currentlyExecuting) { - try { - this.currentlyExecuting = true; - this.execute(); - this.currentlyExecuting = false; - } catch(e) { - this.currentlyExecuting = false; - throw e; - } - } - } -}); -Object.extend(String, { - interpret: function(value) { - return value == null ? '' : String(value); - }, - specialChar: { - '\b': '\\b', - '\t': '\\t', - '\n': '\\n', - '\f': '\\f', - '\r': '\\r', - '\\': '\\\\' - } -}); - -Object.extend(String.prototype, (function() { - var NATIVE_JSON_PARSE_SUPPORT = window.JSON && - typeof JSON.parse === 'function' && - JSON.parse('{"test": true}').test; - - function prepareReplacement(replacement) { - if (Object.isFunction(replacement)) return replacement; - var template = new Template(replacement); - return function(match) { return template.evaluate(match) }; - } - - function gsub(pattern, replacement) { - var result = '', source = this, match; - replacement = prepareReplacement(replacement); - - if (Object.isString(pattern)) - pattern = RegExp.escape(pattern); - - if (!(pattern.length || pattern.source)) { - replacement = replacement(''); - return replacement + source.split('').join(replacement) + replacement; - } - - while (source.length > 0) { - if (match = source.match(pattern)) { - result += source.slice(0, match.index); - result += String.interpret(replacement(match)); - source = source.slice(match.index + match[0].length); - } else { - result += source, source = ''; - } - } - return result; - } - - function sub(pattern, replacement, count) { - replacement = prepareReplacement(replacement); - count = Object.isUndefined(count) ? 1 : count; - - return this.gsub(pattern, function(match) { - if (--count < 0) return match[0]; - return replacement(match); - }); - } - - function scan(pattern, iterator) { - this.gsub(pattern, iterator); - return String(this); - } - - function truncate(length, truncation) { - length = length || 30; - truncation = Object.isUndefined(truncation) ? '...' : truncation; - return this.length > length ? - this.slice(0, length - truncation.length) + truncation : String(this); - } - - function strip() { - return this.replace(/^\s+/, '').replace(/\s+$/, ''); - } - - function stripTags() { - return this.replace(/<\w+(\s+("[^"]*"|'[^']*'|[^>])+)?>|<\/\w+>/gi, ''); - } - - function stripScripts() { - return this.replace(new RegExp(Prototype.ScriptFragment, 'img'), ''); - } - - function extractScripts() { - var matchAll = new RegExp(Prototype.ScriptFragment, 'img'), - matchOne = new RegExp(Prototype.ScriptFragment, 'im'); - return (this.match(matchAll) || []).map(function(scriptTag) { - return (scriptTag.match(matchOne) || ['', ''])[1]; - }); - } - - function evalScripts() { - return this.extractScripts().map(function(script) { return eval(script) }); - } - - function escapeHTML() { - return this.replace(/&/g,'&').replace(//g,'>'); - } - - function unescapeHTML() { - return this.stripTags().replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&'); - } - - - function toQueryParams(separator) { - var match = this.strip().match(/([^?#]*)(#.*)?$/); - if (!match) return { }; - - return match[1].split(separator || '&').inject({ }, function(hash, pair) { - if ((pair = pair.split('='))[0]) { - var key = decodeURIComponent(pair.shift()), - value = pair.length > 1 ? pair.join('=') : pair[0]; - - if (value != undefined) value = decodeURIComponent(value); - - if (key in hash) { - if (!Object.isArray(hash[key])) hash[key] = [hash[key]]; - hash[key].push(value); - } - else hash[key] = value; - } - return hash; - }); - } - - function toArray() { - return this.split(''); - } - - function succ() { - return this.slice(0, this.length - 1) + - String.fromCharCode(this.charCodeAt(this.length - 1) + 1); - } - - function times(count) { - return count < 1 ? '' : new Array(count + 1).join(this); - } - - function camelize() { - return this.replace(/-+(.)?/g, function(match, chr) { - return chr ? chr.toUpperCase() : ''; - }); - } - - function capitalize() { - return this.charAt(0).toUpperCase() + this.substring(1).toLowerCase(); - } - - function underscore() { - return this.replace(/::/g, '/') - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') - .replace(/([a-z\d])([A-Z])/g, '$1_$2') - .replace(/-/g, '_') - .toLowerCase(); - } - - function dasherize() { - return this.replace(/_/g, '-'); - } - - function inspect(useDoubleQuotes) { - var escapedString = this.replace(/[\x00-\x1f\\]/g, function(character) { - if (character in String.specialChar) { - return String.specialChar[character]; - } - return '\\u00' + character.charCodeAt().toPaddedString(2, 16); - }); - if (useDoubleQuotes) return '"' + escapedString.replace(/"/g, '\\"') + '"'; - return "'" + escapedString.replace(/'/g, '\\\'') + "'"; - } - - function unfilterJSON(filter) { - return this.replace(filter || Prototype.JSONFilter, '$1'); - } - - function isJSON() { - var str = this; - if (str.blank()) return false; - str = str.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@'); - str = str.replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']'); - str = str.replace(/(?:^|:|,)(?:\s*\[)+/g, ''); - return (/^[\],:{}\s]*$/).test(str); - } - - function evalJSON(sanitize) { - var json = this.unfilterJSON(), - cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; - if (cx.test(json)) { - json = json.replace(cx, function (a) { - return '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); - }); - } - try { - if (!sanitize || json.isJSON()) return eval('(' + json + ')'); - } catch (e) { } - throw new SyntaxError('Badly formed JSON string: ' + this.inspect()); - } - - function parseJSON() { - var json = this.unfilterJSON(); - return JSON.parse(json); - } - - function include(pattern) { - return this.indexOf(pattern) > -1; - } - - function startsWith(pattern) { - return this.lastIndexOf(pattern, 0) === 0; - } - - function endsWith(pattern) { - var d = this.length - pattern.length; - return d >= 0 && this.indexOf(pattern, d) === d; - } - - function empty() { - return this == ''; - } - - function blank() { - return /^\s*$/.test(this); - } - - function interpolate(object, pattern) { - return new Template(this, pattern).evaluate(object); - } - - return { - gsub: gsub, - sub: sub, - scan: scan, - truncate: truncate, - strip: String.prototype.trim || strip, - stripTags: stripTags, - stripScripts: stripScripts, - extractScripts: extractScripts, - evalScripts: evalScripts, - escapeHTML: escapeHTML, - unescapeHTML: unescapeHTML, - toQueryParams: toQueryParams, - parseQuery: toQueryParams, - toArray: toArray, - succ: succ, - times: times, - camelize: camelize, - capitalize: capitalize, - underscore: underscore, - dasherize: dasherize, - inspect: inspect, - unfilterJSON: unfilterJSON, - isJSON: isJSON, - evalJSON: NATIVE_JSON_PARSE_SUPPORT ? parseJSON : evalJSON, - include: include, - startsWith: startsWith, - endsWith: endsWith, - empty: empty, - blank: blank, - interpolate: interpolate - }; -})()); - -var Template = Class.create({ - initialize: function(template, pattern) { - this.template = template.toString(); - this.pattern = pattern || Template.Pattern; - }, - - evaluate: function(object) { - if (object && Object.isFunction(object.toTemplateReplacements)) - object = object.toTemplateReplacements(); - - return this.template.gsub(this.pattern, function(match) { - if (object == null) return (match[1] + ''); - - var before = match[1] || ''; - if (before == '\\') return match[2]; - - var ctx = object, expr = match[3], - pattern = /^([^.[]+|\[((?:.*?[^\\])?)\])(\.|\[|$)/; - - match = pattern.exec(expr); - if (match == null) return before; - - while (match != null) { - var comp = match[1].startsWith('[') ? match[2].replace(/\\\\]/g, ']') : match[1]; - ctx = ctx[comp]; - if (null == ctx || '' == match[3]) break; - expr = expr.substring('[' == match[3] ? match[1].length : match[0].length); - match = pattern.exec(expr); - } - - return before + String.interpret(ctx); - }); - } -}); -Template.Pattern = /(^|.|\r|\n)(#\{(.*?)\})/; - -var $break = { }; - -var Enumerable = (function() { - function each(iterator, context) { - var index = 0; - try { - this._each(function(value) { - iterator.call(context, value, index++); - }); - } catch (e) { - if (e != $break) throw e; - } - return this; - } - - function eachSlice(number, iterator, context) { - var index = -number, slices = [], array = this.toArray(); - if (number < 1) return array; - while ((index += number) < array.length) - slices.push(array.slice(index, index+number)); - return slices.collect(iterator, context); - } - - function all(iterator, context) { - iterator = iterator || Prototype.K; - var result = true; - this.each(function(value, index) { - result = result && !!iterator.call(context, value, index); - if (!result) throw $break; - }); - return result; - } - - function any(iterator, context) { - iterator = iterator || Prototype.K; - var result = false; - this.each(function(value, index) { - if (result = !!iterator.call(context, value, index)) - throw $break; - }); - return result; - } - - function collect(iterator, context) { - iterator = iterator || Prototype.K; - var results = []; - this.each(function(value, index) { - results.push(iterator.call(context, value, index)); - }); - return results; - } - - function detect(iterator, context) { - var result; - this.each(function(value, index) { - if (iterator.call(context, value, index)) { - result = value; - throw $break; - } - }); - return result; - } - - function findAll(iterator, context) { - var results = []; - this.each(function(value, index) { - if (iterator.call(context, value, index)) - results.push(value); - }); - return results; - } - - function grep(filter, iterator, context) { - iterator = iterator || Prototype.K; - var results = []; - - if (Object.isString(filter)) - filter = new RegExp(RegExp.escape(filter)); - - this.each(function(value, index) { - if (filter.match(value)) - results.push(iterator.call(context, value, index)); - }); - return results; - } - - function include(object) { - if (Object.isFunction(this.indexOf)) - if (this.indexOf(object) != -1) return true; - - var found = false; - this.each(function(value) { - if (value == object) { - found = true; - throw $break; - } - }); - return found; - } - - function inGroupsOf(number, fillWith) { - fillWith = Object.isUndefined(fillWith) ? null : fillWith; - return this.eachSlice(number, function(slice) { - while(slice.length < number) slice.push(fillWith); - return slice; - }); - } - - function inject(memo, iterator, context) { - this.each(function(value, index) { - memo = iterator.call(context, memo, value, index); - }); - return memo; - } - - function invoke(method) { - var args = $A(arguments).slice(1); - return this.map(function(value) { - return value[method].apply(value, args); - }); - } - - function max(iterator, context) { - iterator = iterator || Prototype.K; - var result; - this.each(function(value, index) { - value = iterator.call(context, value, index); - if (result == null || value >= result) - result = value; - }); - return result; - } - - function min(iterator, context) { - iterator = iterator || Prototype.K; - var result; - this.each(function(value, index) { - value = iterator.call(context, value, index); - if (result == null || value < result) - result = value; - }); - return result; - } - - function partition(iterator, context) { - iterator = iterator || Prototype.K; - var trues = [], falses = []; - this.each(function(value, index) { - (iterator.call(context, value, index) ? - trues : falses).push(value); - }); - return [trues, falses]; - } - - function pluck(property) { - var results = []; - this.each(function(value) { - results.push(value[property]); - }); - return results; - } - - function reject(iterator, context) { - var results = []; - this.each(function(value, index) { - if (!iterator.call(context, value, index)) - results.push(value); - }); - return results; - } - - function sortBy(iterator, context) { - return this.map(function(value, index) { - return { - value: value, - criteria: iterator.call(context, value, index) - }; - }).sort(function(left, right) { - var a = left.criteria, b = right.criteria; - return a < b ? -1 : a > b ? 1 : 0; - }).pluck('value'); - } - - function toArray() { - return this.map(); - } - - function zip() { - var iterator = Prototype.K, args = $A(arguments); - if (Object.isFunction(args.last())) - iterator = args.pop(); - - var collections = [this].concat(args).map($A); - return this.map(function(value, index) { - return iterator(collections.pluck(index)); - }); - } - - function size() { - return this.toArray().length; - } - - function inspect() { - return '#'; - } - - - - - - - - - - return { - each: each, - eachSlice: eachSlice, - all: all, - every: all, - any: any, - some: any, - collect: collect, - map: collect, - detect: detect, - findAll: findAll, - select: findAll, - filter: findAll, - grep: grep, - include: include, - member: include, - inGroupsOf: inGroupsOf, - inject: inject, - invoke: invoke, - max: max, - min: min, - partition: partition, - pluck: pluck, - reject: reject, - sortBy: sortBy, - toArray: toArray, - entries: toArray, - zip: zip, - size: size, - inspect: inspect, - find: detect - }; -})(); - -function $A(iterable) { - if (!iterable) return []; - if ('toArray' in Object(iterable)) return iterable.toArray(); - var length = iterable.length || 0, results = new Array(length); - while (length--) results[length] = iterable[length]; - return results; -} - - -function $w(string) { - if (!Object.isString(string)) return []; - string = string.strip(); - return string ? string.split(/\s+/) : []; -} - -Array.from = $A; - - -(function() { - var arrayProto = Array.prototype, - slice = arrayProto.slice, - _each = arrayProto.forEach; // use native browser JS 1.6 implementation if available - - function each(iterator) { - for (var i = 0, length = this.length; i < length; i++) - iterator(this[i]); - } - if (!_each) _each = each; - - function clear() { - this.length = 0; - return this; - } - - function first() { - return this[0]; - } - - function last() { - return this[this.length - 1]; - } - - function compact() { - return this.select(function(value) { - return value != null; - }); - } - - function flatten() { - return this.inject([], function(array, value) { - if (Object.isArray(value)) - return array.concat(value.flatten()); - array.push(value); - return array; - }); - } - - function without() { - var values = slice.call(arguments, 0); - return this.select(function(value) { - return !values.include(value); - }); - } - - function reverse(inline) { - return (inline === false ? this.toArray() : this)._reverse(); - } - - function uniq(sorted) { - return this.inject([], function(array, value, index) { - if (0 == index || (sorted ? array.last() != value : !array.include(value))) - array.push(value); - return array; - }); - } - - function intersect(array) { - return this.uniq().findAll(function(item) { - return array.detect(function(value) { return item === value }); - }); - } - - - function clone() { - return slice.call(this, 0); - } - - function size() { - return this.length; - } - - function inspect() { - return '[' + this.map(Object.inspect).join(', ') + ']'; - } - - function indexOf(item, i) { - i || (i = 0); - var length = this.length; - if (i < 0) i = length + i; - for (; i < length; i++) - if (this[i] === item) return i; - return -1; - } - - function lastIndexOf(item, i) { - i = isNaN(i) ? this.length : (i < 0 ? this.length + i : i) + 1; - var n = this.slice(0, i).reverse().indexOf(item); - return (n < 0) ? n : i - n - 1; - } - - function concat() { - var array = slice.call(this, 0), item; - for (var i = 0, length = arguments.length; i < length; i++) { - item = arguments[i]; - if (Object.isArray(item) && !('callee' in item)) { - for (var j = 0, arrayLength = item.length; j < arrayLength; j++) - array.push(item[j]); - } else { - array.push(item); - } - } - return array; - } - - Object.extend(arrayProto, Enumerable); - - if (!arrayProto._reverse) - arrayProto._reverse = arrayProto.reverse; - - Object.extend(arrayProto, { - _each: _each, - clear: clear, - first: first, - last: last, - compact: compact, - flatten: flatten, - without: without, - reverse: reverse, - uniq: uniq, - intersect: intersect, - clone: clone, - toArray: clone, - size: size, - inspect: inspect - }); - - var CONCAT_ARGUMENTS_BUGGY = (function() { - return [].concat(arguments)[0][0] !== 1; - })(1,2) - - if (CONCAT_ARGUMENTS_BUGGY) arrayProto.concat = concat; - - if (!arrayProto.indexOf) arrayProto.indexOf = indexOf; - if (!arrayProto.lastIndexOf) arrayProto.lastIndexOf = lastIndexOf; -})(); -function $H(object) { - return new Hash(object); -}; - -var Hash = Class.create(Enumerable, (function() { - function initialize(object) { - this._object = Object.isHash(object) ? object.toObject() : Object.clone(object); - } - - - function _each(iterator) { - for (var key in this._object) { - var value = this._object[key], pair = [key, value]; - pair.key = key; - pair.value = value; - iterator(pair); - } - } - - function set(key, value) { - return this._object[key] = value; - } - - function get(key) { - if (this._object[key] !== Object.prototype[key]) - return this._object[key]; - } - - function unset(key) { - var value = this._object[key]; - delete this._object[key]; - return value; - } - - function toObject() { - return Object.clone(this._object); - } - - - - function keys() { - return this.pluck('key'); - } - - function values() { - return this.pluck('value'); - } - - function index(value) { - var match = this.detect(function(pair) { - return pair.value === value; - }); - return match && match.key; - } - - function merge(object) { - return this.clone().update(object); - } - - function update(object) { - return new Hash(object).inject(this, function(result, pair) { - result.set(pair.key, pair.value); - return result; - }); - } - - function toQueryPair(key, value) { - if (Object.isUndefined(value)) return key; - return key + '=' + encodeURIComponent(String.interpret(value)); - } - - function toQueryString() { - return this.inject([], function(results, pair) { - var key = encodeURIComponent(pair.key), values = pair.value; - - if (values && typeof values == 'object') { - if (Object.isArray(values)) - return results.concat(values.map(toQueryPair.curry(key))); - } else results.push(toQueryPair(key, values)); - return results; - }).join('&'); - } - - function inspect() { - return '#'; - } - - function clone() { - return new Hash(this); - } - - return { - initialize: initialize, - _each: _each, - set: set, - get: get, - unset: unset, - toObject: toObject, - toTemplateReplacements: toObject, - keys: keys, - values: values, - index: index, - merge: merge, - update: update, - toQueryString: toQueryString, - inspect: inspect, - toJSON: toObject, - clone: clone - }; -})()); - -Hash.from = $H; -Object.extend(Number.prototype, (function() { - function toColorPart() { - return this.toPaddedString(2, 16); - } - - function succ() { - return this + 1; - } - - function times(iterator, context) { - $R(0, this, true).each(iterator, context); - return this; - } - - function toPaddedString(length, radix) { - var string = this.toString(radix || 10); - return '0'.times(length - string.length) + string; - } - - function abs() { - return Math.abs(this); - } - - function round() { - return Math.round(this); - } - - function ceil() { - return Math.ceil(this); - } - - function floor() { - return Math.floor(this); - } - - return { - toColorPart: toColorPart, - succ: succ, - times: times, - toPaddedString: toPaddedString, - abs: abs, - round: round, - ceil: ceil, - floor: floor - }; -})()); - -function $R(start, end, exclusive) { - return new ObjectRange(start, end, exclusive); -} - -var ObjectRange = Class.create(Enumerable, (function() { - function initialize(start, end, exclusive) { - this.start = start; - this.end = end; - this.exclusive = exclusive; - } - - function _each(iterator) { - var value = this.start; - while (this.include(value)) { - iterator(value); - value = value.succ(); - } - } - - function include(value) { - if (value < this.start) - return false; - if (this.exclusive) - return value < this.end; - return value <= this.end; - } - - return { - initialize: initialize, - _each: _each, - include: include - }; -})()); - - - -var Ajax = { - getTransport: function() { - return Try.these( - function() {return new XMLHttpRequest()}, - function() {return new ActiveXObject('Msxml2.XMLHTTP')}, - function() {return new ActiveXObject('Microsoft.XMLHTTP')} - ) || false; - }, - - activeRequestCount: 0 -}; - -Ajax.Responders = { - responders: [], - - _each: function(iterator) { - this.responders._each(iterator); - }, - - register: function(responder) { - if (!this.include(responder)) - this.responders.push(responder); - }, - - unregister: function(responder) { - this.responders = this.responders.without(responder); - }, - - dispatch: function(callback, request, transport, json) { - this.each(function(responder) { - if (Object.isFunction(responder[callback])) { - try { - responder[callback].apply(responder, [request, transport, json]); - } catch (e) { } - } - }); - } -}; - -Object.extend(Ajax.Responders, Enumerable); - -Ajax.Responders.register({ - onCreate: function() { Ajax.activeRequestCount++ }, - onComplete: function() { Ajax.activeRequestCount-- } -}); -Ajax.Base = Class.create({ - initialize: function(options) { - this.options = { - method: 'post', - asynchronous: true, - contentType: 'application/x-www-form-urlencoded', - encoding: 'UTF-8', - parameters: '', - evalJSON: true, - evalJS: true - }; - Object.extend(this.options, options || { }); - - this.options.method = this.options.method.toLowerCase(); - - if (Object.isString(this.options.parameters)) - this.options.parameters = this.options.parameters.toQueryParams(); - else if (Object.isHash(this.options.parameters)) - this.options.parameters = this.options.parameters.toObject(); - } -}); -Ajax.Request = Class.create(Ajax.Base, { - _complete: false, - - initialize: function($super, url, options) { - $super(options); - this.transport = Ajax.getTransport(); - this.request(url); - }, - - request: function(url) { - this.url = url; - this.method = this.options.method; - var params = Object.clone(this.options.parameters); - - if (!['get', 'post'].include(this.method)) { - params['_method'] = this.method; - this.method = 'post'; - } - - this.parameters = params; - - if (params = Object.toQueryString(params)) { - if (this.method == 'get') - this.url += (this.url.include('?') ? '&' : '?') + params; - else if (/Konqueror|Safari|KHTML/.test(navigator.userAgent)) - params += '&_='; - } - - try { - var response = new Ajax.Response(this); - if (this.options.onCreate) this.options.onCreate(response); - Ajax.Responders.dispatch('onCreate', this, response); - - this.transport.open(this.method.toUpperCase(), this.url, - this.options.asynchronous); - - if (this.options.asynchronous) this.respondToReadyState.bind(this).defer(1); - - this.transport.onreadystatechange = this.onStateChange.bind(this); - this.setRequestHeaders(); - - this.body = this.method == 'post' ? (this.options.postBody || params) : null; - this.transport.send(this.body); - - /* Force Firefox to handle ready state 4 for synchronous requests */ - if (!this.options.asynchronous && this.transport.overrideMimeType) - this.onStateChange(); - - } - catch (e) { - this.dispatchException(e); - } - }, - - onStateChange: function() { - var readyState = this.transport.readyState; - if (readyState > 1 && !((readyState == 4) && this._complete)) - this.respondToReadyState(this.transport.readyState); - }, - - setRequestHeaders: function() { - var headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'X-Prototype-Version': Prototype.Version, - 'Accept': 'text/javascript, text/html, application/xml, text/xml, */*' - }; - - if (this.method == 'post') { - headers['Content-type'] = this.options.contentType + - (this.options.encoding ? '; charset=' + this.options.encoding : ''); - - /* Force "Connection: close" for older Mozilla browsers to work - * around a bug where XMLHttpRequest sends an incorrect - * Content-length header. See Mozilla Bugzilla #246651. - */ - if (this.transport.overrideMimeType && - (navigator.userAgent.match(/Gecko\/(\d{4})/) || [0,2005])[1] < 2005) - headers['Connection'] = 'close'; - } - - if (typeof this.options.requestHeaders == 'object') { - var extras = this.options.requestHeaders; - - if (Object.isFunction(extras.push)) - for (var i = 0, length = extras.length; i < length; i += 2) - headers[extras[i]] = extras[i+1]; - else - $H(extras).each(function(pair) { headers[pair.key] = pair.value }); - } - - for (var name in headers) - this.transport.setRequestHeader(name, headers[name]); - }, - - success: function() { - var status = this.getStatus(); - return !status || (status >= 200 && status < 300); - }, - - getStatus: function() { - try { - return this.transport.status || 0; - } catch (e) { return 0 } - }, - - respondToReadyState: function(readyState) { - var state = Ajax.Request.Events[readyState], response = new Ajax.Response(this); - - if (state == 'Complete') { - try { - this._complete = true; - (this.options['on' + response.status] - || this.options['on' + (this.success() ? 'Success' : 'Failure')] - || Prototype.emptyFunction)(response, response.headerJSON); - } catch (e) { - this.dispatchException(e); - } - - var contentType = response.getHeader('Content-type'); - if (this.options.evalJS == 'force' - || (this.options.evalJS && this.isSameOrigin() && contentType - && contentType.match(/^\s*(text|application)\/(x-)?(java|ecma)script(;.*)?\s*$/i))) - this.evalResponse(); - } - - try { - (this.options['on' + state] || Prototype.emptyFunction)(response, response.headerJSON); - Ajax.Responders.dispatch('on' + state, this, response, response.headerJSON); - } catch (e) { - this.dispatchException(e); - } - - if (state == 'Complete') { - this.transport.onreadystatechange = Prototype.emptyFunction; - } - }, - - isSameOrigin: function() { - var m = this.url.match(/^\s*https?:\/\/[^\/]*/); - return !m || (m[0] == '#{protocol}//#{domain}#{port}'.interpolate({ - protocol: location.protocol, - domain: document.domain, - port: location.port ? ':' + location.port : '' - })); - }, - - getHeader: function(name) { - try { - return this.transport.getResponseHeader(name) || null; - } catch (e) { return null; } - }, - - evalResponse: function() { - try { - return eval((this.transport.responseText || '').unfilterJSON()); - } catch (e) { - this.dispatchException(e); - } - }, - - dispatchException: function(exception) { - (this.options.onException || Prototype.emptyFunction)(this, exception); - Ajax.Responders.dispatch('onException', this, exception); - } -}); - -Ajax.Request.Events = - ['Uninitialized', 'Loading', 'Loaded', 'Interactive', 'Complete']; - - - - - - - - -Ajax.Response = Class.create({ - initialize: function(request){ - this.request = request; - var transport = this.transport = request.transport, - readyState = this.readyState = transport.readyState; - - if ((readyState > 2 && !Prototype.Browser.IE) || readyState == 4) { - this.status = this.getStatus(); - this.statusText = this.getStatusText(); - this.responseText = String.interpret(transport.responseText); - this.headerJSON = this._getHeaderJSON(); - } - - if (readyState == 4) { - var xml = transport.responseXML; - this.responseXML = Object.isUndefined(xml) ? null : xml; - this.responseJSON = this._getResponseJSON(); - } - }, - - status: 0, - - statusText: '', - - getStatus: Ajax.Request.prototype.getStatus, - - getStatusText: function() { - try { - return this.transport.statusText || ''; - } catch (e) { return '' } - }, - - getHeader: Ajax.Request.prototype.getHeader, - - getAllHeaders: function() { - try { - return this.getAllResponseHeaders(); - } catch (e) { return null } - }, - - getResponseHeader: function(name) { - return this.transport.getResponseHeader(name); - }, - - getAllResponseHeaders: function() { - return this.transport.getAllResponseHeaders(); - }, - - _getHeaderJSON: function() { - var json = this.getHeader('X-JSON'); - if (!json) return null; - json = decodeURIComponent(escape(json)); - try { - return json.evalJSON(this.request.options.sanitizeJSON || - !this.request.isSameOrigin()); - } catch (e) { - this.request.dispatchException(e); - } - }, - - _getResponseJSON: function() { - var options = this.request.options; - if (!options.evalJSON || (options.evalJSON != 'force' && - !(this.getHeader('Content-type') || '').include('application/json')) || - this.responseText.blank()) - return null; - try { - return this.responseText.evalJSON(options.sanitizeJSON || - !this.request.isSameOrigin()); - } catch (e) { - this.request.dispatchException(e); - } - } -}); - -Ajax.Updater = Class.create(Ajax.Request, { - initialize: function($super, container, url, options) { - this.container = { - success: (container.success || container), - failure: (container.failure || (container.success ? null : container)) - }; - - options = Object.clone(options); - var onComplete = options.onComplete; - options.onComplete = (function(response, json) { - this.updateContent(response.responseText); - if (Object.isFunction(onComplete)) onComplete(response, json); - }).bind(this); - - $super(url, options); - }, - - updateContent: function(responseText) { - var receiver = this.container[this.success() ? 'success' : 'failure'], - options = this.options; - - if (!options.evalScripts) responseText = responseText.stripScripts(); - - if (receiver = $(receiver)) { - if (options.insertion) { - if (Object.isString(options.insertion)) { - var insertion = { }; insertion[options.insertion] = responseText; - receiver.insert(insertion); - } - else options.insertion(receiver, responseText); - } - else receiver.update(responseText); - } - } -}); - -Ajax.PeriodicalUpdater = Class.create(Ajax.Base, { - initialize: function($super, container, url, options) { - $super(options); - this.onComplete = this.options.onComplete; - - this.frequency = (this.options.frequency || 2); - this.decay = (this.options.decay || 1); - - this.updater = { }; - this.container = container; - this.url = url; - - this.start(); - }, - - start: function() { - this.options.onComplete = this.updateComplete.bind(this); - this.onTimerEvent(); - }, - - stop: function() { - this.updater.options.onComplete = undefined; - clearTimeout(this.timer); - (this.onComplete || Prototype.emptyFunction).apply(this, arguments); - }, - - updateComplete: function(response) { - if (this.options.decay) { - this.decay = (response.responseText == this.lastText ? - this.decay * this.options.decay : 1); - - this.lastText = response.responseText; - } - this.timer = this.onTimerEvent.bind(this).delay(this.decay * this.frequency); - }, - - onTimerEvent: function() { - this.updater = new Ajax.Updater(this.container, this.url, this.options); - } -}); - - -function $(element) { - if (arguments.length > 1) { - for (var i = 0, elements = [], length = arguments.length; i < length; i++) - elements.push($(arguments[i])); - return elements; - } - if (Object.isString(element)) - element = document.getElementById(element); - return Element.extend(element); -} - -if (Prototype.BrowserFeatures.XPath) { - document._getElementsByXPath = function(expression, parentElement) { - var results = []; - var query = document.evaluate(expression, $(parentElement) || document, - null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); - for (var i = 0, length = query.snapshotLength; i < length; i++) - results.push(Element.extend(query.snapshotItem(i))); - return results; - }; -} - -/*--------------------------------------------------------------------------*/ - -if (!Node) var Node = { }; - -if (!Node.ELEMENT_NODE) { - Object.extend(Node, { - ELEMENT_NODE: 1, - ATTRIBUTE_NODE: 2, - TEXT_NODE: 3, - CDATA_SECTION_NODE: 4, - ENTITY_REFERENCE_NODE: 5, - ENTITY_NODE: 6, - PROCESSING_INSTRUCTION_NODE: 7, - COMMENT_NODE: 8, - DOCUMENT_NODE: 9, - DOCUMENT_TYPE_NODE: 10, - DOCUMENT_FRAGMENT_NODE: 11, - NOTATION_NODE: 12 - }); -} - - - -(function(global) { - - var HAS_EXTENDED_CREATE_ELEMENT_SYNTAX = (function(){ - try { - var el = document.createElement(''); - return el.tagName.toLowerCase() === 'input' && el.name === 'x'; - } - catch(err) { - return false; - } - })(); - - var element = global.Element; - - global.Element = function(tagName, attributes) { - attributes = attributes || { }; - tagName = tagName.toLowerCase(); - var cache = Element.cache; - if (HAS_EXTENDED_CREATE_ELEMENT_SYNTAX && attributes.name) { - tagName = '<' + tagName + ' name="' + attributes.name + '">'; - delete attributes.name; - return Element.writeAttribute(document.createElement(tagName), attributes); - } - if (!cache[tagName]) cache[tagName] = Element.extend(document.createElement(tagName)); - return Element.writeAttribute(cache[tagName].cloneNode(false), attributes); - }; - - Object.extend(global.Element, element || { }); - if (element) global.Element.prototype = element.prototype; - -})(this); - -Element.idCounter = 1; -Element.cache = { }; - -function purgeElement(element) { - var uid = element._prototypeUID; - if (uid) { - Element.stopObserving(element); - element._prototypeUID = void 0; - delete Element.Storage[uid]; - } -} - -Element.Methods = { - visible: function(element) { - return $(element).style.display != 'none'; - }, - - toggle: function(element) { - element = $(element); - Element[Element.visible(element) ? 'hide' : 'show'](element); - return element; - }, - - hide: function(element) { - element = $(element); - element.style.display = 'none'; - return element; - }, - - show: function(element) { - element = $(element); - element.style.display = ''; - return element; - }, - - remove: function(element) { - element = $(element); - element.parentNode.removeChild(element); - return element; - }, - - update: (function(){ - - var SELECT_ELEMENT_INNERHTML_BUGGY = (function(){ - var el = document.createElement("select"), - isBuggy = true; - el.innerHTML = ""; - if (el.options && el.options[0]) { - isBuggy = el.options[0].nodeName.toUpperCase() !== "OPTION"; - } - el = null; - return isBuggy; - })(); - - var TABLE_ELEMENT_INNERHTML_BUGGY = (function(){ - try { - var el = document.createElement("table"); - if (el && el.tBodies) { - el.innerHTML = "test"; - var isBuggy = typeof el.tBodies[0] == "undefined"; - el = null; - return isBuggy; - } - } catch (e) { - return true; - } - })(); - - var SCRIPT_ELEMENT_REJECTS_TEXTNODE_APPENDING = (function () { - var s = document.createElement("script"), - isBuggy = false; - try { - s.appendChild(document.createTextNode("")); - isBuggy = !s.firstChild || - s.firstChild && s.firstChild.nodeType !== 3; - } catch (e) { - isBuggy = true; - } - s = null; - return isBuggy; - })(); - - function update(element, content) { - element = $(element); - - var descendants = element.getElementsByTagName('*'), - i = descendants.length; - while (i--) purgeElement(descendants[i]); - - if (content && content.toElement) - content = content.toElement(); - - if (Object.isElement(content)) - return element.update().insert(content); - - content = Object.toHTML(content); - - var tagName = element.tagName.toUpperCase(); - - if (tagName === 'SCRIPT' && SCRIPT_ELEMENT_REJECTS_TEXTNODE_APPENDING) { - element.text = content; - return element; - } - - if (SELECT_ELEMENT_INNERHTML_BUGGY || TABLE_ELEMENT_INNERHTML_BUGGY) { - if (tagName in Element._insertionTranslations.tags) { - while (element.firstChild) { - element.removeChild(element.firstChild); - } - Element._getContentFromAnonymousElement(tagName, content.stripScripts()) - .each(function(node) { - element.appendChild(node) - }); - } - else { - element.innerHTML = content.stripScripts(); - } - } - else { - element.innerHTML = content.stripScripts(); - } - - content.evalScripts.bind(content).defer(); - return element; - } - - return update; - })(), - - replace: function(element, content) { - element = $(element); - if (content && content.toElement) content = content.toElement(); - else if (!Object.isElement(content)) { - content = Object.toHTML(content); - var range = element.ownerDocument.createRange(); - range.selectNode(element); - content.evalScripts.bind(content).defer(); - content = range.createContextualFragment(content.stripScripts()); - } - element.parentNode.replaceChild(content, element); - return element; - }, - - insert: function(element, insertions) { - element = $(element); - - if (Object.isString(insertions) || Object.isNumber(insertions) || - Object.isElement(insertions) || (insertions && (insertions.toElement || insertions.toHTML))) - insertions = {bottom:insertions}; - - var content, insert, tagName, childNodes; - - for (var position in insertions) { - content = insertions[position]; - position = position.toLowerCase(); - insert = Element._insertionTranslations[position]; - - if (content && content.toElement) content = content.toElement(); - if (Object.isElement(content)) { - insert(element, content); - continue; - } - - content = Object.toHTML(content); - - tagName = ((position == 'before' || position == 'after') - ? element.parentNode : element).tagName.toUpperCase(); - - childNodes = Element._getContentFromAnonymousElement(tagName, content.stripScripts()); - - if (position == 'top' || position == 'after') childNodes.reverse(); - childNodes.each(insert.curry(element)); - - content.evalScripts.bind(content).defer(); - } - - return element; - }, - - wrap: function(element, wrapper, attributes) { - element = $(element); - if (Object.isElement(wrapper)) - $(wrapper).writeAttribute(attributes || { }); - else if (Object.isString(wrapper)) wrapper = new Element(wrapper, attributes); - else wrapper = new Element('div', wrapper); - if (element.parentNode) - element.parentNode.replaceChild(wrapper, element); - wrapper.appendChild(element); - return wrapper; - }, - - inspect: function(element) { - element = $(element); - var result = '<' + element.tagName.toLowerCase(); - $H({'id': 'id', 'className': 'class'}).each(function(pair) { - var property = pair.first(), - attribute = pair.last(), - value = (element[property] || '').toString(); - if (value) result += ' ' + attribute + '=' + value.inspect(true); - }); - return result + '>'; - }, - - recursivelyCollect: function(element, property, maximumLength) { - element = $(element); - maximumLength = maximumLength || -1; - var elements = []; - - while (element = element[property]) { - if (element.nodeType == 1) - elements.push(Element.extend(element)); - if (elements.length == maximumLength) - break; - } - - return elements; - }, - - ancestors: function(element) { - return Element.recursivelyCollect(element, 'parentNode'); - }, - - descendants: function(element) { - return Element.select(element, "*"); - }, - - firstDescendant: function(element) { - element = $(element).firstChild; - while (element && element.nodeType != 1) element = element.nextSibling; - return $(element); - }, - - immediateDescendants: function(element) { - var results = [], child = $(element).firstChild; - while (child) { - if (child.nodeType === 1) { - results.push(Element.extend(child)); - } - child = child.nextSibling; - } - return results; - }, - - previousSiblings: function(element, maximumLength) { - return Element.recursivelyCollect(element, 'previousSibling'); - }, - - nextSiblings: function(element) { - return Element.recursivelyCollect(element, 'nextSibling'); - }, - - siblings: function(element) { - element = $(element); - return Element.previousSiblings(element).reverse() - .concat(Element.nextSiblings(element)); - }, - - match: function(element, selector) { - element = $(element); - if (Object.isString(selector)) - return Prototype.Selector.match(element, selector); - return selector.match(element); - }, - - up: function(element, expression, index) { - element = $(element); - if (arguments.length == 1) return $(element.parentNode); - var ancestors = Element.ancestors(element); - return Object.isNumber(expression) ? ancestors[expression] : - Prototype.Selector.find(ancestors, expression, index); - }, - - down: function(element, expression, index) { - element = $(element); - if (arguments.length == 1) return Element.firstDescendant(element); - return Object.isNumber(expression) ? Element.descendants(element)[expression] : - Element.select(element, expression)[index || 0]; - }, - - previous: function(element, expression, index) { - element = $(element); - if (Object.isNumber(expression)) index = expression, expression = false; - if (!Object.isNumber(index)) index = 0; - - if (expression) { - return Prototype.Selector.find(element.previousSiblings(), expression, index); - } else { - return element.recursivelyCollect("previousSibling", index + 1)[index]; - } - }, - - next: function(element, expression, index) { - element = $(element); - if (Object.isNumber(expression)) index = expression, expression = false; - if (!Object.isNumber(index)) index = 0; - - if (expression) { - return Prototype.Selector.find(element.nextSiblings(), expression, index); - } else { - var maximumLength = Object.isNumber(index) ? index + 1 : 1; - return element.recursivelyCollect("nextSibling", index + 1)[index]; - } - }, - - - select: function(element) { - element = $(element); - var expressions = Array.prototype.slice.call(arguments, 1).join(', '); - return Prototype.Selector.select(expressions, element); - }, - - adjacent: function(element) { - element = $(element); - var expressions = Array.prototype.slice.call(arguments, 1).join(', '); - return Prototype.Selector.select(expressions, element.parentNode).without(element); - }, - - identify: function(element) { - element = $(element); - var id = Element.readAttribute(element, 'id'); - if (id) return id; - do { id = 'anonymous_element_' + Element.idCounter++ } while ($(id)); - Element.writeAttribute(element, 'id', id); - return id; - }, - - readAttribute: function(element, name) { - element = $(element); - if (Prototype.Browser.IE) { - var t = Element._attributeTranslations.read; - if (t.values[name]) return t.values[name](element, name); - if (t.names[name]) name = t.names[name]; - if (name.include(':')) { - return (!element.attributes || !element.attributes[name]) ? null : - element.attributes[name].value; - } - } - return element.getAttribute(name); - }, - - writeAttribute: function(element, name, value) { - element = $(element); - var attributes = { }, t = Element._attributeTranslations.write; - - if (typeof name == 'object') attributes = name; - else attributes[name] = Object.isUndefined(value) ? true : value; - - for (var attr in attributes) { - name = t.names[attr] || attr; - value = attributes[attr]; - if (t.values[attr]) name = t.values[attr](element, value); - if (value === false || value === null) - element.removeAttribute(name); - else if (value === true) - element.setAttribute(name, name); - else element.setAttribute(name, value); - } - return element; - }, - - getHeight: function(element) { - return Element.getDimensions(element).height; - }, - - getWidth: function(element) { - return Element.getDimensions(element).width; - }, - - classNames: function(element) { - return new Element.ClassNames(element); - }, - - hasClassName: function(element, className) { - if (!(element = $(element))) return; - var elementClassName = element.className; - return (elementClassName.length > 0 && (elementClassName == className || - new RegExp("(^|\\s)" + className + "(\\s|$)").test(elementClassName))); - }, - - addClassName: function(element, className) { - if (!(element = $(element))) return; - if (!Element.hasClassName(element, className)) - element.className += (element.className ? ' ' : '') + className; - return element; - }, - - removeClassName: function(element, className) { - if (!(element = $(element))) return; - element.className = element.className.replace( - new RegExp("(^|\\s+)" + className + "(\\s+|$)"), ' ').strip(); - return element; - }, - - toggleClassName: function(element, className) { - if (!(element = $(element))) return; - return Element[Element.hasClassName(element, className) ? - 'removeClassName' : 'addClassName'](element, className); - }, - - cleanWhitespace: function(element) { - element = $(element); - var node = element.firstChild; - while (node) { - var nextNode = node.nextSibling; - if (node.nodeType == 3 && !/\S/.test(node.nodeValue)) - element.removeChild(node); - node = nextNode; - } - return element; - }, - - empty: function(element) { - return $(element).innerHTML.blank(); - }, - - descendantOf: function(element, ancestor) { - element = $(element), ancestor = $(ancestor); - - if (element.compareDocumentPosition) - return (element.compareDocumentPosition(ancestor) & 8) === 8; - - if (ancestor.contains) - return ancestor.contains(element) && ancestor !== element; - - while (element = element.parentNode) - if (element == ancestor) return true; - - return false; - }, - - scrollTo: function(element) { - element = $(element); - var pos = Element.cumulativeOffset(element); - window.scrollTo(pos[0], pos[1]); - return element; - }, - - getStyle: function(element, style) { - element = $(element); - style = style == 'float' ? 'cssFloat' : style.camelize(); - var value = element.style[style]; - if (!value || value == 'auto') { - var css = document.defaultView.getComputedStyle(element, null); - value = css ? css[style] : null; - } - if (style == 'opacity') return value ? parseFloat(value) : 1.0; - return value == 'auto' ? null : value; - }, - - getOpacity: function(element) { - return $(element).getStyle('opacity'); - }, - - setStyle: function(element, styles) { - element = $(element); - var elementStyle = element.style, match; - if (Object.isString(styles)) { - element.style.cssText += ';' + styles; - return styles.include('opacity') ? - element.setOpacity(styles.match(/opacity:\s*(\d?\.?\d*)/)[1]) : element; - } - for (var property in styles) - if (property == 'opacity') element.setOpacity(styles[property]); - else - elementStyle[(property == 'float' || property == 'cssFloat') ? - (Object.isUndefined(elementStyle.styleFloat) ? 'cssFloat' : 'styleFloat') : - property] = styles[property]; - - return element; - }, - - setOpacity: function(element, value) { - element = $(element); - element.style.opacity = (value == 1 || value === '') ? '' : - (value < 0.00001) ? 0 : value; - return element; - }, - - makePositioned: function(element) { - element = $(element); - var pos = Element.getStyle(element, 'position'); - if (pos == 'static' || !pos) { - element._madePositioned = true; - element.style.position = 'relative'; - if (Prototype.Browser.Opera) { - element.style.top = 0; - element.style.left = 0; - } - } - return element; - }, - - undoPositioned: function(element) { - element = $(element); - if (element._madePositioned) { - element._madePositioned = undefined; - element.style.position = - element.style.top = - element.style.left = - element.style.bottom = - element.style.right = ''; - } - return element; - }, - - makeClipping: function(element) { - element = $(element); - if (element._overflow) return element; - element._overflow = Element.getStyle(element, 'overflow') || 'auto'; - if (element._overflow !== 'hidden') - element.style.overflow = 'hidden'; - return element; - }, - - undoClipping: function(element) { - element = $(element); - if (!element._overflow) return element; - element.style.overflow = element._overflow == 'auto' ? '' : element._overflow; - element._overflow = null; - return element; - }, - - cumulativeOffset: function(element) { - var valueT = 0, valueL = 0; - if (element.parentNode) { - do { - valueT += element.offsetTop || 0; - valueL += element.offsetLeft || 0; - element = element.offsetParent; - } while (element); - } - return Element._returnOffset(valueL, valueT); - }, - - positionedOffset: function(element) { - var valueT = 0, valueL = 0; - do { - valueT += element.offsetTop || 0; - valueL += element.offsetLeft || 0; - element = element.offsetParent; - if (element) { - if (element.tagName.toUpperCase() == 'BODY') break; - var p = Element.getStyle(element, 'position'); - if (p !== 'static') break; - } - } while (element); - return Element._returnOffset(valueL, valueT); - }, - - absolutize: function(element) { - element = $(element); - if (Element.getStyle(element, 'position') == 'absolute') return element; - - var offsets = Element.positionedOffset(element), - top = offsets[1], - left = offsets[0], - width = element.clientWidth, - height = element.clientHeight; - - element._originalLeft = left - parseFloat(element.style.left || 0); - element._originalTop = top - parseFloat(element.style.top || 0); - element._originalWidth = element.style.width; - element._originalHeight = element.style.height; - - element.style.position = 'absolute'; - element.style.top = top + 'px'; - element.style.left = left + 'px'; - element.style.width = width + 'px'; - element.style.height = height + 'px'; - return element; - }, - - relativize: function(element) { - element = $(element); - if (Element.getStyle(element, 'position') == 'relative') return element; - - element.style.position = 'relative'; - var top = parseFloat(element.style.top || 0) - (element._originalTop || 0), - left = parseFloat(element.style.left || 0) - (element._originalLeft || 0); - - element.style.top = top + 'px'; - element.style.left = left + 'px'; - element.style.height = element._originalHeight; - element.style.width = element._originalWidth; - return element; - }, - - cumulativeScrollOffset: function(element) { - var valueT = 0, valueL = 0; - do { - valueT += element.scrollTop || 0; - valueL += element.scrollLeft || 0; - element = element.parentNode; - } while (element); - return Element._returnOffset(valueL, valueT); - }, - - getOffsetParent: function(element) { - if (element.offsetParent) return $(element.offsetParent); - if (element == document.body) return $(element); - - while ((element = element.parentNode) && element != document.body) - if (Element.getStyle(element, 'position') != 'static') - return $(element); - - return $(document.body); - }, - - viewportOffset: function(forElement) { - var valueT = 0, - valueL = 0, - element = forElement; - - do { - valueT += element.offsetTop || 0; - valueL += element.offsetLeft || 0; - - if (element.offsetParent == document.body && - Element.getStyle(element, 'position') == 'absolute') break; - - } while (element = element.offsetParent); - - element = forElement; - do { - if (!Prototype.Browser.Opera || (element.tagName && (element.tagName.toUpperCase() == 'BODY'))) { - valueT -= element.scrollTop || 0; - valueL -= element.scrollLeft || 0; - } - } while (element = element.parentNode); - - return Element._returnOffset(valueL, valueT); - }, - - clonePosition: function(element, source) { - var options = Object.extend({ - setLeft: true, - setTop: true, - setWidth: true, - setHeight: true, - offsetTop: 0, - offsetLeft: 0 - }, arguments[2] || { }); - - source = $(source); - var p = Element.viewportOffset(source), delta = [0, 0], parent = null; - - element = $(element); - - if (Element.getStyle(element, 'position') == 'absolute') { - parent = Element.getOffsetParent(element); - delta = Element.viewportOffset(parent); - } - - if (parent == document.body) { - delta[0] -= document.body.offsetLeft; - delta[1] -= document.body.offsetTop; - } - - if (options.setLeft) element.style.left = (p[0] - delta[0] + options.offsetLeft) + 'px'; - if (options.setTop) element.style.top = (p[1] - delta[1] + options.offsetTop) + 'px'; - if (options.setWidth) element.style.width = source.offsetWidth + 'px'; - if (options.setHeight) element.style.height = source.offsetHeight + 'px'; - return element; - } -}; - -Object.extend(Element.Methods, { - getElementsBySelector: Element.Methods.select, - - childElements: Element.Methods.immediateDescendants -}); - -Element._attributeTranslations = { - write: { - names: { - className: 'class', - htmlFor: 'for' - }, - values: { } - } -}; - -if (Prototype.Browser.Opera) { - Element.Methods.getStyle = Element.Methods.getStyle.wrap( - function(proceed, element, style) { - switch (style) { - case 'left': case 'top': case 'right': case 'bottom': - if (proceed(element, 'position') === 'static') return null; - case 'height': case 'width': - if (!Element.visible(element)) return null; - - var dim = parseInt(proceed(element, style), 10); - - if (dim !== element['offset' + style.capitalize()]) - return dim + 'px'; - - var properties; - if (style === 'height') { - properties = ['border-top-width', 'padding-top', - 'padding-bottom', 'border-bottom-width']; - } - else { - properties = ['border-left-width', 'padding-left', - 'padding-right', 'border-right-width']; - } - return properties.inject(dim, function(memo, property) { - var val = proceed(element, property); - return val === null ? memo : memo - parseInt(val, 10); - }) + 'px'; - default: return proceed(element, style); - } - } - ); - - Element.Methods.readAttribute = Element.Methods.readAttribute.wrap( - function(proceed, element, attribute) { - if (attribute === 'title') return element.title; - return proceed(element, attribute); - } - ); -} - -else if (Prototype.Browser.IE) { - Element.Methods.getOffsetParent = Element.Methods.getOffsetParent.wrap( - function(proceed, element) { - element = $(element); - if (!element.parentNode) return $(document.body); - var position = element.getStyle('position'); - if (position !== 'static') return proceed(element); - element.setStyle({ position: 'relative' }); - var value = proceed(element); - element.setStyle({ position: position }); - return value; - } - ); - - $w('positionedOffset viewportOffset').each(function(method) { - Element.Methods[method] = Element.Methods[method].wrap( - function(proceed, element) { - element = $(element); - if (!element.parentNode) return Element._returnOffset(0, 0); - var position = element.getStyle('position'); - if (position !== 'static') return proceed(element); - var offsetParent = element.getOffsetParent(); - if (offsetParent && offsetParent.getStyle('position') === 'fixed') - offsetParent.setStyle({ zoom: 1 }); - element.setStyle({ position: 'relative' }); - var value = proceed(element); - element.setStyle({ position: position }); - return value; - } - ); - }); - - Element.Methods.getStyle = function(element, style) { - element = $(element); - style = (style == 'float' || style == 'cssFloat') ? 'styleFloat' : style.camelize(); - var value = element.style[style]; - if (!value && element.currentStyle) value = element.currentStyle[style]; - - if (style == 'opacity') { - if (value = (element.getStyle('filter') || '').match(/alpha\(opacity=(.*)\)/)) - if (value[1]) return parseFloat(value[1]) / 100; - return 1.0; - } - - if (value == 'auto') { - if ((style == 'width' || style == 'height') && (element.getStyle('display') != 'none')) - return element['offset' + style.capitalize()] + 'px'; - return null; - } - return value; - }; - - Element.Methods.setOpacity = function(element, value) { - function stripAlpha(filter){ - return filter.replace(/alpha\([^\)]*\)/gi,''); - } - element = $(element); - var currentStyle = element.currentStyle; - if ((currentStyle && !currentStyle.hasLayout) || - (!currentStyle && element.style.zoom == 'normal')) - element.style.zoom = 1; - - var filter = element.getStyle('filter'), style = element.style; - if (value == 1 || value === '') { - (filter = stripAlpha(filter)) ? - style.filter = filter : style.removeAttribute('filter'); - return element; - } else if (value < 0.00001) value = 0; - style.filter = stripAlpha(filter) + - 'alpha(opacity=' + (value * 100) + ')'; - return element; - }; - - Element._attributeTranslations = (function(){ - - var classProp = 'className', - forProp = 'for', - el = document.createElement('div'); - - el.setAttribute(classProp, 'x'); - - if (el.className !== 'x') { - el.setAttribute('class', 'x'); - if (el.className === 'x') { - classProp = 'class'; - } - } - el = null; - - el = document.createElement('label'); - el.setAttribute(forProp, 'x'); - if (el.htmlFor !== 'x') { - el.setAttribute('htmlFor', 'x'); - if (el.htmlFor === 'x') { - forProp = 'htmlFor'; - } - } - el = null; - - return { - read: { - names: { - 'class': classProp, - 'className': classProp, - 'for': forProp, - 'htmlFor': forProp - }, - values: { - _getAttr: function(element, attribute) { - return element.getAttribute(attribute); - }, - _getAttr2: function(element, attribute) { - return element.getAttribute(attribute, 2); - }, - _getAttrNode: function(element, attribute) { - var node = element.getAttributeNode(attribute); - return node ? node.value : ""; - }, - _getEv: (function(){ - - var el = document.createElement('div'), f; - el.onclick = Prototype.emptyFunction; - var value = el.getAttribute('onclick'); - - if (String(value).indexOf('{') > -1) { - f = function(element, attribute) { - attribute = element.getAttribute(attribute); - if (!attribute) return null; - attribute = attribute.toString(); - attribute = attribute.split('{')[1]; - attribute = attribute.split('}')[0]; - return attribute.strip(); - }; - } - else if (value === '') { - f = function(element, attribute) { - attribute = element.getAttribute(attribute); - if (!attribute) return null; - return attribute.strip(); - }; - } - el = null; - return f; - })(), - _flag: function(element, attribute) { - return $(element).hasAttribute(attribute) ? attribute : null; - }, - style: function(element) { - return element.style.cssText.toLowerCase(); - }, - title: function(element) { - return element.title; - } - } - } - } - })(); - - Element._attributeTranslations.write = { - names: Object.extend({ - cellpadding: 'cellPadding', - cellspacing: 'cellSpacing' - }, Element._attributeTranslations.read.names), - values: { - checked: function(element, value) { - element.checked = !!value; - }, - - style: function(element, value) { - element.style.cssText = value ? value : ''; - } - } - }; - - Element._attributeTranslations.has = {}; - - $w('colSpan rowSpan vAlign dateTime accessKey tabIndex ' + - 'encType maxLength readOnly longDesc frameBorder').each(function(attr) { - Element._attributeTranslations.write.names[attr.toLowerCase()] = attr; - Element._attributeTranslations.has[attr.toLowerCase()] = attr; - }); - - (function(v) { - Object.extend(v, { - href: v._getAttr2, - src: v._getAttr2, - type: v._getAttr, - action: v._getAttrNode, - disabled: v._flag, - checked: v._flag, - readonly: v._flag, - multiple: v._flag, - onload: v._getEv, - onunload: v._getEv, - onclick: v._getEv, - ondblclick: v._getEv, - onmousedown: v._getEv, - onmouseup: v._getEv, - onmouseover: v._getEv, - onmousemove: v._getEv, - onmouseout: v._getEv, - onfocus: v._getEv, - onblur: v._getEv, - onkeypress: v._getEv, - onkeydown: v._getEv, - onkeyup: v._getEv, - onsubmit: v._getEv, - onreset: v._getEv, - onselect: v._getEv, - onchange: v._getEv - }); - })(Element._attributeTranslations.read.values); - - if (Prototype.BrowserFeatures.ElementExtensions) { - (function() { - function _descendants(element) { - var nodes = element.getElementsByTagName('*'), results = []; - for (var i = 0, node; node = nodes[i]; i++) - if (node.tagName !== "!") // Filter out comment nodes. - results.push(node); - return results; - } - - Element.Methods.down = function(element, expression, index) { - element = $(element); - if (arguments.length == 1) return element.firstDescendant(); - return Object.isNumber(expression) ? _descendants(element)[expression] : - Element.select(element, expression)[index || 0]; - } - })(); - } - -} - -else if (Prototype.Browser.Gecko && /rv:1\.8\.0/.test(navigator.userAgent)) { - Element.Methods.setOpacity = function(element, value) { - element = $(element); - element.style.opacity = (value == 1) ? 0.999999 : - (value === '') ? '' : (value < 0.00001) ? 0 : value; - return element; - }; -} - -else if (Prototype.Browser.WebKit) { - Element.Methods.setOpacity = function(element, value) { - element = $(element); - element.style.opacity = (value == 1 || value === '') ? '' : - (value < 0.00001) ? 0 : value; - - if (value == 1) - if (element.tagName.toUpperCase() == 'IMG' && element.width) { - element.width++; element.width--; - } else try { - var n = document.createTextNode(' '); - element.appendChild(n); - element.removeChild(n); - } catch (e) { } - - return element; - }; - - Element.Methods.cumulativeOffset = function(element) { - var valueT = 0, valueL = 0; - do { - valueT += element.offsetTop || 0; - valueL += element.offsetLeft || 0; - if (element.offsetParent == document.body) - if (Element.getStyle(element, 'position') == 'absolute') break; - - element = element.offsetParent; - } while (element); - - return Element._returnOffset(valueL, valueT); - }; -} - -if ('outerHTML' in document.documentElement) { - Element.Methods.replace = function(element, content) { - element = $(element); - - if (content && content.toElement) content = content.toElement(); - if (Object.isElement(content)) { - element.parentNode.replaceChild(content, element); - return element; - } - - content = Object.toHTML(content); - var parent = element.parentNode, tagName = parent.tagName.toUpperCase(); - - if (Element._insertionTranslations.tags[tagName]) { - var nextSibling = element.next(), - fragments = Element._getContentFromAnonymousElement(tagName, content.stripScripts()); - parent.removeChild(element); - if (nextSibling) - fragments.each(function(node) { parent.insertBefore(node, nextSibling) }); - else - fragments.each(function(node) { parent.appendChild(node) }); - } - else element.outerHTML = content.stripScripts(); - - content.evalScripts.bind(content).defer(); - return element; - }; -} - -Element._returnOffset = function(l, t) { - var result = [l, t]; - result.left = l; - result.top = t; - return result; -}; - -Element._getContentFromAnonymousElement = function(tagName, html) { - var div = new Element('div'), - t = Element._insertionTranslations.tags[tagName]; - if (t) { - div.innerHTML = t[0] + html + t[1]; - for (var i = t[2]; i--; ) { - div = div.firstChild; - } - } - else { - div.innerHTML = html; - } - return $A(div.childNodes); -}; - -Element._insertionTranslations = { - before: function(element, node) { - element.parentNode.insertBefore(node, element); - }, - top: function(element, node) { - element.insertBefore(node, element.firstChild); - }, - bottom: function(element, node) { - element.appendChild(node); - }, - after: function(element, node) { - element.parentNode.insertBefore(node, element.nextSibling); - }, - tags: { - TABLE: ['', '
    ', 1], - TBODY: ['', '
    ', 2], - TR: ['', '
    ', 3], - TD: ['
    ', '
    ', 4], - SELECT: ['', 1] - } -}; - -(function() { - var tags = Element._insertionTranslations.tags; - Object.extend(tags, { - THEAD: tags.TBODY, - TFOOT: tags.TBODY, - TH: tags.TD - }); -})(); - -Element.Methods.Simulated = { - hasAttribute: function(element, attribute) { - attribute = Element._attributeTranslations.has[attribute] || attribute; - var node = $(element).getAttributeNode(attribute); - return !!(node && node.specified); - } -}; - -Element.Methods.ByTag = { }; - -Object.extend(Element, Element.Methods); - -(function(div) { - - if (!Prototype.BrowserFeatures.ElementExtensions && div['__proto__']) { - window.HTMLElement = { }; - window.HTMLElement.prototype = div['__proto__']; - Prototype.BrowserFeatures.ElementExtensions = true; - } - - div = null; - -})(document.createElement('div')); - -Element.extend = (function() { - - function checkDeficiency(tagName) { - if (typeof window.Element != 'undefined') { - var proto = window.Element.prototype; - if (proto) { - var id = '_' + (Math.random()+'').slice(2), - el = document.createElement(tagName); - proto[id] = 'x'; - var isBuggy = (el[id] !== 'x'); - delete proto[id]; - el = null; - return isBuggy; - } - } - return false; - } - - function extendElementWith(element, methods) { - for (var property in methods) { - var value = methods[property]; - if (Object.isFunction(value) && !(property in element)) - element[property] = value.methodize(); - } - } - - var HTMLOBJECTELEMENT_PROTOTYPE_BUGGY = checkDeficiency('object'); - - if (Prototype.BrowserFeatures.SpecificElementExtensions) { - if (HTMLOBJECTELEMENT_PROTOTYPE_BUGGY) { - return function(element) { - if (element && typeof element._extendedByPrototype == 'undefined') { - var t = element.tagName; - if (t && (/^(?:object|applet|embed)$/i.test(t))) { - extendElementWith(element, Element.Methods); - extendElementWith(element, Element.Methods.Simulated); - extendElementWith(element, Element.Methods.ByTag[t.toUpperCase()]); - } - } - return element; - } - } - return Prototype.K; - } - - var Methods = { }, ByTag = Element.Methods.ByTag; - - var extend = Object.extend(function(element) { - if (!element || typeof element._extendedByPrototype != 'undefined' || - element.nodeType != 1 || element == window) return element; - - var methods = Object.clone(Methods), - tagName = element.tagName.toUpperCase(); - - if (ByTag[tagName]) Object.extend(methods, ByTag[tagName]); - - extendElementWith(element, methods); - - element._extendedByPrototype = Prototype.emptyFunction; - return element; - - }, { - refresh: function() { - if (!Prototype.BrowserFeatures.ElementExtensions) { - Object.extend(Methods, Element.Methods); - Object.extend(Methods, Element.Methods.Simulated); - } - } - }); - - extend.refresh(); - return extend; -})(); - -if (document.documentElement.hasAttribute) { - Element.hasAttribute = function(element, attribute) { - return element.hasAttribute(attribute); - }; -} -else { - Element.hasAttribute = Element.Methods.Simulated.hasAttribute; -} - -Element.addMethods = function(methods) { - var F = Prototype.BrowserFeatures, T = Element.Methods.ByTag; - - if (!methods) { - Object.extend(Form, Form.Methods); - Object.extend(Form.Element, Form.Element.Methods); - Object.extend(Element.Methods.ByTag, { - "FORM": Object.clone(Form.Methods), - "INPUT": Object.clone(Form.Element.Methods), - "SELECT": Object.clone(Form.Element.Methods), - "TEXTAREA": Object.clone(Form.Element.Methods) - }); - } - - if (arguments.length == 2) { - var tagName = methods; - methods = arguments[1]; - } - - if (!tagName) Object.extend(Element.Methods, methods || { }); - else { - if (Object.isArray(tagName)) tagName.each(extend); - else extend(tagName); - } - - function extend(tagName) { - tagName = tagName.toUpperCase(); - if (!Element.Methods.ByTag[tagName]) - Element.Methods.ByTag[tagName] = { }; - Object.extend(Element.Methods.ByTag[tagName], methods); - } - - function copy(methods, destination, onlyIfAbsent) { - onlyIfAbsent = onlyIfAbsent || false; - for (var property in methods) { - var value = methods[property]; - if (!Object.isFunction(value)) continue; - if (!onlyIfAbsent || !(property in destination)) - destination[property] = value.methodize(); - } - } - - function findDOMClass(tagName) { - var klass; - var trans = { - "OPTGROUP": "OptGroup", "TEXTAREA": "TextArea", "P": "Paragraph", - "FIELDSET": "FieldSet", "UL": "UList", "OL": "OList", "DL": "DList", - "DIR": "Directory", "H1": "Heading", "H2": "Heading", "H3": "Heading", - "H4": "Heading", "H5": "Heading", "H6": "Heading", "Q": "Quote", - "INS": "Mod", "DEL": "Mod", "A": "Anchor", "IMG": "Image", "CAPTION": - "TableCaption", "COL": "TableCol", "COLGROUP": "TableCol", "THEAD": - "TableSection", "TFOOT": "TableSection", "TBODY": "TableSection", "TR": - "TableRow", "TH": "TableCell", "TD": "TableCell", "FRAMESET": - "FrameSet", "IFRAME": "IFrame" - }; - if (trans[tagName]) klass = 'HTML' + trans[tagName] + 'Element'; - if (window[klass]) return window[klass]; - klass = 'HTML' + tagName + 'Element'; - if (window[klass]) return window[klass]; - klass = 'HTML' + tagName.capitalize() + 'Element'; - if (window[klass]) return window[klass]; - - var element = document.createElement(tagName), - proto = element['__proto__'] || element.constructor.prototype; - - element = null; - return proto; - } - - var elementPrototype = window.HTMLElement ? HTMLElement.prototype : - Element.prototype; - - if (F.ElementExtensions) { - copy(Element.Methods, elementPrototype); - copy(Element.Methods.Simulated, elementPrototype, true); - } - - if (F.SpecificElementExtensions) { - for (var tag in Element.Methods.ByTag) { - var klass = findDOMClass(tag); - if (Object.isUndefined(klass)) continue; - copy(T[tag], klass.prototype); - } - } - - Object.extend(Element, Element.Methods); - delete Element.ByTag; - - if (Element.extend.refresh) Element.extend.refresh(); - Element.cache = { }; -}; - - -document.viewport = { - - getDimensions: function() { - return { width: this.getWidth(), height: this.getHeight() }; - }, - - getScrollOffsets: function() { - return Element._returnOffset( - window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft, - window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop); - } -}; - -(function(viewport) { - var B = Prototype.Browser, doc = document, element, property = {}; - - function getRootElement() { - if (B.WebKit && !doc.evaluate) - return document; - - if (B.Opera && window.parseFloat(window.opera.version()) < 9.5) - return document.body; - - return document.documentElement; - } - - function define(D) { - if (!element) element = getRootElement(); - - property[D] = 'client' + D; - - viewport['get' + D] = function() { return element[property[D]] }; - return viewport['get' + D](); - } - - viewport.getWidth = define.curry('Width'); - - viewport.getHeight = define.curry('Height'); -})(document.viewport); - - -Element.Storage = { - UID: 1 -}; - -Element.addMethods({ - getStorage: function(element) { - if (!(element = $(element))) return; - - var uid; - if (element === window) { - uid = 0; - } else { - if (typeof element._prototypeUID === "undefined") - element._prototypeUID = Element.Storage.UID++; - uid = element._prototypeUID; - } - - if (!Element.Storage[uid]) - Element.Storage[uid] = $H(); - - return Element.Storage[uid]; - }, - - store: function(element, key, value) { - if (!(element = $(element))) return; - - if (arguments.length === 2) { - Element.getStorage(element).update(key); - } else { - Element.getStorage(element).set(key, value); - } - - return element; - }, - - retrieve: function(element, key, defaultValue) { - if (!(element = $(element))) return; - var hash = Element.getStorage(element), value = hash.get(key); - - if (Object.isUndefined(value)) { - hash.set(key, defaultValue); - value = defaultValue; - } - - return value; - }, - - clone: function(element, deep) { - if (!(element = $(element))) return; - var clone = element.cloneNode(deep); - clone._prototypeUID = void 0; - if (deep) { - var descendants = Element.select(clone, '*'), - i = descendants.length; - while (i--) { - descendants[i]._prototypeUID = void 0; - } - } - return Element.extend(clone); - }, - - purge: function(element) { - if (!(element = $(element))) return; - purgeElement(element); - - var descendants = element.getElementsByTagName('*'), - i = descendants.length; - - while (i--) purgeElement(descendants[i]); - - return null; - } -}); - -(function() { - - function toDecimal(pctString) { - var match = pctString.match(/^(\d+)%?$/i); - if (!match) return null; - return (Number(match[1]) / 100); - } - - function getPixelValue(value, property) { - if (Object.isElement(value)) { - element = value; - value = element.getStyle(property); - } - if (value === null) { - return null; - } - - if ((/^(?:-)?\d+(\.\d+)?(px)?$/i).test(value)) { - return window.parseFloat(value); - } - - if (/\d/.test(value) && element.runtimeStyle) { - var style = element.style.left, rStyle = element.runtimeStyle.left; - element.runtimeStyle.left = element.currentStyle.left; - element.style.left = value || 0; - value = element.style.pixelLeft; - element.style.left = style; - element.runtimeStyle.left = rStyle; - - return value; - } - - if (value.include('%')) { - var decimal = toDecimal(value); - var whole; - if (property.include('left') || property.include('right') || - property.include('width')) { - whole = $(element.parentNode).measure('width'); - } else if (property.include('top') || property.include('bottom') || - property.include('height')) { - whole = $(element.parentNode).measure('height'); - } - - return whole * decimal; - } - - return 0; - } - - function toCSSPixels(number) { - if (Object.isString(number) && number.endsWith('px')) { - return number; - } - return number + 'px'; - } - - function isDisplayed(element) { - var originalElement = element; - while (element && element.parentNode) { - var display = element.getStyle('display'); - if (display === 'none') { - return false; - } - element = $(element.parentNode); - } - return true; - } - - var hasLayout = Prototype.K; - if ('currentStyle' in document.documentElement) { - hasLayout = function(element) { - if (!element.currentStyle.hasLayout) { - element.style.zoom = 1; - } - return element; - }; - } - - function cssNameFor(key) { - if (key.include('border')) key = key + '-width'; - return key.camelize(); - } - - Element.Layout = Class.create(Hash, { - initialize: function($super, element, preCompute) { - $super(); - this.element = $(element); - - Element.Layout.PROPERTIES.each( function(property) { - this._set(property, null); - }, this); - - if (preCompute) { - this._preComputing = true; - this._begin(); - Element.Layout.PROPERTIES.each( this._compute, this ); - this._end(); - this._preComputing = false; - } - }, - - _set: function(property, value) { - return Hash.prototype.set.call(this, property, value); - }, - - set: function(property, value) { - throw "Properties of Element.Layout are read-only."; - }, - - get: function($super, property) { - var value = $super(property); - return value === null ? this._compute(property) : value; - }, - - _begin: function() { - if (this._prepared) return; - - var element = this.element; - if (isDisplayed(element)) { - this._prepared = true; - return; - } - - var originalStyles = { - position: element.style.position || '', - width: element.style.width || '', - visibility: element.style.visibility || '', - display: element.style.display || '' - }; - - element.store('prototype_original_styles', originalStyles); - - var position = element.getStyle('position'), - width = element.getStyle('width'); - - element.setStyle({ - position: 'absolute', - visibility: 'hidden', - display: 'block' - }); - - var positionedWidth = element.getStyle('width'); - - var newWidth; - if (width && (positionedWidth === width)) { - newWidth = getPixelValue(width); - } else if (width && (position === 'absolute' || position === 'fixed')) { - newWidth = getPixelValue(width); - } else { - var parent = element.parentNode, pLayout = $(parent).getLayout(); - - newWidth = pLayout.get('width') - - this.get('margin-left') - - this.get('border-left') - - this.get('padding-left') - - this.get('padding-right') - - this.get('border-right') - - this.get('margin-right'); - } - - element.setStyle({ width: newWidth + 'px' }); - - this._prepared = true; - }, - - _end: function() { - var element = this.element; - var originalStyles = element.retrieve('prototype_original_styles'); - element.store('prototype_original_styles', null); - element.setStyle(originalStyles); - this._prepared = false; - }, - - _compute: function(property) { - var COMPUTATIONS = Element.Layout.COMPUTATIONS; - if (!(property in COMPUTATIONS)) { - throw "Property not found."; - } - return this._set(property, COMPUTATIONS[property].call(this, this.element)); - }, - - toObject: function() { - var args = $A(arguments); - var keys = (args.length === 0) ? Element.Layout.PROPERTIES : - args.join(' ').split(' '); - var obj = {}; - keys.each( function(key) { - if (!Element.Layout.PROPERTIES.include(key)) return; - var value = this.get(key); - if (value != null) obj[key] = value; - }, this); - return obj; - }, - - toHash: function() { - var obj = this.toObject.apply(this, arguments); - return new Hash(obj); - }, - - toCSS: function() { - var args = $A(arguments); - var keys = (args.length === 0) ? Element.Layout.PROPERTIES : - args.join(' ').split(' '); - var css = {}; - - keys.each( function(key) { - if (!Element.Layout.PROPERTIES.include(key)) return; - if (Element.Layout.COMPOSITE_PROPERTIES.include(key)) return; - - var value = this.get(key); - if (value != null) css[cssNameFor(key)] = value + 'px'; - }, this); - return css; - }, - - inspect: function() { - return "#"; - } - }); - - Object.extend(Element.Layout, { - PROPERTIES: $w('height width top left right bottom border-left border-right border-top border-bottom padding-left padding-right padding-top padding-bottom margin-top margin-bottom margin-left margin-right padding-box-width padding-box-height border-box-width border-box-height margin-box-width margin-box-height'), - - COMPOSITE_PROPERTIES: $w('padding-box-width padding-box-height margin-box-width margin-box-height border-box-width border-box-height'), - - COMPUTATIONS: { - 'height': function(element) { - if (!this._preComputing) this._begin(); - - var bHeight = this.get('border-box-height'); - if (bHeight <= 0) return 0; - - var bTop = this.get('border-top'), - bBottom = this.get('border-bottom'); - - var pTop = this.get('padding-top'), - pBottom = this.get('padding-bottom'); - - if (!this._preComputing) this._end(); - - return bHeight - bTop - bBottom - pTop - pBottom; - }, - - 'width': function(element) { - if (!this._preComputing) this._begin(); - - var bWidth = this.get('border-box-width'); - if (bWidth <= 0) return 0; - - var bLeft = this.get('border-left'), - bRight = this.get('border-right'); - - var pLeft = this.get('padding-left'), - pRight = this.get('padding-right'); - - if (!this._preComputing) this._end(); - - return bWidth - bLeft - bRight - pLeft - pRight; - }, - - 'padding-box-height': function(element) { - var height = this.get('height'), - pTop = this.get('padding-top'), - pBottom = this.get('padding-bottom'); - - return height + pTop + pBottom; - }, - - 'padding-box-width': function(element) { - var width = this.get('width'), - pLeft = this.get('padding-left'), - pRight = this.get('padding-right'); - - return width + pLeft + pRight; - }, - - 'border-box-height': function(element) { - return element.offsetHeight; - }, - - 'border-box-width': function(element) { - return element.offsetWidth; - }, - - 'margin-box-height': function(element) { - var bHeight = this.get('border-box-height'), - mTop = this.get('margin-top'), - mBottom = this.get('margin-bottom'); - - if (bHeight <= 0) return 0; - - return bHeight + mTop + mBottom; - }, - - 'margin-box-width': function(element) { - var bWidth = this.get('border-box-width'), - mLeft = this.get('margin-left'), - mRight = this.get('margin-right'); - - if (bWidth <= 0) return 0; - - return bWidth + mLeft + mRight; - }, - - 'top': function(element) { - var offset = element.positionedOffset(); - return offset.top; - }, - - 'bottom': function(element) { - var offset = element.positionedOffset(), - parent = element.getOffsetParent(), - pHeight = parent.measure('height'); - - var mHeight = this.get('border-box-height'); - - return pHeight - mHeight - offset.top; - }, - - 'left': function(element) { - var offset = element.positionedOffset(); - return offset.left; - }, - - 'right': function(element) { - var offset = element.positionedOffset(), - parent = element.getOffsetParent(), - pWidth = parent.measure('width'); - - var mWidth = this.get('border-box-width'); - - return pWidth - mWidth - offset.left; - }, - - 'padding-top': function(element) { - return getPixelValue(element, 'paddingTop'); - }, - - 'padding-bottom': function(element) { - return getPixelValue(element, 'paddingBottom'); - }, - - 'padding-left': function(element) { - return getPixelValue(element, 'paddingLeft'); - }, - - 'padding-right': function(element) { - return getPixelValue(element, 'paddingRight'); - }, - - 'border-top': function(element) { - return Object.isNumber(element.clientTop) ? element.clientTop : - getPixelValue(element, 'borderTopWidth'); - }, - - 'border-bottom': function(element) { - return Object.isNumber(element.clientBottom) ? element.clientBottom : - getPixelValue(element, 'borderBottomWidth'); - }, - - 'border-left': function(element) { - return Object.isNumber(element.clientLeft) ? element.clientLeft : - getPixelValue(element, 'borderLeftWidth'); - }, - - 'border-right': function(element) { - return Object.isNumber(element.clientRight) ? element.clientRight : - getPixelValue(element, 'borderRightWidth'); - }, - - 'margin-top': function(element) { - return getPixelValue(element, 'marginTop'); - }, - - 'margin-bottom': function(element) { - return getPixelValue(element, 'marginBottom'); - }, - - 'margin-left': function(element) { - return getPixelValue(element, 'marginLeft'); - }, - - 'margin-right': function(element) { - return getPixelValue(element, 'marginRight'); - } - } - }); - - if ('getBoundingClientRect' in document.documentElement) { - Object.extend(Element.Layout.COMPUTATIONS, { - 'right': function(element) { - var parent = hasLayout(element.getOffsetParent()); - var rect = element.getBoundingClientRect(), - pRect = parent.getBoundingClientRect(); - - return (pRect.right - rect.right).round(); - }, - - 'bottom': function(element) { - var parent = hasLayout(element.getOffsetParent()); - var rect = element.getBoundingClientRect(), - pRect = parent.getBoundingClientRect(); - - return (pRect.bottom - rect.bottom).round(); - } - }); - } - - Element.Offset = Class.create({ - initialize: function(left, top) { - this.left = left.round(); - this.top = top.round(); - - this[0] = this.left; - this[1] = this.top; - }, - - relativeTo: function(offset) { - return new Element.Offset( - this.left - offset.left, - this.top - offset.top - ); - }, - - inspect: function() { - return "#".interpolate(this); - }, - - toString: function() { - return "[#{left}, #{top}]".interpolate(this); - }, - - toArray: function() { - return [this.left, this.top]; - } - }); - - function getLayout(element, preCompute) { - return new Element.Layout(element, preCompute); - } - - function measure(element, property) { - return $(element).getLayout().get(property); - } - - function getDimensions(element) { - var layout = $(element).getLayout(); - return { - width: layout.get('width'), - height: layout.get('height') - }; - } - - function getOffsetParent(element) { - if (isDetached(element)) return $(document.body); - - var isInline = (Element.getStyle(element, 'display') === 'inline'); - if (!isInline && element.offsetParent) return $(element.offsetParent); - if (element === document.body) return $(element); - - while ((element = element.parentNode) && element !== document.body) { - if (Element.getStyle(element, 'position') !== 'static') { - return (element.nodeName === 'HTML') ? $(document.body) : $(element); - } - } - - return $(document.body); - } - - - function cumulativeOffset(element) { - var valueT = 0, valueL = 0; - do { - valueT += element.offsetTop || 0; - valueL += element.offsetLeft || 0; - element = element.offsetParent; - } while (element); - return new Element.Offset(valueL, valueT); - } - - function positionedOffset(element) { - var layout = element.getLayout(); - - var valueT = 0, valueL = 0; - do { - valueT += element.offsetTop || 0; - valueL += element.offsetLeft || 0; - element = element.offsetParent; - if (element) { - if (isBody(element)) break; - var p = Element.getStyle(element, 'position'); - if (p !== 'static') break; - } - } while (element); - - valueL -= layout.get('margin-top'); - valueT -= layout.get('margin-left'); - - return new Element.Offset(valueL, valueT); - } - - function cumulativeScrollOffset(element) { - var valueT = 0, valueL = 0; - do { - valueT += element.scrollTop || 0; - valueL += element.scrollLeft || 0; - element = element.parentNode; - } while (element); - return new Element.Offset(valueL, valueT); - } - - function viewportOffset(forElement) { - var valueT = 0, valueL = 0, docBody = document.body; - - var element = forElement; - do { - valueT += element.offsetTop || 0; - valueL += element.offsetLeft || 0; - if (element.offsetParent == docBody && - Element.getStyle(element, 'position') == 'absolute') break; - } while (element = element.offsetParent); - - element = forElement; - do { - if (element != docBody) { - valueT -= element.scrollTop || 0; - valueL -= element.scrollLeft || 0; - } - } while (element = element.parentNode); - return new Element.Offset(valueL, valueT); - } - - function absolutize(element) { - element = $(element); - - if (Element.getStyle(element, 'position') === 'absolute') { - return element; - } - - var offsetParent = getOffsetParent(element); - var eOffset = element.viewportOffset(), - pOffset = offsetParent.viewportOffset(); - - var offset = eOffset.relativeTo(pOffset); - var layout = element.getLayout(); - - element.store('prototype_absolutize_original_styles', { - left: element.getStyle('left'), - top: element.getStyle('top'), - width: element.getStyle('width'), - height: element.getStyle('height') - }); - - element.setStyle({ - position: 'absolute', - top: offset.top + 'px', - left: offset.left + 'px', - width: layout.get('width') + 'px', - height: layout.get('height') + 'px' - }); - - return element; - } - - function relativize(element) { - element = $(element); - if (Element.getStyle(element, 'position') === 'relative') { - return element; - } - - var originalStyles = - element.retrieve('prototype_absolutize_original_styles'); - - if (originalStyles) element.setStyle(originalStyles); - return element; - } - - Element.addMethods({ - getLayout: getLayout, - measure: measure, - getDimensions: getDimensions, - getOffsetParent: getOffsetParent, - cumulativeOffset: cumulativeOffset, - positionedOffset: positionedOffset, - cumulativeScrollOffset: cumulativeScrollOffset, - viewportOffset: viewportOffset, - absolutize: absolutize, - relativize: relativize - }); - - function isBody(element) { - return element.nodeName.toUpperCase() === 'BODY'; - } - - function isDetached(element) { - return element !== document.body && - !Element.descendantOf(element, document.body); - } - - if ('getBoundingClientRect' in document.documentElement) { - Element.addMethods({ - viewportOffset: function(element) { - element = $(element); - if (isDetached(element)) return new Element.Offset(0, 0); - - var rect = element.getBoundingClientRect(), - docEl = document.documentElement; - return new Element.Offset(rect.left - docEl.clientLeft, - rect.top - docEl.clientTop); - }, - - positionedOffset: function(element) { - element = $(element); - var parent = element.getOffsetParent(); - if (isDetached(element)) return new Element.Offset(0, 0); - - if (element.offsetParent && - element.offsetParent.nodeName.toUpperCase() === 'HTML') { - return positionedOffset(element); - } - - var eOffset = element.viewportOffset(), - pOffset = isBody(parent) ? viewportOffset(parent) : - parent.viewportOffset(); - var retOffset = eOffset.relativeTo(pOffset); - - var layout = element.getLayout(); - var top = retOffset.top - layout.get('margin-top'); - var left = retOffset.left - layout.get('margin-left'); - - return new Element.Offset(left, top); - } - }); - } -})(); -window.$$ = function() { - var expression = $A(arguments).join(', '); - return Prototype.Selector.select(expression, document); -}; - -Prototype.Selector = (function() { - - function select() { - throw new Error('Method "Prototype.Selector.select" must be defined.'); - } - - function match() { - throw new Error('Method "Prototype.Selector.match" must be defined.'); - } - - function find(elements, expression, index) { - index = index || 0; - var match = Prototype.Selector.match, length = elements.length, matchIndex = 0, i; - - for (i = 0; i < length; i++) { - if (match(elements[i], expression) && index == matchIndex++) { - return Element.extend(elements[i]); - } - } - } - - function extendElements(elements) { - for (var i = 0, length = elements.length; i < length; i++) { - Element.extend(elements[i]); - } - return elements; - } - - - var K = Prototype.K; - - return { - select: select, - match: match, - find: find, - extendElements: (Element.extend === K) ? K : extendElements, - extendElement: Element.extend - }; -})(); -Prototype._original_property = window.Sizzle; -/*! - * Sizzle CSS Selector Engine - v1.0 - * Copyright 2009, The Dojo Foundation - * Released under the MIT, BSD, and GPL Licenses. - * More information: http://sizzlejs.com/ - */ -(function(){ - -var chunker = /((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^[\]]*\]|['"][^'"]*['"]|[^[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g, - done = 0, - toString = Object.prototype.toString, - hasDuplicate = false, - baseHasDuplicate = true; - -[0, 0].sort(function(){ - baseHasDuplicate = false; - return 0; -}); - -var Sizzle = function(selector, context, results, seed) { - results = results || []; - var origContext = context = context || document; - - if ( context.nodeType !== 1 && context.nodeType !== 9 ) { - return []; - } - - if ( !selector || typeof selector !== "string" ) { - return results; - } - - var parts = [], m, set, checkSet, check, mode, extra, prune = true, contextXML = isXML(context), - soFar = selector; - - while ( (chunker.exec(""), m = chunker.exec(soFar)) !== null ) { - soFar = m[3]; - - parts.push( m[1] ); - - if ( m[2] ) { - extra = m[3]; - break; - } - } - - if ( parts.length > 1 && origPOS.exec( selector ) ) { - if ( parts.length === 2 && Expr.relative[ parts[0] ] ) { - set = posProcess( parts[0] + parts[1], context ); - } else { - set = Expr.relative[ parts[0] ] ? - [ context ] : - Sizzle( parts.shift(), context ); - - while ( parts.length ) { - selector = parts.shift(); - - if ( Expr.relative[ selector ] ) - selector += parts.shift(); - - set = posProcess( selector, set ); - } - } - } else { - if ( !seed && parts.length > 1 && context.nodeType === 9 && !contextXML && - Expr.match.ID.test(parts[0]) && !Expr.match.ID.test(parts[parts.length - 1]) ) { - var ret = Sizzle.find( parts.shift(), context, contextXML ); - context = ret.expr ? Sizzle.filter( ret.expr, ret.set )[0] : ret.set[0]; - } - - if ( context ) { - var ret = seed ? - { expr: parts.pop(), set: makeArray(seed) } : - Sizzle.find( parts.pop(), parts.length === 1 && (parts[0] === "~" || parts[0] === "+") && context.parentNode ? context.parentNode : context, contextXML ); - set = ret.expr ? Sizzle.filter( ret.expr, ret.set ) : ret.set; - - if ( parts.length > 0 ) { - checkSet = makeArray(set); - } else { - prune = false; - } - - while ( parts.length ) { - var cur = parts.pop(), pop = cur; - - if ( !Expr.relative[ cur ] ) { - cur = ""; - } else { - pop = parts.pop(); - } - - if ( pop == null ) { - pop = context; - } - - Expr.relative[ cur ]( checkSet, pop, contextXML ); - } - } else { - checkSet = parts = []; - } - } - - if ( !checkSet ) { - checkSet = set; - } - - if ( !checkSet ) { - throw "Syntax error, unrecognized expression: " + (cur || selector); - } - - if ( toString.call(checkSet) === "[object Array]" ) { - if ( !prune ) { - results.push.apply( results, checkSet ); - } else if ( context && context.nodeType === 1 ) { - for ( var i = 0; checkSet[i] != null; i++ ) { - if ( checkSet[i] && (checkSet[i] === true || checkSet[i].nodeType === 1 && contains(context, checkSet[i])) ) { - results.push( set[i] ); - } - } - } else { - for ( var i = 0; checkSet[i] != null; i++ ) { - if ( checkSet[i] && checkSet[i].nodeType === 1 ) { - results.push( set[i] ); - } - } - } - } else { - makeArray( checkSet, results ); - } - - if ( extra ) { - Sizzle( extra, origContext, results, seed ); - Sizzle.uniqueSort( results ); - } - - return results; -}; - -Sizzle.uniqueSort = function(results){ - if ( sortOrder ) { - hasDuplicate = baseHasDuplicate; - results.sort(sortOrder); - - if ( hasDuplicate ) { - for ( var i = 1; i < results.length; i++ ) { - if ( results[i] === results[i-1] ) { - results.splice(i--, 1); - } - } - } - } - - return results; -}; - -Sizzle.matches = function(expr, set){ - return Sizzle(expr, null, null, set); -}; - -Sizzle.find = function(expr, context, isXML){ - var set, match; - - if ( !expr ) { - return []; - } - - for ( var i = 0, l = Expr.order.length; i < l; i++ ) { - var type = Expr.order[i], match; - - if ( (match = Expr.leftMatch[ type ].exec( expr )) ) { - var left = match[1]; - match.splice(1,1); - - if ( left.substr( left.length - 1 ) !== "\\" ) { - match[1] = (match[1] || "").replace(/\\/g, ""); - set = Expr.find[ type ]( match, context, isXML ); - if ( set != null ) { - expr = expr.replace( Expr.match[ type ], "" ); - break; - } - } - } - } - - if ( !set ) { - set = context.getElementsByTagName("*"); - } - - return {set: set, expr: expr}; -}; - -Sizzle.filter = function(expr, set, inplace, not){ - var old = expr, result = [], curLoop = set, match, anyFound, - isXMLFilter = set && set[0] && isXML(set[0]); - - while ( expr && set.length ) { - for ( var type in Expr.filter ) { - if ( (match = Expr.match[ type ].exec( expr )) != null ) { - var filter = Expr.filter[ type ], found, item; - anyFound = false; - - if ( curLoop == result ) { - result = []; - } - - if ( Expr.preFilter[ type ] ) { - match = Expr.preFilter[ type ]( match, curLoop, inplace, result, not, isXMLFilter ); - - if ( !match ) { - anyFound = found = true; - } else if ( match === true ) { - continue; - } - } - - if ( match ) { - for ( var i = 0; (item = curLoop[i]) != null; i++ ) { - if ( item ) { - found = filter( item, match, i, curLoop ); - var pass = not ^ !!found; - - if ( inplace && found != null ) { - if ( pass ) { - anyFound = true; - } else { - curLoop[i] = false; - } - } else if ( pass ) { - result.push( item ); - anyFound = true; - } - } - } - } - - if ( found !== undefined ) { - if ( !inplace ) { - curLoop = result; - } - - expr = expr.replace( Expr.match[ type ], "" ); - - if ( !anyFound ) { - return []; - } - - break; - } - } - } - - if ( expr == old ) { - if ( anyFound == null ) { - throw "Syntax error, unrecognized expression: " + expr; - } else { - break; - } - } - - old = expr; - } - - return curLoop; -}; - -var Expr = Sizzle.selectors = { - order: [ "ID", "NAME", "TAG" ], - match: { - ID: /#((?:[\w\u00c0-\uFFFF-]|\\.)+)/, - CLASS: /\.((?:[\w\u00c0-\uFFFF-]|\\.)+)/, - NAME: /\[name=['"]*((?:[\w\u00c0-\uFFFF-]|\\.)+)['"]*\]/, - ATTR: /\[\s*((?:[\w\u00c0-\uFFFF-]|\\.)+)\s*(?:(\S?=)\s*(['"]*)(.*?)\3|)\s*\]/, - TAG: /^((?:[\w\u00c0-\uFFFF\*-]|\\.)+)/, - CHILD: /:(only|nth|last|first)-child(?:\((even|odd|[\dn+-]*)\))?/, - POS: /:(nth|eq|gt|lt|first|last|even|odd)(?:\((\d*)\))?(?=[^-]|$)/, - PSEUDO: /:((?:[\w\u00c0-\uFFFF-]|\\.)+)(?:\((['"]*)((?:\([^\)]+\)|[^\2\(\)]*)+)\2\))?/ - }, - leftMatch: {}, - attrMap: { - "class": "className", - "for": "htmlFor" - }, - attrHandle: { - href: function(elem){ - return elem.getAttribute("href"); - } - }, - relative: { - "+": function(checkSet, part, isXML){ - var isPartStr = typeof part === "string", - isTag = isPartStr && !/\W/.test(part), - isPartStrNotTag = isPartStr && !isTag; - - if ( isTag && !isXML ) { - part = part.toUpperCase(); - } - - for ( var i = 0, l = checkSet.length, elem; i < l; i++ ) { - if ( (elem = checkSet[i]) ) { - while ( (elem = elem.previousSibling) && elem.nodeType !== 1 ) {} - - checkSet[i] = isPartStrNotTag || elem && elem.nodeName === part ? - elem || false : - elem === part; - } - } - - if ( isPartStrNotTag ) { - Sizzle.filter( part, checkSet, true ); - } - }, - ">": function(checkSet, part, isXML){ - var isPartStr = typeof part === "string"; - - if ( isPartStr && !/\W/.test(part) ) { - part = isXML ? part : part.toUpperCase(); - - for ( var i = 0, l = checkSet.length; i < l; i++ ) { - var elem = checkSet[i]; - if ( elem ) { - var parent = elem.parentNode; - checkSet[i] = parent.nodeName === part ? parent : false; - } - } - } else { - for ( var i = 0, l = checkSet.length; i < l; i++ ) { - var elem = checkSet[i]; - if ( elem ) { - checkSet[i] = isPartStr ? - elem.parentNode : - elem.parentNode === part; - } - } - - if ( isPartStr ) { - Sizzle.filter( part, checkSet, true ); - } - } - }, - "": function(checkSet, part, isXML){ - var doneName = done++, checkFn = dirCheck; - - if ( !/\W/.test(part) ) { - var nodeCheck = part = isXML ? part : part.toUpperCase(); - checkFn = dirNodeCheck; - } - - checkFn("parentNode", part, doneName, checkSet, nodeCheck, isXML); - }, - "~": function(checkSet, part, isXML){ - var doneName = done++, checkFn = dirCheck; - - if ( typeof part === "string" && !/\W/.test(part) ) { - var nodeCheck = part = isXML ? part : part.toUpperCase(); - checkFn = dirNodeCheck; - } - - checkFn("previousSibling", part, doneName, checkSet, nodeCheck, isXML); - } - }, - find: { - ID: function(match, context, isXML){ - if ( typeof context.getElementById !== "undefined" && !isXML ) { - var m = context.getElementById(match[1]); - return m ? [m] : []; - } - }, - NAME: function(match, context, isXML){ - if ( typeof context.getElementsByName !== "undefined" ) { - var ret = [], results = context.getElementsByName(match[1]); - - for ( var i = 0, l = results.length; i < l; i++ ) { - if ( results[i].getAttribute("name") === match[1] ) { - ret.push( results[i] ); - } - } - - return ret.length === 0 ? null : ret; - } - }, - TAG: function(match, context){ - return context.getElementsByTagName(match[1]); - } - }, - preFilter: { - CLASS: function(match, curLoop, inplace, result, not, isXML){ - match = " " + match[1].replace(/\\/g, "") + " "; - - if ( isXML ) { - return match; - } - - for ( var i = 0, elem; (elem = curLoop[i]) != null; i++ ) { - if ( elem ) { - if ( not ^ (elem.className && (" " + elem.className + " ").indexOf(match) >= 0) ) { - if ( !inplace ) - result.push( elem ); - } else if ( inplace ) { - curLoop[i] = false; - } - } - } - - return false; - }, - ID: function(match){ - return match[1].replace(/\\/g, ""); - }, - TAG: function(match, curLoop){ - for ( var i = 0; curLoop[i] === false; i++ ){} - return curLoop[i] && isXML(curLoop[i]) ? match[1] : match[1].toUpperCase(); - }, - CHILD: function(match){ - if ( match[1] == "nth" ) { - var test = /(-?)(\d*)n((?:\+|-)?\d*)/.exec( - match[2] == "even" && "2n" || match[2] == "odd" && "2n+1" || - !/\D/.test( match[2] ) && "0n+" + match[2] || match[2]); - - match[2] = (test[1] + (test[2] || 1)) - 0; - match[3] = test[3] - 0; - } - - match[0] = done++; - - return match; - }, - ATTR: function(match, curLoop, inplace, result, not, isXML){ - var name = match[1].replace(/\\/g, ""); - - if ( !isXML && Expr.attrMap[name] ) { - match[1] = Expr.attrMap[name]; - } - - if ( match[2] === "~=" ) { - match[4] = " " + match[4] + " "; - } - - return match; - }, - PSEUDO: function(match, curLoop, inplace, result, not){ - if ( match[1] === "not" ) { - if ( ( chunker.exec(match[3]) || "" ).length > 1 || /^\w/.test(match[3]) ) { - match[3] = Sizzle(match[3], null, null, curLoop); - } else { - var ret = Sizzle.filter(match[3], curLoop, inplace, true ^ not); - if ( !inplace ) { - result.push.apply( result, ret ); - } - return false; - } - } else if ( Expr.match.POS.test( match[0] ) || Expr.match.CHILD.test( match[0] ) ) { - return true; - } - - return match; - }, - POS: function(match){ - match.unshift( true ); - return match; - } - }, - filters: { - enabled: function(elem){ - return elem.disabled === false && elem.type !== "hidden"; - }, - disabled: function(elem){ - return elem.disabled === true; - }, - checked: function(elem){ - return elem.checked === true; - }, - selected: function(elem){ - elem.parentNode.selectedIndex; - return elem.selected === true; - }, - parent: function(elem){ - return !!elem.firstChild; - }, - empty: function(elem){ - return !elem.firstChild; - }, - has: function(elem, i, match){ - return !!Sizzle( match[3], elem ).length; - }, - header: function(elem){ - return /h\d/i.test( elem.nodeName ); - }, - text: function(elem){ - return "text" === elem.type; - }, - radio: function(elem){ - return "radio" === elem.type; - }, - checkbox: function(elem){ - return "checkbox" === elem.type; - }, - file: function(elem){ - return "file" === elem.type; - }, - password: function(elem){ - return "password" === elem.type; - }, - submit: function(elem){ - return "submit" === elem.type; - }, - image: function(elem){ - return "image" === elem.type; - }, - reset: function(elem){ - return "reset" === elem.type; - }, - button: function(elem){ - return "button" === elem.type || elem.nodeName.toUpperCase() === "BUTTON"; - }, - input: function(elem){ - return /input|select|textarea|button/i.test(elem.nodeName); - } - }, - setFilters: { - first: function(elem, i){ - return i === 0; - }, - last: function(elem, i, match, array){ - return i === array.length - 1; - }, - even: function(elem, i){ - return i % 2 === 0; - }, - odd: function(elem, i){ - return i % 2 === 1; - }, - lt: function(elem, i, match){ - return i < match[3] - 0; - }, - gt: function(elem, i, match){ - return i > match[3] - 0; - }, - nth: function(elem, i, match){ - return match[3] - 0 == i; - }, - eq: function(elem, i, match){ - return match[3] - 0 == i; - } - }, - filter: { - PSEUDO: function(elem, match, i, array){ - var name = match[1], filter = Expr.filters[ name ]; - - if ( filter ) { - return filter( elem, i, match, array ); - } else if ( name === "contains" ) { - return (elem.textContent || elem.innerText || "").indexOf(match[3]) >= 0; - } else if ( name === "not" ) { - var not = match[3]; - - for ( var i = 0, l = not.length; i < l; i++ ) { - if ( not[i] === elem ) { - return false; - } - } - - return true; - } - }, - CHILD: function(elem, match){ - var type = match[1], node = elem; - switch (type) { - case 'only': - case 'first': - while ( (node = node.previousSibling) ) { - if ( node.nodeType === 1 ) return false; - } - if ( type == 'first') return true; - node = elem; - case 'last': - while ( (node = node.nextSibling) ) { - if ( node.nodeType === 1 ) return false; - } - return true; - case 'nth': - var first = match[2], last = match[3]; - - if ( first == 1 && last == 0 ) { - return true; - } - - var doneName = match[0], - parent = elem.parentNode; - - if ( parent && (parent.sizcache !== doneName || !elem.nodeIndex) ) { - var count = 0; - for ( node = parent.firstChild; node; node = node.nextSibling ) { - if ( node.nodeType === 1 ) { - node.nodeIndex = ++count; - } - } - parent.sizcache = doneName; - } - - var diff = elem.nodeIndex - last; - if ( first == 0 ) { - return diff == 0; - } else { - return ( diff % first == 0 && diff / first >= 0 ); - } - } - }, - ID: function(elem, match){ - return elem.nodeType === 1 && elem.getAttribute("id") === match; - }, - TAG: function(elem, match){ - return (match === "*" && elem.nodeType === 1) || elem.nodeName === match; - }, - CLASS: function(elem, match){ - return (" " + (elem.className || elem.getAttribute("class")) + " ") - .indexOf( match ) > -1; - }, - ATTR: function(elem, match){ - var name = match[1], - result = Expr.attrHandle[ name ] ? - Expr.attrHandle[ name ]( elem ) : - elem[ name ] != null ? - elem[ name ] : - elem.getAttribute( name ), - value = result + "", - type = match[2], - check = match[4]; - - return result == null ? - type === "!=" : - type === "=" ? - value === check : - type === "*=" ? - value.indexOf(check) >= 0 : - type === "~=" ? - (" " + value + " ").indexOf(check) >= 0 : - !check ? - value && result !== false : - type === "!=" ? - value != check : - type === "^=" ? - value.indexOf(check) === 0 : - type === "$=" ? - value.substr(value.length - check.length) === check : - type === "|=" ? - value === check || value.substr(0, check.length + 1) === check + "-" : - false; - }, - POS: function(elem, match, i, array){ - var name = match[2], filter = Expr.setFilters[ name ]; - - if ( filter ) { - return filter( elem, i, match, array ); - } - } - } -}; - -var origPOS = Expr.match.POS; - -for ( var type in Expr.match ) { - Expr.match[ type ] = new RegExp( Expr.match[ type ].source + /(?![^\[]*\])(?![^\(]*\))/.source ); - Expr.leftMatch[ type ] = new RegExp( /(^(?:.|\r|\n)*?)/.source + Expr.match[ type ].source ); -} - -var makeArray = function(array, results) { - array = Array.prototype.slice.call( array, 0 ); - - if ( results ) { - results.push.apply( results, array ); - return results; - } - - return array; -}; - -try { - Array.prototype.slice.call( document.documentElement.childNodes, 0 ); - -} catch(e){ - makeArray = function(array, results) { - var ret = results || []; - - if ( toString.call(array) === "[object Array]" ) { - Array.prototype.push.apply( ret, array ); - } else { - if ( typeof array.length === "number" ) { - for ( var i = 0, l = array.length; i < l; i++ ) { - ret.push( array[i] ); - } - } else { - for ( var i = 0; array[i]; i++ ) { - ret.push( array[i] ); - } - } - } - - return ret; - }; -} - -var sortOrder; - -if ( document.documentElement.compareDocumentPosition ) { - sortOrder = function( a, b ) { - if ( !a.compareDocumentPosition || !b.compareDocumentPosition ) { - if ( a == b ) { - hasDuplicate = true; - } - return 0; - } - - var ret = a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1; - if ( ret === 0 ) { - hasDuplicate = true; - } - return ret; - }; -} else if ( "sourceIndex" in document.documentElement ) { - sortOrder = function( a, b ) { - if ( !a.sourceIndex || !b.sourceIndex ) { - if ( a == b ) { - hasDuplicate = true; - } - return 0; - } - - var ret = a.sourceIndex - b.sourceIndex; - if ( ret === 0 ) { - hasDuplicate = true; - } - return ret; - }; -} else if ( document.createRange ) { - sortOrder = function( a, b ) { - if ( !a.ownerDocument || !b.ownerDocument ) { - if ( a == b ) { - hasDuplicate = true; - } - return 0; - } - - var aRange = a.ownerDocument.createRange(), bRange = b.ownerDocument.createRange(); - aRange.setStart(a, 0); - aRange.setEnd(a, 0); - bRange.setStart(b, 0); - bRange.setEnd(b, 0); - var ret = aRange.compareBoundaryPoints(Range.START_TO_END, bRange); - if ( ret === 0 ) { - hasDuplicate = true; - } - return ret; - }; -} - -(function(){ - var form = document.createElement("div"), - id = "script" + (new Date).getTime(); - form.innerHTML = ""; - - var root = document.documentElement; - root.insertBefore( form, root.firstChild ); - - if ( !!document.getElementById( id ) ) { - Expr.find.ID = function(match, context, isXML){ - if ( typeof context.getElementById !== "undefined" && !isXML ) { - var m = context.getElementById(match[1]); - return m ? m.id === match[1] || typeof m.getAttributeNode !== "undefined" && m.getAttributeNode("id").nodeValue === match[1] ? [m] : undefined : []; - } - }; - - Expr.filter.ID = function(elem, match){ - var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id"); - return elem.nodeType === 1 && node && node.nodeValue === match; - }; - } - - root.removeChild( form ); - root = form = null; // release memory in IE -})(); - -(function(){ - - var div = document.createElement("div"); - div.appendChild( document.createComment("") ); - - if ( div.getElementsByTagName("*").length > 0 ) { - Expr.find.TAG = function(match, context){ - var results = context.getElementsByTagName(match[1]); - - if ( match[1] === "*" ) { - var tmp = []; - - for ( var i = 0; results[i]; i++ ) { - if ( results[i].nodeType === 1 ) { - tmp.push( results[i] ); - } - } - - results = tmp; - } - - return results; - }; - } - - div.innerHTML = ""; - if ( div.firstChild && typeof div.firstChild.getAttribute !== "undefined" && - div.firstChild.getAttribute("href") !== "#" ) { - Expr.attrHandle.href = function(elem){ - return elem.getAttribute("href", 2); - }; - } - - div = null; // release memory in IE -})(); - -if ( document.querySelectorAll ) (function(){ - var oldSizzle = Sizzle, div = document.createElement("div"); - div.innerHTML = "

    "; - - if ( div.querySelectorAll && div.querySelectorAll(".TEST").length === 0 ) { - return; - } - - Sizzle = function(query, context, extra, seed){ - context = context || document; - - if ( !seed && context.nodeType === 9 && !isXML(context) ) { - try { - return makeArray( context.querySelectorAll(query), extra ); - } catch(e){} - } - - return oldSizzle(query, context, extra, seed); - }; - - for ( var prop in oldSizzle ) { - Sizzle[ prop ] = oldSizzle[ prop ]; - } - - div = null; // release memory in IE -})(); - -if ( document.getElementsByClassName && document.documentElement.getElementsByClassName ) (function(){ - var div = document.createElement("div"); - div.innerHTML = "
    "; - - if ( div.getElementsByClassName("e").length === 0 ) - return; - - div.lastChild.className = "e"; - - if ( div.getElementsByClassName("e").length === 1 ) - return; - - Expr.order.splice(1, 0, "CLASS"); - Expr.find.CLASS = function(match, context, isXML) { - if ( typeof context.getElementsByClassName !== "undefined" && !isXML ) { - return context.getElementsByClassName(match[1]); - } - }; - - div = null; // release memory in IE -})(); - -function dirNodeCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) { - var sibDir = dir == "previousSibling" && !isXML; - for ( var i = 0, l = checkSet.length; i < l; i++ ) { - var elem = checkSet[i]; - if ( elem ) { - if ( sibDir && elem.nodeType === 1 ){ - elem.sizcache = doneName; - elem.sizset = i; - } - elem = elem[dir]; - var match = false; - - while ( elem ) { - if ( elem.sizcache === doneName ) { - match = checkSet[elem.sizset]; - break; - } - - if ( elem.nodeType === 1 && !isXML ){ - elem.sizcache = doneName; - elem.sizset = i; - } - - if ( elem.nodeName === cur ) { - match = elem; - break; - } - - elem = elem[dir]; - } - - checkSet[i] = match; - } - } -} - -function dirCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) { - var sibDir = dir == "previousSibling" && !isXML; - for ( var i = 0, l = checkSet.length; i < l; i++ ) { - var elem = checkSet[i]; - if ( elem ) { - if ( sibDir && elem.nodeType === 1 ) { - elem.sizcache = doneName; - elem.sizset = i; - } - elem = elem[dir]; - var match = false; - - while ( elem ) { - if ( elem.sizcache === doneName ) { - match = checkSet[elem.sizset]; - break; - } - - if ( elem.nodeType === 1 ) { - if ( !isXML ) { - elem.sizcache = doneName; - elem.sizset = i; - } - if ( typeof cur !== "string" ) { - if ( elem === cur ) { - match = true; - break; - } - - } else if ( Sizzle.filter( cur, [elem] ).length > 0 ) { - match = elem; - break; - } - } - - elem = elem[dir]; - } - - checkSet[i] = match; - } - } -} - -var contains = document.compareDocumentPosition ? function(a, b){ - return a.compareDocumentPosition(b) & 16; -} : function(a, b){ - return a !== b && (a.contains ? a.contains(b) : true); -}; - -var isXML = function(elem){ - return elem.nodeType === 9 && elem.documentElement.nodeName !== "HTML" || - !!elem.ownerDocument && elem.ownerDocument.documentElement.nodeName !== "HTML"; -}; - -var posProcess = function(selector, context){ - var tmpSet = [], later = "", match, - root = context.nodeType ? [context] : context; - - while ( (match = Expr.match.PSEUDO.exec( selector )) ) { - later += match[0]; - selector = selector.replace( Expr.match.PSEUDO, "" ); - } - - selector = Expr.relative[selector] ? selector + "*" : selector; - - for ( var i = 0, l = root.length; i < l; i++ ) { - Sizzle( selector, root[i], tmpSet ); - } - - return Sizzle.filter( later, tmpSet ); -}; - - -window.Sizzle = Sizzle; - -})(); - -;(function(engine) { - var extendElements = Prototype.Selector.extendElements; - - function select(selector, scope) { - return extendElements(engine(selector, scope || document)); - } - - function match(element, selector) { - return engine.matches(selector, [element]).length == 1; - } - - Prototype.Selector.engine = engine; - Prototype.Selector.select = select; - Prototype.Selector.match = match; -})(Sizzle); - -window.Sizzle = Prototype._original_property; -delete Prototype._original_property; - -var Form = { - reset: function(form) { - form = $(form); - form.reset(); - return form; - }, - - serializeElements: function(elements, options) { - if (typeof options != 'object') options = { hash: !!options }; - else if (Object.isUndefined(options.hash)) options.hash = true; - var key, value, submitted = false, submit = options.submit; - - var data = elements.inject({ }, function(result, element) { - if (!element.disabled && element.name) { - key = element.name; value = $(element).getValue(); - if (value != null && element.type != 'file' && (element.type != 'submit' || (!submitted && - submit !== false && (!submit || key == submit) && (submitted = true)))) { - if (key in result) { - if (!Object.isArray(result[key])) result[key] = [result[key]]; - result[key].push(value); - } - else result[key] = value; - } - } - return result; - }); - - return options.hash ? data : Object.toQueryString(data); - } -}; - -Form.Methods = { - serialize: function(form, options) { - return Form.serializeElements(Form.getElements(form), options); - }, - - getElements: function(form) { - var elements = $(form).getElementsByTagName('*'), - element, - arr = [ ], - serializers = Form.Element.Serializers; - for (var i = 0; element = elements[i]; i++) { - arr.push(element); - } - return arr.inject([], function(elements, child) { - if (serializers[child.tagName.toLowerCase()]) - elements.push(Element.extend(child)); - return elements; - }) - }, - - getInputs: function(form, typeName, name) { - form = $(form); - var inputs = form.getElementsByTagName('input'); - - if (!typeName && !name) return $A(inputs).map(Element.extend); - - for (var i = 0, matchingInputs = [], length = inputs.length; i < length; i++) { - var input = inputs[i]; - if ((typeName && input.type != typeName) || (name && input.name != name)) - continue; - matchingInputs.push(Element.extend(input)); - } - - return matchingInputs; - }, - - disable: function(form) { - form = $(form); - Form.getElements(form).invoke('disable'); - return form; - }, - - enable: function(form) { - form = $(form); - Form.getElements(form).invoke('enable'); - return form; - }, - - findFirstElement: function(form) { - var elements = $(form).getElements().findAll(function(element) { - return 'hidden' != element.type && !element.disabled; - }); - var firstByIndex = elements.findAll(function(element) { - return element.hasAttribute('tabIndex') && element.tabIndex >= 0; - }).sortBy(function(element) { return element.tabIndex }).first(); - - return firstByIndex ? firstByIndex : elements.find(function(element) { - return /^(?:input|select|textarea)$/i.test(element.tagName); - }); - }, - - focusFirstElement: function(form) { - form = $(form); - form.findFirstElement().activate(); - return form; - }, - - request: function(form, options) { - form = $(form), options = Object.clone(options || { }); - - var params = options.parameters, action = form.readAttribute('action') || ''; - if (action.blank()) action = window.location.href; - options.parameters = form.serialize(true); - - if (params) { - if (Object.isString(params)) params = params.toQueryParams(); - Object.extend(options.parameters, params); - } - - if (form.hasAttribute('method') && !options.method) - options.method = form.method; - - return new Ajax.Request(action, options); - } -}; - -/*--------------------------------------------------------------------------*/ - - -Form.Element = { - focus: function(element) { - $(element).focus(); - return element; - }, - - select: function(element) { - $(element).select(); - return element; - } -}; - -Form.Element.Methods = { - - serialize: function(element) { - element = $(element); - if (!element.disabled && element.name) { - var value = element.getValue(); - if (value != undefined) { - var pair = { }; - pair[element.name] = value; - return Object.toQueryString(pair); - } - } - return ''; - }, - - getValue: function(element) { - element = $(element); - var method = element.tagName.toLowerCase(); - return Form.Element.Serializers[method](element); - }, - - setValue: function(element, value) { - element = $(element); - var method = element.tagName.toLowerCase(); - Form.Element.Serializers[method](element, value); - return element; - }, - - clear: function(element) { - $(element).value = ''; - return element; - }, - - present: function(element) { - return $(element).value != ''; - }, - - activate: function(element) { - element = $(element); - try { - element.focus(); - if (element.select && (element.tagName.toLowerCase() != 'input' || - !(/^(?:button|reset|submit)$/i.test(element.type)))) - element.select(); - } catch (e) { } - return element; - }, - - disable: function(element) { - element = $(element); - element.disabled = true; - return element; - }, - - enable: function(element) { - element = $(element); - element.disabled = false; - return element; - } -}; - -/*--------------------------------------------------------------------------*/ - -var Field = Form.Element; - -var $F = Form.Element.Methods.getValue; - -/*--------------------------------------------------------------------------*/ - -Form.Element.Serializers = { - input: function(element, value) { - switch (element.type.toLowerCase()) { - case 'checkbox': - case 'radio': - return Form.Element.Serializers.inputSelector(element, value); - default: - return Form.Element.Serializers.textarea(element, value); - } - }, - - inputSelector: function(element, value) { - if (Object.isUndefined(value)) return element.checked ? element.value : null; - else element.checked = !!value; - }, - - textarea: function(element, value) { - if (Object.isUndefined(value)) return element.value; - else element.value = value; - }, - - select: function(element, value) { - if (Object.isUndefined(value)) - return this[element.type == 'select-one' ? - 'selectOne' : 'selectMany'](element); - else { - var opt, currentValue, single = !Object.isArray(value); - for (var i = 0, length = element.length; i < length; i++) { - opt = element.options[i]; - currentValue = this.optionValue(opt); - if (single) { - if (currentValue == value) { - opt.selected = true; - return; - } - } - else opt.selected = value.include(currentValue); - } - } - }, - - selectOne: function(element) { - var index = element.selectedIndex; - return index >= 0 ? this.optionValue(element.options[index]) : null; - }, - - selectMany: function(element) { - var values, length = element.length; - if (!length) return null; - - for (var i = 0, values = []; i < length; i++) { - var opt = element.options[i]; - if (opt.selected) values.push(this.optionValue(opt)); - } - return values; - }, - - optionValue: function(opt) { - return Element.extend(opt).hasAttribute('value') ? opt.value : opt.text; - } -}; - -/*--------------------------------------------------------------------------*/ - - -Abstract.TimedObserver = Class.create(PeriodicalExecuter, { - initialize: function($super, element, frequency, callback) { - $super(callback, frequency); - this.element = $(element); - this.lastValue = this.getValue(); - }, - - execute: function() { - var value = this.getValue(); - if (Object.isString(this.lastValue) && Object.isString(value) ? - this.lastValue != value : String(this.lastValue) != String(value)) { - this.callback(this.element, value); - this.lastValue = value; - } - } -}); - -Form.Element.Observer = Class.create(Abstract.TimedObserver, { - getValue: function() { - return Form.Element.getValue(this.element); - } -}); - -Form.Observer = Class.create(Abstract.TimedObserver, { - getValue: function() { - return Form.serialize(this.element); - } -}); - -/*--------------------------------------------------------------------------*/ - -Abstract.EventObserver = Class.create({ - initialize: function(element, callback) { - this.element = $(element); - this.callback = callback; - - this.lastValue = this.getValue(); - if (this.element.tagName.toLowerCase() == 'form') - this.registerFormCallbacks(); - else - this.registerCallback(this.element); - }, - - onElementEvent: function() { - var value = this.getValue(); - if (this.lastValue != value) { - this.callback(this.element, value); - this.lastValue = value; - } - }, - - registerFormCallbacks: function() { - Form.getElements(this.element).each(this.registerCallback, this); - }, - - registerCallback: function(element) { - if (element.type) { - switch (element.type.toLowerCase()) { - case 'checkbox': - case 'radio': - Event.observe(element, 'click', this.onElementEvent.bind(this)); - break; - default: - Event.observe(element, 'change', this.onElementEvent.bind(this)); - break; - } - } - } -}); - -Form.Element.EventObserver = Class.create(Abstract.EventObserver, { - getValue: function() { - return Form.Element.getValue(this.element); - } -}); - -Form.EventObserver = Class.create(Abstract.EventObserver, { - getValue: function() { - return Form.serialize(this.element); - } -}); -(function() { - - var Event = { - KEY_BACKSPACE: 8, - KEY_TAB: 9, - KEY_RETURN: 13, - KEY_ESC: 27, - KEY_LEFT: 37, - KEY_UP: 38, - KEY_RIGHT: 39, - KEY_DOWN: 40, - KEY_DELETE: 46, - KEY_HOME: 36, - KEY_END: 35, - KEY_PAGEUP: 33, - KEY_PAGEDOWN: 34, - KEY_INSERT: 45, - - cache: {} - }; - - var docEl = document.documentElement; - var MOUSEENTER_MOUSELEAVE_EVENTS_SUPPORTED = 'onmouseenter' in docEl - && 'onmouseleave' in docEl; - - var _isButton; - if (Prototype.Browser.IE) { - var buttonMap = { 0: 1, 1: 4, 2: 2 }; - _isButton = function(event, code) { - return event.button === buttonMap[code]; - }; - } else if (Prototype.Browser.WebKit) { - _isButton = function(event, code) { - switch (code) { - case 0: return event.which == 1 && !event.metaKey; - case 1: return event.which == 1 && event.metaKey; - default: return false; - } - }; - } else { - _isButton = function(event, code) { - return event.which ? (event.which === code + 1) : (event.button === code); - }; - } - - function isLeftClick(event) { return _isButton(event, 0) } - - function isMiddleClick(event) { return _isButton(event, 1) } - - function isRightClick(event) { return _isButton(event, 2) } - - function element(event) { - event = Event.extend(event); - - var node = event.target, type = event.type, - currentTarget = event.currentTarget; - - if (currentTarget && currentTarget.tagName) { - if (type === 'load' || type === 'error' || - (type === 'click' && currentTarget.tagName.toLowerCase() === 'input' - && currentTarget.type === 'radio')) - node = currentTarget; - } - - if (node.nodeType == Node.TEXT_NODE) - node = node.parentNode; - - return Element.extend(node); - } - - function findElement(event, expression) { - var element = Event.element(event); - if (!expression) return element; - while (element) { - if (Object.isElement(element) && Prototype.Selector.match(element, expression)) { - return Element.extend(element); - } - element = element.parentNode; - } - } - - function pointer(event) { - return { x: pointerX(event), y: pointerY(event) }; - } - - function pointerX(event) { - var docElement = document.documentElement, - body = document.body || { scrollLeft: 0 }; - - return event.pageX || (event.clientX + - (docElement.scrollLeft || body.scrollLeft) - - (docElement.clientLeft || 0)); - } - - function pointerY(event) { - var docElement = document.documentElement, - body = document.body || { scrollTop: 0 }; - - return event.pageY || (event.clientY + - (docElement.scrollTop || body.scrollTop) - - (docElement.clientTop || 0)); - } - - - function stop(event) { - Event.extend(event); - event.preventDefault(); - event.stopPropagation(); - - event.stopped = true; - } - - Event.Methods = { - isLeftClick: isLeftClick, - isMiddleClick: isMiddleClick, - isRightClick: isRightClick, - - element: element, - findElement: findElement, - - pointer: pointer, - pointerX: pointerX, - pointerY: pointerY, - - stop: stop - }; - - - var methods = Object.keys(Event.Methods).inject({ }, function(m, name) { - m[name] = Event.Methods[name].methodize(); - return m; - }); - - if (Prototype.Browser.IE) { - function _relatedTarget(event) { - var element; - switch (event.type) { - case 'mouseover': element = event.fromElement; break; - case 'mouseout': element = event.toElement; break; - default: return null; - } - return Element.extend(element); - } - - Object.extend(methods, { - stopPropagation: function() { this.cancelBubble = true }, - preventDefault: function() { this.returnValue = false }, - inspect: function() { return '[object Event]' } - }); - - Event.extend = function(event, element) { - if (!event) return false; - if (event._extendedByPrototype) return event; - - event._extendedByPrototype = Prototype.emptyFunction; - var pointer = Event.pointer(event); - - Object.extend(event, { - target: event.srcElement || element, - relatedTarget: _relatedTarget(event), - pageX: pointer.x, - pageY: pointer.y - }); - - return Object.extend(event, methods); - }; - } else { - Event.prototype = window.Event.prototype || document.createEvent('HTMLEvents').__proto__; - Object.extend(Event.prototype, methods); - Event.extend = Prototype.K; - } - - function _createResponder(element, eventName, handler) { - var registry = Element.retrieve(element, 'prototype_event_registry'); - - if (Object.isUndefined(registry)) { - CACHE.push(element); - registry = Element.retrieve(element, 'prototype_event_registry', $H()); - } - - var respondersForEvent = registry.get(eventName); - if (Object.isUndefined(respondersForEvent)) { - respondersForEvent = []; - registry.set(eventName, respondersForEvent); - } - - if (respondersForEvent.pluck('handler').include(handler)) return false; - - var responder; - if (eventName.include(":")) { - responder = function(event) { - if (Object.isUndefined(event.eventName)) - return false; - - if (event.eventName !== eventName) - return false; - - Event.extend(event, element); - handler.call(element, event); - }; - } else { - if (!MOUSEENTER_MOUSELEAVE_EVENTS_SUPPORTED && - (eventName === "mouseenter" || eventName === "mouseleave")) { - if (eventName === "mouseenter" || eventName === "mouseleave") { - responder = function(event) { - Event.extend(event, element); - - var parent = event.relatedTarget; - while (parent && parent !== element) { - try { parent = parent.parentNode; } - catch(e) { parent = element; } - } - - if (parent === element) return; - - handler.call(element, event); - }; - } - } else { - responder = function(event) { - Event.extend(event, element); - handler.call(element, event); - }; - } - } - - responder.handler = handler; - respondersForEvent.push(responder); - return responder; - } - - function _destroyCache() { - for (var i = 0, length = CACHE.length; i < length; i++) { - Event.stopObserving(CACHE[i]); - CACHE[i] = null; - } - } - - var CACHE = []; - - if (Prototype.Browser.IE) - window.attachEvent('onunload', _destroyCache); - - if (Prototype.Browser.WebKit) - window.addEventListener('unload', Prototype.emptyFunction, false); - - - var _getDOMEventName = Prototype.K, - translations = { mouseenter: "mouseover", mouseleave: "mouseout" }; - - if (!MOUSEENTER_MOUSELEAVE_EVENTS_SUPPORTED) { - _getDOMEventName = function(eventName) { - return (translations[eventName] || eventName); - }; - } - - function observe(element, eventName, handler) { - element = $(element); - - var responder = _createResponder(element, eventName, handler); - - if (!responder) return element; - - if (eventName.include(':')) { - if (element.addEventListener) - element.addEventListener("dataavailable", responder, false); - else { - element.attachEvent("ondataavailable", responder); - element.attachEvent("onfilterchange", responder); - } - } else { - var actualEventName = _getDOMEventName(eventName); - - if (element.addEventListener) - element.addEventListener(actualEventName, responder, false); - else - element.attachEvent("on" + actualEventName, responder); - } - - return element; - } - - function stopObserving(element, eventName, handler) { - element = $(element); - - var registry = Element.retrieve(element, 'prototype_event_registry'); - if (!registry) return element; - - if (!eventName) { - registry.each( function(pair) { - var eventName = pair.key; - stopObserving(element, eventName); - }); - return element; - } - - var responders = registry.get(eventName); - if (!responders) return element; - - if (!handler) { - responders.each(function(r) { - stopObserving(element, eventName, r.handler); - }); - return element; - } - - var responder = responders.find( function(r) { return r.handler === handler; }); - if (!responder) return element; - - if (eventName.include(':')) { - if (element.removeEventListener) - element.removeEventListener("dataavailable", responder, false); - else { - element.detachEvent("ondataavailable", responder); - element.detachEvent("onfilterchange", responder); - } - } else { - var actualEventName = _getDOMEventName(eventName); - if (element.removeEventListener) - element.removeEventListener(actualEventName, responder, false); - else - element.detachEvent('on' + actualEventName, responder); - } - - registry.set(eventName, responders.without(responder)); - - return element; - } - - function fire(element, eventName, memo, bubble) { - element = $(element); - - if (Object.isUndefined(bubble)) - bubble = true; - - if (element == document && document.createEvent && !element.dispatchEvent) - element = document.documentElement; - - var event; - if (document.createEvent) { - event = document.createEvent('HTMLEvents'); - event.initEvent('dataavailable', true, true); - } else { - event = document.createEventObject(); - event.eventType = bubble ? 'ondataavailable' : 'onfilterchange'; - } - - event.eventName = eventName; - event.memo = memo || { }; - - if (document.createEvent) - element.dispatchEvent(event); - else - element.fireEvent(event.eventType, event); - - return Event.extend(event); - } - - Event.Handler = Class.create({ - initialize: function(element, eventName, selector, callback) { - this.element = $(element); - this.eventName = eventName; - this.selector = selector; - this.callback = callback; - this.handler = this.handleEvent.bind(this); - }, - - start: function() { - Event.observe(this.element, this.eventName, this.handler); - return this; - }, - - stop: function() { - Event.stopObserving(this.element, this.eventName, this.handler); - return this; - }, - - handleEvent: function(event) { - var element = event.findElement(this.selector); - if (element) this.callback.call(this.element, event, element); - } - }); - - function on(element, eventName, selector, callback) { - element = $(element); - if (Object.isFunction(selector) && Object.isUndefined(callback)) { - callback = selector, selector = null; - } - - return new Event.Handler(element, eventName, selector, callback).start(); - } - - Object.extend(Event, Event.Methods); - - Object.extend(Event, { - fire: fire, - observe: observe, - stopObserving: stopObserving, - on: on - }); - - Element.addMethods({ - fire: fire, - - observe: observe, - - stopObserving: stopObserving, - - on: on - }); - - Object.extend(document, { - fire: fire.methodize(), - - observe: observe.methodize(), - - stopObserving: stopObserving.methodize(), - - on: on.methodize(), - - loaded: false - }); - - if (window.Event) Object.extend(window.Event, Event); - else window.Event = Event; -})(); - -(function() { - /* Support for the DOMContentLoaded event is based on work by Dan Webb, - Matthias Miller, Dean Edwards, John Resig, and Diego Perini. */ - - var timer; - - function fireContentLoadedEvent() { - if (document.loaded) return; - if (timer) window.clearTimeout(timer); - document.loaded = true; - document.fire('dom:loaded'); - } - - function checkReadyState() { - if (document.readyState === 'complete') { - document.stopObserving('readystatechange', checkReadyState); - fireContentLoadedEvent(); - } - } - - function pollDoScroll() { - try { document.documentElement.doScroll('left'); } - catch(e) { - timer = pollDoScroll.defer(); - return; - } - fireContentLoadedEvent(); - } - - if (document.addEventListener) { - document.addEventListener('DOMContentLoaded', fireContentLoadedEvent, false); - } else { - document.observe('readystatechange', checkReadyState); - if (window == top) - timer = pollDoScroll.defer(); - } - - Event.observe(window, 'load', fireContentLoadedEvent); -})(); - -Element.addMethods(); - -/*------------------------------- DEPRECATED -------------------------------*/ - -Hash.toQueryString = Object.toQueryString; - -var Toggle = { display: Element.toggle }; - -Element.Methods.childOf = Element.Methods.descendantOf; - -var Insertion = { - Before: function(element, content) { - return Element.insert(element, {before:content}); - }, - - Top: function(element, content) { - return Element.insert(element, {top:content}); - }, - - Bottom: function(element, content) { - return Element.insert(element, {bottom:content}); - }, - - After: function(element, content) { - return Element.insert(element, {after:content}); - } -}; - -var $continue = new Error('"throw $continue" is deprecated, use "return" instead'); - -var Position = { - includeScrollOffsets: false, - - prepare: function() { - this.deltaX = window.pageXOffset - || document.documentElement.scrollLeft - || document.body.scrollLeft - || 0; - this.deltaY = window.pageYOffset - || document.documentElement.scrollTop - || document.body.scrollTop - || 0; - }, - - within: function(element, x, y) { - if (this.includeScrollOffsets) - return this.withinIncludingScrolloffsets(element, x, y); - this.xcomp = x; - this.ycomp = y; - this.offset = Element.cumulativeOffset(element); - - return (y >= this.offset[1] && - y < this.offset[1] + element.offsetHeight && - x >= this.offset[0] && - x < this.offset[0] + element.offsetWidth); - }, - - withinIncludingScrolloffsets: function(element, x, y) { - var offsetcache = Element.cumulativeScrollOffset(element); - - this.xcomp = x + offsetcache[0] - this.deltaX; - this.ycomp = y + offsetcache[1] - this.deltaY; - this.offset = Element.cumulativeOffset(element); - - return (this.ycomp >= this.offset[1] && - this.ycomp < this.offset[1] + element.offsetHeight && - this.xcomp >= this.offset[0] && - this.xcomp < this.offset[0] + element.offsetWidth); - }, - - overlap: function(mode, element) { - if (!mode) return 0; - if (mode == 'vertical') - return ((this.offset[1] + element.offsetHeight) - this.ycomp) / - element.offsetHeight; - if (mode == 'horizontal') - return ((this.offset[0] + element.offsetWidth) - this.xcomp) / - element.offsetWidth; - }, - - - cumulativeOffset: Element.Methods.cumulativeOffset, - - positionedOffset: Element.Methods.positionedOffset, - - absolutize: function(element) { - Position.prepare(); - return Element.absolutize(element); - }, - - relativize: function(element) { - Position.prepare(); - return Element.relativize(element); - }, - - realOffset: Element.Methods.cumulativeScrollOffset, - - offsetParent: Element.Methods.getOffsetParent, - - page: Element.Methods.viewportOffset, - - clone: function(source, target, options) { - options = options || { }; - return Element.clonePosition(target, source, options); - } -}; - -/*--------------------------------------------------------------------------*/ - -if (!document.getElementsByClassName) document.getElementsByClassName = function(instanceMethods){ - function iter(name) { - return name.blank() ? null : "[contains(concat(' ', @class, ' '), ' " + name + " ')]"; - } - - instanceMethods.getElementsByClassName = Prototype.BrowserFeatures.XPath ? - function(element, className) { - className = className.toString().strip(); - var cond = /\s/.test(className) ? $w(className).map(iter).join('') : iter(className); - return cond ? document._getElementsByXPath('.//*' + cond, element) : []; - } : function(element, className) { - className = className.toString().strip(); - var elements = [], classNames = (/\s/.test(className) ? $w(className) : null); - if (!classNames && !className) return elements; - - var nodes = $(element).getElementsByTagName('*'); - className = ' ' + className + ' '; - - for (var i = 0, child, cn; child = nodes[i]; i++) { - if (child.className && (cn = ' ' + child.className + ' ') && (cn.include(className) || - (classNames && classNames.all(function(name) { - return !name.toString().blank() && cn.include(' ' + name + ' '); - })))) - elements.push(Element.extend(child)); - } - return elements; - }; - - return function(className, parentElement) { - return $(parentElement || document.body).getElementsByClassName(className); - }; -}(Element.Methods); - -/*--------------------------------------------------------------------------*/ - -Element.ClassNames = Class.create(); -Element.ClassNames.prototype = { - initialize: function(element) { - this.element = $(element); - }, - - _each: function(iterator) { - this.element.className.split(/\s+/).select(function(name) { - return name.length > 0; - })._each(iterator); - }, - - set: function(className) { - this.element.className = className; - }, - - add: function(classNameToAdd) { - if (this.include(classNameToAdd)) return; - this.set($A(this).concat(classNameToAdd).join(' ')); - }, - - remove: function(classNameToRemove) { - if (!this.include(classNameToRemove)) return; - this.set($A(this).without(classNameToRemove).join(' ')); - }, - - toString: function() { - return $A(this).join(' '); - } -}; - -Object.extend(Element.ClassNames.prototype, Enumerable); - -/*--------------------------------------------------------------------------*/ - -(function() { - window.Selector = Class.create({ - initialize: function(expression) { - this.expression = expression.strip(); - }, - - findElements: function(rootElement) { - return Prototype.Selector.select(this.expression, rootElement); - }, - - match: function(element) { - return Prototype.Selector.match(element, this.expression); - }, - - toString: function() { - return this.expression; - }, - - inspect: function() { - return "#"; - } - }); - - Object.extend(Selector, { - matchElements: function(elements, expression) { - var match = Prototype.Selector.match, - results = []; - - for (var i = 0, length = elements.length; i < length; i++) { - var element = elements[i]; - if (match(element, expression)) { - results.push(Element.extend(element)); - } - } - return results; - }, - - findElement: function(elements, expression, index) { - index = index || 0; - var matchIndex = 0, element; - for (var i = 0, length = elements.length; i < length; i++) { - element = elements[i]; - if (Prototype.Selector.match(element, expression) && index === matchIndex++) { - return Element.extend(element); - } - } - }, - - findChildElements: function(element, expressions) { - var selector = expressions.toArray().join(', '); - return Prototype.Selector.select(selector, element || document); - } - }); -})(); diff --git a/test/dummy/public/javascripts/rails.js b/test/dummy/public/javascripts/rails.js deleted file mode 100644 index aed6aed3..00000000 --- a/test/dummy/public/javascripts/rails.js +++ /dev/null @@ -1,191 +0,0 @@ -(function() { - // Technique from Juriy Zaytsev - // http://thinkweb2.com/projects/prototype/detecting-event-support-without-browser-sniffing/ - function isEventSupported(eventName) { - var el = document.createElement('div'); - eventName = 'on' + eventName; - var isSupported = (eventName in el); - if (!isSupported) { - el.setAttribute(eventName, 'return;'); - isSupported = typeof el[eventName] == 'function'; - } - el = null; - return isSupported; - } - - function isForm(element) { - return Object.isElement(element) && element.nodeName.toUpperCase() == 'FORM' - } - - function isInput(element) { - if (Object.isElement(element)) { - var name = element.nodeName.toUpperCase() - return name == 'INPUT' || name == 'SELECT' || name == 'TEXTAREA' - } - else return false - } - - var submitBubbles = isEventSupported('submit'), - changeBubbles = isEventSupported('change') - - if (!submitBubbles || !changeBubbles) { - // augment the Event.Handler class to observe custom events when needed - Event.Handler.prototype.initialize = Event.Handler.prototype.initialize.wrap( - function(init, element, eventName, selector, callback) { - init(element, eventName, selector, callback) - // is the handler being attached to an element that doesn't support this event? - if ( (!submitBubbles && this.eventName == 'submit' && !isForm(this.element)) || - (!changeBubbles && this.eventName == 'change' && !isInput(this.element)) ) { - // "submit" => "emulated:submit" - this.eventName = 'emulated:' + this.eventName - } - } - ) - } - - if (!submitBubbles) { - // discover forms on the page by observing focus events which always bubble - document.on('focusin', 'form', function(focusEvent, form) { - // special handler for the real "submit" event (one-time operation) - if (!form.retrieve('emulated:submit')) { - form.on('submit', function(submitEvent) { - var emulated = form.fire('emulated:submit', submitEvent, true) - // if custom event received preventDefault, cancel the real one too - if (emulated.returnValue === false) submitEvent.preventDefault() - }) - form.store('emulated:submit', true) - } - }) - } - - if (!changeBubbles) { - // discover form inputs on the page - document.on('focusin', 'input, select, texarea', function(focusEvent, input) { - // special handler for real "change" events - if (!input.retrieve('emulated:change')) { - input.on('change', function(changeEvent) { - input.fire('emulated:change', changeEvent, true) - }) - input.store('emulated:change', true) - } - }) - } - - function handleRemote(element) { - var method, url, params; - - var event = element.fire("ajax:before"); - if (event.stopped) return false; - - if (element.tagName.toLowerCase() === 'form') { - method = element.readAttribute('method') || 'post'; - url = element.readAttribute('action'); - params = element.serialize(); - } else { - method = element.readAttribute('data-method') || 'get'; - url = element.readAttribute('href'); - params = {}; - } - - new Ajax.Request(url, { - method: method, - parameters: params, - evalScripts: true, - - onComplete: function(request) { element.fire("ajax:complete", request); }, - onSuccess: function(request) { element.fire("ajax:success", request); }, - onFailure: function(request) { element.fire("ajax:failure", request); } - }); - - element.fire("ajax:after"); - } - - function handleMethod(element) { - var method = element.readAttribute('data-method'), - url = element.readAttribute('href'), - csrf_param = $$('meta[name=csrf-param]')[0], - csrf_token = $$('meta[name=csrf-token]')[0]; - - var form = new Element('form', { method: "POST", action: url, style: "display: none;" }); - element.parentNode.insert(form); - - if (method !== 'post') { - var field = new Element('input', { type: 'hidden', name: '_method', value: method }); - form.insert(field); - } - - if (csrf_param) { - var param = csrf_param.readAttribute('content'), - token = csrf_token.readAttribute('content'), - field = new Element('input', { type: 'hidden', name: param, value: token }); - form.insert(field); - } - - form.submit(); - } - - - document.on("click", "*[data-confirm]", function(event, element) { - var message = element.readAttribute('data-confirm'); - if (!confirm(message)) event.stop(); - }); - - document.on("click", "a[data-remote]", function(event, element) { - if (event.stopped) return; - handleRemote(element); - event.stop(); - }); - - document.on("click", "a[data-method]", function(event, element) { - if (event.stopped) return; - handleMethod(element); - event.stop(); - }); - - document.on("submit", function(event) { - var element = event.findElement(), - message = element.readAttribute('data-confirm'); - if (message && !confirm(message)) { - event.stop(); - return false; - } - - var inputs = element.select("input[type=submit][data-disable-with]"); - inputs.each(function(input) { - input.disabled = true; - input.writeAttribute('data-original-value', input.value); - input.value = input.readAttribute('data-disable-with'); - }); - - var element = event.findElement("form[data-remote]"); - if (element) { - handleRemote(element); - event.stop(); - } - }); - - document.on("ajax:after", "form", function(event, element) { - var inputs = element.select("input[type=submit][disabled=true][data-disable-with]"); - inputs.each(function(input) { - input.value = input.readAttribute('data-original-value'); - input.removeAttribute('data-original-value'); - input.disabled = false; - }); - }); - - Ajax.Responders.register({ - onCreate: function(request) { - var csrf_meta_tag = $$('meta[name=csrf-token]')[0]; - - if (csrf_meta_tag) { - var header = 'X-CSRF-Token', - token = csrf_meta_tag.readAttribute('content'); - - if (!request.options.requestHeaders) { - request.options.requestHeaders = {}; - } - request.options.requestHeaders[header] = token; - } - } - }); -})(); diff --git a/test/dummy/public/robots.txt b/test/dummy/public/robots.txt deleted file mode 100644 index 085187fa..00000000 --- a/test/dummy/public/robots.txt +++ /dev/null @@ -1,5 +0,0 @@ -# See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file -# -# To ban all spiders from the entire site uncomment the next two lines: -# User-Agent: * -# Disallow: / diff --git a/test/dummy/public/stylesheets/.gitkeep b/test/dummy/public/stylesheets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/dummy/public/stylesheets/scaffold.css b/test/dummy/public/stylesheets/scaffold.css deleted file mode 100644 index 1ae70002..00000000 --- a/test/dummy/public/stylesheets/scaffold.css +++ /dev/null @@ -1,56 +0,0 @@ -body { background-color: #fff; color: #333; } - -body, p, ol, ul, td { - font-family: verdana, arial, helvetica, sans-serif; - font-size: 13px; - line-height: 18px; -} - -pre { - background-color: #eee; - padding: 10px; - font-size: 11px; -} - -a { color: #000; } -a:visited { color: #666; } -a:hover { color: #fff; background-color:#000; } - -div.field, div.actions { - margin-bottom: 10px; -} - -#notice { - color: green; -} - -.field_with_errors { - padding: 2px; - background-color: red; - display: table; -} - -#error_explanation { - width: 450px; - border: 2px solid red; - padding: 7px; - padding-bottom: 0; - margin-bottom: 20px; - background-color: #f0f0f0; -} - -#error_explanation h2 { - text-align: left; - font-weight: bold; - padding: 5px 5px 5px 15px; - font-size: 12px; - margin: -7px; - margin-bottom: 0px; - background-color: #c00; - color: #fff; -} - -#error_explanation ul li { - font-size: 12px; - list-style: square; -} diff --git a/test/dummy/script/rails b/test/dummy/script/rails deleted file mode 100755 index f8da2cff..00000000 --- a/test/dummy/script/rails +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env ruby -# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. - -APP_PATH = File.expand_path('../../config/application', __FILE__) -require File.expand_path('../../config/boot', __FILE__) -require 'rails/commands' diff --git a/test/dummy/test/functional/posts_controller_test.rb b/test/dummy/test/functional/posts_controller_test.rb deleted file mode 100644 index 68cf967e..00000000 --- a/test/dummy/test/functional/posts_controller_test.rb +++ /dev/null @@ -1,237 +0,0 @@ -require 'test_helper' - -class PostsControllerTest < ActionController::TestCase - setup do - Time.stubs(:current).returns('Sat, 20 Apr 2013 20:58:55 UTC +00:00') - @email_notifier = ExceptionNotifier.registered_exception_notifier(:email) - begin - post :create, method: :post, params: { secret: "secret" } - rescue => e - @exception = e - @mail = @email_notifier.create_email(@exception, {:env => request.env, :data => {:message => 'My Custom Message'}}) - end - end - - test "should have raised an exception" do - refute_nil @exception - end - - test "should have generated a notification email" do - refute_nil @mail - end - - test "mail should be plain text and UTF-8 enconded by default" do - assert_equal @mail.content_type, "text/plain; charset=UTF-8" - end - - test "mail should have a from address set" do - assert_equal @mail.from, ["dummynotifier@example.com"] - end - - test "mail should have a to address set" do - assert_equal @mail.to, ["dummyexceptions@example.com"] - end - - test "mail subject should have the proper prefix" do - assert_includes @mail.subject, "[Dummy ERROR]" - end - - test "mail subject should include descriptive error message" do - assert_includes @mail.subject, "(NoMethodError) \"undefined method `nw'" - end - - test "mail should contain backtrace in body" do - assert_includes @mail.encoded, "`method_missing'\r\n app/controllers/posts_controller.rb:18:in `create'\r\n" - end - - test "mail should contain timestamp of exception in body" do - assert_includes @mail.encoded, "Timestamp : #{Time.current}" - end - - test "mail should contain the newly defined section" do - assert_includes @mail.encoded, "* New text section for testing" - end - - test "mail should contain the custom message" do - assert_includes @mail.encoded, "My Custom Message" - end - - test "should filter sensible data" do - assert_includes @mail.encoded, "secret\"=>\"[FILTERED]" - end - - test "mail should contain the custom header" do - assert_includes @mail.encoded, 'X-Custom-Header: foobar' - end - - test "mail should not contain any attachments" do - assert_equal @mail.attachments, [] - end - - test "should not send notification if one of ignored exceptions" do - begin - get :invalid - rescue => e - @ignored_exception = e - unless ExceptionNotifier.ignored_exceptions.include?(@ignored_exception.class.name) - ignored_mail = @email_notifier.create_email(@ignored_exception, {:env => request.env}) - end - end - - assert_equal @ignored_exception.class.inspect, "ActionController::UrlGenerationError" - assert_nil ignored_mail - end - - test "should filter session_id on secure requests" do - request.env['HTTPS'] = 'on' - begin - post :create, method: :post - rescue => e - @secured_mail = @email_notifier.create_email(e, {:env => request.env}) - end - - assert request.ssl? - assert_includes @secured_mail.encoded, "* session id: [FILTERED]\r\n *" - end - - test "should ignore exception if from unwanted crawler" do - request.env['HTTP_USER_AGENT'] = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" - begin - post :create, method: :post - rescue => e - @exception = e - custom_env = request.env - custom_env['exception_notifier.options'] ||= {} - custom_env['exception_notifier.options'].merge!(:ignore_crawlers => %w(Googlebot)) - ignore_array = custom_env['exception_notifier.options'][:ignore_crawlers] - unless ExceptionNotification::Rack.new(Dummy::Application, custom_env['exception_notifier.options']).send(:from_crawler, custom_env, ignore_array) - ignored_mail = @email_notifier.create_email(@exception, {:env => custom_env}) - end - end - - assert_nil ignored_mail - end - - test "should send html email when selected html format" do - begin - post :create, method: :post - rescue => e - @exception = e - custom_env = request.env - custom_env['exception_notifier.options'] ||= {} - custom_env['exception_notifier.options'].merge!({:email_format => :html}) - @mail = @email_notifier.create_email(@exception, {:env => custom_env}) - end - - assert_includes @mail.content_type, "multipart/alternative" - end -end - -class PostsControllerTestWithoutVerboseSubject < ActionController::TestCase - tests PostsController - setup do - @email_notifier = ExceptionNotifier::EmailNotifier.new(:verbose_subject => false) - begin - post :create, method: :post - rescue => e - @exception = e - @mail = @email_notifier.create_email(@exception, {:env => request.env}) - end - end - - test "should not include exception message in subject" do - assert_includes @mail.subject, '[ERROR]' - assert_includes @mail.subject, '(NoMethodError)' - refute_includes @mail.subject, 'undefined method' - end -end - -class PostsControllerTestWithoutControllerAndActionNames < ActionController::TestCase - tests PostsController - setup do - @email_notifier = ExceptionNotifier::EmailNotifier.new(:include_controller_and_action_names_in_subject => false) - begin - post :create, method: :post - rescue => e - @exception = e - @mail = @email_notifier.create_email(@exception, {:env => request.env}) - end - end - - test "should include controller and action names in subject" do - assert_includes @mail.subject, '[ERROR]' - assert_includes @mail.subject, '(NoMethodError)' - refute_includes @mail.subject, 'posts#create' - end -end - -class PostsControllerTestWithSmtpSettings < ActionController::TestCase - tests PostsController - setup do - @email_notifier = ExceptionNotifier::EmailNotifier.new(:smtp_settings => { - :user_name => "Dummy user_name", - :password => "Dummy password" - }) - - begin - post :create, method: :post - rescue => e - @exception = e - @mail = @email_notifier.create_email(@exception, {:env => request.env}) - end - end - - test "should have overridden smtp settings" do - assert_equal "Dummy user_name", @mail.delivery_method.settings[:user_name] - assert_equal "Dummy password", @mail.delivery_method.settings[:password] - end - - test "should have overridden smtp settings with background notification" do - @mail = @email_notifier.create_email(@exception) - assert_equal "Dummy user_name", @mail.delivery_method.settings[:user_name] - assert_equal "Dummy password", @mail.delivery_method.settings[:password] - end -end - -class PostsControllerTestBackgroundNotification < ActionController::TestCase - tests PostsController - setup do - @email_notifier = ExceptionNotifier.registered_exception_notifier(:email) - begin - post :create, method: :post - rescue => exception - @mail = @email_notifier.create_email(exception) - end - end - - test "mail should contain the specified section" do - assert_includes @mail.encoded, "* New background section for testing" - end -end - -class PostsControllerTestWithExceptionRecipientsAsProc < ActionController::TestCase - tests PostsController - setup do - exception_recipients = %w{first@example.com second@example.com} - - @email_notifier = ExceptionNotifier::EmailNotifier.new( - exception_recipients: -> { [ exception_recipients.shift ] } - ) - - @action = proc do - begin - post :create, method: :post - rescue => e - @exception = e - @mail = @email_notifier.create_email(@exception, {:env => request.env}) - end - end - end - - test "should lazily evaluate exception_recipients" do - @action.call - assert_equal [ "first@example.com" ], @mail.to - @action.call - assert_equal [ "second@example.com" ], @mail.to - end -end diff --git a/test/dummy/test/test_helper.rb b/test/dummy/test/test_helper.rb deleted file mode 100644 index 82985177..00000000 --- a/test/dummy/test/test_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -ENV["RAILS_ENV"] = "test" -require File.expand_path('../../config/environment', __FILE__) -require 'rails/test_help' - -class ActiveSupport::TestCase - # Add more helper methods to be used by all tests here... -end diff --git a/test/exception_notification/rack_test.rb b/test/exception_notification/rack_test.rb index c09bb8fa..3a107b61 100644 --- a/test/exception_notification/rack_test.rb +++ b/test/exception_notification/rack_test.rb @@ -1,13 +1,12 @@ require 'test_helper' class RackTest < ActiveSupport::TestCase - setup do @pass_app = Object.new @pass_app.stubs(:call).returns([nil, { 'X-Cascade' => 'pass' }, nil]) @normal_app = Object.new - @normal_app.stubs(:call).returns([nil, { }, nil]) + @normal_app.stubs(:call).returns([nil, {}, nil]) end teardown do @@ -15,30 +14,47 @@ class RackTest < ActiveSupport::TestCase ExceptionNotifier.notification_trigger = nil end - test "should ignore \"X-Cascade\" header by default" do + test 'should ignore "X-Cascade" header by default' do ExceptionNotifier.expects(:notify_exception).never ExceptionNotification::Rack.new(@pass_app).call({}) end - test "should notify on \"X-Cascade\" = \"pass\" if ignore_cascade_pass option is false" do + test 'should notify on "X-Cascade" = "pass" if ignore_cascade_pass option is false' do ExceptionNotifier.expects(:notify_exception).once - ExceptionNotification::Rack.new(@pass_app, :ignore_cascade_pass => false).call({}) + ExceptionNotification::Rack.new(@pass_app, ignore_cascade_pass: false).call({}) end - test "should assign error_grouping if error_grouping is specified" do + test 'should assign error_grouping if error_grouping is specified' do refute ExceptionNotifier.error_grouping ExceptionNotification::Rack.new(@normal_app, error_grouping: true).call({}) assert ExceptionNotifier.error_grouping end - test "should assign notification_trigger if notification_trigger is specified" do + test 'should assign notification_trigger if notification_trigger is specified' do assert_nil ExceptionNotifier.notification_trigger - ExceptionNotification::Rack.new(@normal_app, notification_trigger: lambda {|i| true}).call({}) + ExceptionNotification::Rack.new(@normal_app, notification_trigger: ->(_i) { true }).call({}) assert_respond_to ExceptionNotifier.notification_trigger, :call end - test "should set default cache to Rails cache" do - ExceptionNotification::Rack.new(@normal_app, error_grouping: true).call({}) - assert_equal Rails.cache, ExceptionNotifier.error_grouping_cache + if defined?(Rails) && Rails.respond_to?(:cache) + test 'should set default cache to Rails cache' do + ExceptionNotification::Rack.new(@normal_app, error_grouping: true).call({}) + assert_equal Rails.cache, ExceptionNotifier.error_grouping_cache + end + end + + test 'should ignore exceptions with Usar Agent in ignore_crawlers' do + exception_app = Object.new + exception_app.stubs(:call).raises(RuntimeError) + + env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; Crawlerbot/2.1;)' } + + begin + ExceptionNotification::Rack.new(exception_app, ignore_crawlers: %w[Crawlerbot]).call(env) + + flunk + rescue StandardError + refute env['exception_notifier.delivered'] + end end end diff --git a/test/exception_notification/resque_test.rb b/test/exception_notification/resque_test.rb new file mode 100644 index 00000000..4d146d4a --- /dev/null +++ b/test/exception_notification/resque_test.rb @@ -0,0 +1,52 @@ +require 'test_helper' + +require 'exception_notification/resque' +require 'resque' +require 'mock_redis' +require 'resque/failure/multiple' +require 'resque/failure/redis' + +class ResqueTest < ActiveSupport::TestCase + setup do + # Resque.redis=() only supports a String or Redis instance in Resque 1.8 + Resque.instance_variable_set(:@redis, MockRedis.new) + + Resque::Failure::Multiple.classes = [Resque::Failure::Redis, ExceptionNotification::Resque] + Resque::Failure.backend = Resque::Failure::Multiple + + @worker = Resque::Worker.new(:jobs) + # Forking causes issues with Mocha's `.expects` + @worker.cant_fork = true + end + + test 'count returns the number of failures' do + Resque::Job.create(:jobs, BadJob) + @worker.work(0) + assert_equal 1, ExceptionNotification::Resque.count + end + + test 'notifies exception when job fails' do + ExceptionNotifier.expects(:notify_exception).with do |ex, opts| + ex.is_a?(RuntimeError) && + ex.message == 'Bad job!' && + opts[:data][:resque][:error_class] == 'RuntimeError' && + opts[:data][:resque][:error_message] == 'Bad job!' && + opts[:data][:resque][:failed_at].present? && + opts[:data][:resque][:payload] == { + 'class' => 'ResqueTest::BadJob', + 'args' => [] + } && + opts[:data][:resque][:queue] == :jobs && + opts[:data][:resque][:worker].present? + end + + Resque::Job.create(:jobs, BadJob) + @worker.work(0) + end + + class BadJob + def self.perform + raise 'Bad job!' + end + end +end diff --git a/test/exception_notifier/campfire_notifier_test.rb b/test/exception_notifier/campfire_notifier_test.rb index 86cf7bc2..e3065a97 100644 --- a/test/exception_notifier/campfire_notifier_test.rb +++ b/test/exception_notifier/campfire_notifier_test.rb @@ -8,43 +8,42 @@ end class CampfireNotifierTest < ActiveSupport::TestCase - - test "should send campfire notification if properly configured" do + test 'should send campfire notification if properly configured' do ExceptionNotifier::CampfireNotifier.stubs(:new).returns(Object.new) - campfire = ExceptionNotifier::CampfireNotifier.new({:subdomain => 'test', :token => 'test_token', :room_name => 'test_room'}) + campfire = ExceptionNotifier::CampfireNotifier.new(subdomain: 'test', token: 'test_token', room_name: 'test_room') campfire.stubs(:call).returns(fake_notification) notif = campfire.call(fake_exception) assert !notif[:message].empty? assert_equal notif[:message][:type], 'PasteMessage' - assert_includes notif[:message][:body], "A new exception occurred:" - assert_includes notif[:message][:body], "divided by 0" - assert_includes notif[:message][:body], "/exception_notification/test/campfire_test.rb:45" + assert_includes notif[:message][:body], 'A new exception occurred:' + assert_includes notif[:message][:body], 'divided by 0' + assert_includes notif[:message][:body], '/exception_notification/test/campfire_test.rb:45' end - test "should send campfire notification without backtrace info if properly configured" do + test 'should send campfire notification without backtrace info if properly configured' do ExceptionNotifier::CampfireNotifier.stubs(:new).returns(Object.new) - campfire = ExceptionNotifier::CampfireNotifier.new({:subdomain => 'test', :token => 'test_token', :room_name => 'test_room'}) + campfire = ExceptionNotifier::CampfireNotifier.new(subdomain: 'test', token: 'test_token', room_name: 'test_room') campfire.stubs(:call).returns(fake_notification_without_backtrace) notif = campfire.call(fake_exception_without_backtrace) assert !notif[:message].empty? assert_equal notif[:message][:type], 'PasteMessage' - assert_includes notif[:message][:body], "A new exception occurred:" - assert_includes notif[:message][:body], "my custom error" + assert_includes notif[:message][:body], 'A new exception occurred:' + assert_includes notif[:message][:body], 'my custom error' end - test "should not send campfire notification if badly configured" do - wrong_params = {:subdomain => 'test', :token => 'bad_token', :room_name => 'test_room'} - Tinder::Campfire.stubs(:new).with('test', {:token => 'bad_token'}).returns(nil) + test 'should not send campfire notification if badly configured' do + wrong_params = { subdomain: 'test', token: 'bad_token', room_name: 'test_room' } + Tinder::Campfire.stubs(:new).with('test', token: 'bad_token').returns(nil) campfire = ExceptionNotifier::CampfireNotifier.new(wrong_params) assert_nil campfire.room assert_nil campfire.call(fake_exception) end - test "should not send campfire notification if config attr missing" do - wrong_params = {:subdomain => 'test', :room_name => 'test_room'} + test 'should not send campfire notification if config attr missing' do + wrong_params = { subdomain: 'test', room_name: 'test_room' } Tinder::Campfire.stubs(:new).with('test', {}).returns(nil) campfire = ExceptionNotifier::CampfireNotifier.new(wrong_params) @@ -52,35 +51,34 @@ class CampfireNotifierTest < ActiveSupport::TestCase assert_nil campfire.call(fake_exception) end - test "should send the new exception message if no :accumulated_errors_count option" do + test 'should send the new exception message if no :accumulated_errors_count option' do campfire = ExceptionNotifier::CampfireNotifier.new({}) campfire.stubs(:active?).returns(true) - campfire.expects(:send_notice).with{ |_, _, message| message.start_with?("A new exception occurred") }.once + campfire.expects(:send_notice).with { |_, _, message| message.start_with?('A new exception occurred') }.once campfire.call(fake_exception) end - test "shoud send the exception message if :accumulated_errors_count option greater than 1" do + test 'shoud send the exception message if :accumulated_errors_count option greater than 1' do campfire = ExceptionNotifier::CampfireNotifier.new({}) campfire.stubs(:active?).returns(true) - campfire.expects(:send_notice).with{ |_, _, message| message.start_with?("The exception occurred 3 times:") }.once + campfire.expects(:send_notice).with { |_, _, message| message.start_with?('The exception occurred 3 times:') }.once campfire.call(fake_exception, accumulated_errors_count: 3) end - test "should call pre/post_callback if specified" do - pre_callback_called, post_callback_called = 0,0 + test 'should call pre/post_callback if specified' do + pre_callback_called = 0 + post_callback_called = 0 Tinder::Campfire.stubs(:new).returns(Object.new) campfire = ExceptionNotifier::CampfireNotifier.new( - { - :subdomain => 'test', - :token => 'test_token', - :room_name => 'test_room', - :pre_callback => proc { |opts, notifier, backtrace, message, message_opts| - pre_callback_called += 1 - }, - :post_callback => proc { |opts, notifier, backtrace, message, message_opts| - post_callback_called += 1 - } + subdomain: 'test', + token: 'test_token', + room_name: 'test_room', + pre_callback: proc { |_opts, _notifier, _backtrace, _message, _message_opts| + pre_callback_called += 1 + }, + post_callback: proc { |_opts, _notifier, _backtrace, _message, _message_opts| + post_callback_called += 1 } ) campfire.room = Object.new @@ -93,24 +91,26 @@ class CampfireNotifierTest < ActiveSupport::TestCase private def fake_notification - {:message => {:type => 'PasteMessage', - :body => "A new exception occurred: 'divided by 0' on '/Users/sebastian/exception_notification/test/campfire_test.rb:45:in `/'" - } + { + message: { + type: 'PasteMessage', + body: "A new exception occurred: 'divided by 0' on '/Users/sebastian/exception_notification/test/campfire_test.rb:45:in `/'" + } } end def fake_exception - begin - 5/0 - rescue Exception => e - e - end + 5 / 0 + rescue StandardError => e + e end def fake_notification_without_backtrace - {:message => {:type => 'PasteMessage', - :body => "A new exception occurred: 'my custom error'" - } + { + message: { + type: 'PasteMessage', + body: "A new exception occurred: 'my custom error'" + } } end diff --git a/test/exception_notifier/datadog_notifier_test.rb b/test/exception_notifier/datadog_notifier_test.rb new file mode 100644 index 00000000..90d1ffbb --- /dev/null +++ b/test/exception_notifier/datadog_notifier_test.rb @@ -0,0 +1,151 @@ +require 'test_helper' +require 'dogapi/common' +require 'dogapi/event' + +class DatadogNotifierTest < ActiveSupport::TestCase + def setup + @client = FakeDatadogClient.new + @options = { + client: @client + } + @notifier = ExceptionNotifier::DatadogNotifier.new(@options) + @exception = FakeException.new + @controller = FakeController.new + @request = FakeRequest.new + end + + test 'should send an event to datadog' do + fake_event = Dogapi::Event.any_instance + @client.expects(:emit_event).with(fake_event) + + @notifier.stubs(:datadog_event).returns(fake_event) + @notifier.call(@exception) + end + + test 'should include exception class in event title' do + event = @notifier.datadog_event(@exception) + assert_includes event.msg_title, 'FakeException' + end + + test 'should include prefix in event title and not append previous events' do + options = { + client: @client, + title_prefix: 'prefix' + } + + notifier = ExceptionNotifier::DatadogNotifier.new(options) + event = notifier.datadog_event(@exception) + assert_equal event.msg_title, 'prefix (DatadogNotifierTest::FakeException) "Fake exception message"' + + event2 = notifier.datadog_event(@exception) + assert_equal event2.msg_title, 'prefix (DatadogNotifierTest::FakeException) "Fake exception message"' + end + + test 'should include exception message in event title' do + event = @notifier.datadog_event(@exception) + assert_includes event.msg_title, 'Fake exception message' + end + + test 'should include controller info in event title if controller information is available' do + event = @notifier.datadog_event(@exception, + env: { + 'action_controller.instance' => @controller, + 'REQUEST_METHOD' => 'GET', + 'rack.input' => '' + }) + assert_includes event.msg_title, 'Fake controller' + assert_includes event.msg_title, 'Fake action' + end + + test 'should include backtrace info in event body' do + event = @notifier.datadog_event(@exception) + assert_includes event.msg_text, "backtrace line 1\nbacktrace line 2\nbacktrace line 3" + end + + test 'should include request info in event body' do + ActionDispatch::Request.stubs(:new).returns(@request) + + event = @notifier.datadog_event(@exception, + env: { + 'action_controller.instance' => @controller, + 'REQUEST_METHOD' => 'GET', + 'rack.input' => '' + }) + assert_includes event.msg_text, 'http://localhost:8080' + assert_includes event.msg_text, 'GET' + assert_includes event.msg_text, '127.0.0.1' + assert_includes event.msg_text, '{"param 1"=>"value 1", "param 2"=>"value 2"}' + end + + test 'should include tags in event' do + options = { + client: @client, + tags: %w[error production] + } + notifier = ExceptionNotifier::DatadogNotifier.new(options) + event = notifier.datadog_event(@exception) + assert_equal event.tags, %w[error production] + end + + test 'should include event title in event aggregation key' do + event = @notifier.datadog_event(@exception) + assert_equal event.aggregation_key, [event.msg_title] + end + + class FakeDatadogClient + def emit_event(event); end + end + + class FakeController + def controller_name + 'Fake controller' + end + + def action_name + 'Fake action' + end + end + + class FakeException + def backtrace + [ + 'backtrace line 1', + 'backtrace line 2', + 'backtrace line 3', + 'backtrace line 4', + 'backtrace line 5' + ] + end + + def message + 'Fake exception message' + end + end + + class FakeRequest + def url + 'http://localhost:8080' + end + + def request_method + 'GET' + end + + def remote_ip + '127.0.0.1' + end + + def filtered_parameters + { + 'param 1' => 'value 1', + 'param 2' => 'value 2' + } + end + + def session + { + 'session_id' => '1234' + } + end + end +end diff --git a/test/exception_notifier/email_notifier_test.rb b/test/exception_notifier/email_notifier_test.rb index 442f258c..2d55d956 100644 --- a/test/exception_notifier/email_notifier_test.rb +++ b/test/exception_notifier/email_notifier_test.rb @@ -1,181 +1,148 @@ require 'test_helper' require 'action_mailer' +require 'action_controller' class EmailNotifierTest < ActiveSupport::TestCase setup do Time.stubs(:current).returns('Sat, 20 Apr 2013 20:58:55 UTC +00:00') - @email_notifier = ExceptionNotifier.registered_exception_notifier(:email) - begin - 1/0 - rescue => e - @exception = e - @mail = @email_notifier.create_email(@exception, - :data => {:job => 'DivideWorkerJob', :payload => '1/0', :message => 'My Custom Message'}) - end - end - test "should call pre/post_callback if specified" do - assert_equal @email_notifier.options[:pre_callback_called], 1 - assert_equal @email_notifier.options[:post_callback_called], 1 - end + @exception = ZeroDivisionError.new('divided by 0') + @exception.set_backtrace(['test/exception_notifier/email_notifier_test.rb:20']) + + @email_notifier = ExceptionNotifier::EmailNotifier.new( + email_prefix: '[Dummy ERROR] ', + sender_address: %("Dummy Notifier" ), + exception_recipients: %w[dummyexceptions@example.com], + email_headers: { 'X-Custom-Header' => 'foobar' }, + sections: %w[new_section request session environment backtrace], + background_sections: %w[new_bkg_section backtrace data], + pre_callback: proc { |_opts, _notifier, _backtrace, _message, _message_opts| @pre_callback_called = true }, + post_callback: proc { |_opts, _notifier, _backtrace, _message, _message_opts| @post_callback_called = true }, + smtp_settings: { + user_name: 'Dummy user_name', + password: 'Dummy password' + } + ) - test "should have default sender address overridden" do - assert_equal @email_notifier.sender_address, %("Dummy Notifier" ) + @mail = @email_notifier.call( + @exception, + data: { job: 'DivideWorkerJob', payload: '1/0', message: 'My Custom Message' } + ) end - test "should have default exception recipients overridden" do - assert_equal @email_notifier.exception_recipients, %w(dummyexceptions@example.com) + test 'should call pre/post_callback if specified' do + assert @pre_callback_called + assert @post_callback_called end - test "should have default email prefix overridden" do - assert_equal @email_notifier.email_prefix, "[Dummy ERROR] " - end + test 'sends mail with correct content' do + assert_equal %("Dummy Notifier" ), @mail[:from].value + assert_equal %w[dummyexceptions@example.com], @mail.to + assert_equal '[Dummy ERROR] (ZeroDivisionError) "divided by 0"', @mail.subject + assert_equal 'foobar', @mail['X-Custom-Header'].value + assert_equal 'text/plain; charset=UTF-8', @mail.content_type + assert_equal [], @mail.attachments + assert_equal 'Dummy user_name', @mail.delivery_method.settings[:user_name] + assert_equal 'Dummy password', @mail.delivery_method.settings[:password] - test "should have default email headers overridden" do - assert_equal @email_notifier.email_headers, { "X-Custom-Header" => "foobar"} - end + body = <<-BODY.gsub(/^ /, '') + A ZeroDivisionError occurred in background at Sat, 20 Apr 2013 20:58:55 UTC +00:00 : - test "should have default sections overridden" do - for section in %w(new_section request session environment backtrace) - assert_includes @email_notifier.sections, section - end - end + divided by 0 + test/exception_notifier/email_notifier_test.rb:20 - test "should have default background sections" do - for section in %w(new_bkg_section backtrace data) - assert_includes @email_notifier.background_sections, section - end - end + ------------------------------- + New bkg section: + ------------------------------- - test "should have email format by default" do - assert_equal @email_notifier.email_format, :text - end - - test "should have verbose subject by default" do - assert @email_notifier.verbose_subject - end + * New background section for testing - test "should have normalize_subject false by default" do - refute @email_notifier.normalize_subject - end - - test "should have delivery_method nil by default" do - assert_nil @email_notifier.delivery_method - end + ------------------------------- + Backtrace: + ------------------------------- - test "should have mailer_settings nil by default" do - assert_nil @email_notifier.mailer_settings - end + test/exception_notifier/email_notifier_test.rb:20 - test "should have mailer_parent by default" do - assert_equal @email_notifier.mailer_parent, 'ActionMailer::Base' - end + ------------------------------- + Data: + ------------------------------- - test "should have template_path by default" do - assert_equal @email_notifier.template_path, 'exception_notifier' - end + * data: {:job=>"DivideWorkerJob", :payload=>"1/0", :message=>"My Custom Message"} - test "should normalize multiple digits into one N" do - assert_equal 'N foo N bar N baz N', - ExceptionNotifier::EmailNotifier.normalize_digits('1 foo 12 bar 123 baz 1234') - end - test "mail should be plain text and UTF-8 enconded by default" do - assert_equal @mail.content_type, "text/plain; charset=UTF-8" - end + BODY - test "should have raised an exception" do - refute_nil @exception + assert_equal body, @mail.decode_body end - test "should have generated a notification email" do - refute_nil @mail - end - - test "mail should have a from address set" do - assert_equal @mail.from, ["dummynotifier@example.com"] - end - - test "mail should have a to address set" do - assert_equal @mail.to, ["dummyexceptions@example.com"] - end - - test "mail should have a descriptive subject" do - assert_match(/^\[Dummy ERROR\]\s+\(ZeroDivisionError\) "divided by 0"$/, @mail.subject) - end - - test "mail should say exception was raised in background at show timestamp" do - assert_includes @mail.encoded, "A ZeroDivisionError occurred in background at #{Time.current}" + test 'should normalize multiple digits into one N' do + assert_equal 'N foo N bar N baz N', + ExceptionNotifier::EmailNotifier.normalize_digits('1 foo 12 bar 123 baz 1234') end test "mail should prefix exception class with 'an' instead of 'a' when it starts with a vowel" do begin - raise ActiveRecord::RecordNotFound - rescue => e + raise ArgumentError + rescue StandardError => e @vowel_exception = e - @vowel_mail = @email_notifier.create_email(@vowel_exception) + @vowel_mail = @email_notifier.call(@vowel_exception) end - assert_includes @vowel_mail.encoded, "An ActiveRecord::RecordNotFound occurred in background at #{Time.current}" - end - - test "mail should contain backtrace in body" do - assert @mail.encoded.include?("test/exception_notifier/email_notifier_test.rb:9"), "\n#{@mail.inspect}" - end - - test "mail should contain data in body" do - assert_includes @mail.encoded, '* data:' - assert_includes @mail.encoded, ':payload=>"1/0"' - assert_includes @mail.encoded, ':job=>"DivideWorkerJob"' - assert_includes @mail.encoded, "My Custom Message" - end - - test "mail should not contain any attachments" do - assert_equal @mail.attachments, [] + assert_includes @vowel_mail.encoded, "An ArgumentError occurred in background at #{Time.current}" end - test "should not send notification if one of ignored exceptions" do + test 'should not send notification if one of ignored exceptions' do begin - raise ActiveRecord::RecordNotFound - rescue => e + raise AbstractController::ActionNotFound + rescue StandardError => e @ignored_exception = e unless ExceptionNotifier.ignored_exceptions.include?(@ignored_exception.class.name) - ignored_mail = @email_notifier.create_email(@ignored_exception) + ignored_mail = @email_notifier.call(@ignored_exception) end end - assert_equal @ignored_exception.class.inspect, "ActiveRecord::RecordNotFound" + assert_equal @ignored_exception.class.inspect, 'AbstractController::ActionNotFound' assert_nil ignored_mail end - test "should encode environment strings" do + test 'should encode environment strings' do email_notifier = ExceptionNotifier::EmailNotifier.new( - :sender_address => "", - :exception_recipients => %w{dummyexceptions@example.com}, - :deliver_with => :deliver_now + sender_address: '', + exception_recipients: %w[dummyexceptions@example.com] ) - mail = email_notifier.create_email( + mail = email_notifier.call( @exception, - :env => { - "REQUEST_METHOD" => "GET", - "rack.input" => "", - "invalid_encoding" => "R\xC3\xA9sum\xC3\xA9".force_encoding(Encoding::ASCII), - }, - :email_format => :text + env: { + 'REQUEST_METHOD' => 'GET', + 'rack.input' => '', + 'invalid_encoding' => "R\xC3\xA9sum\xC3\xA9".force_encoding(Encoding::ASCII) + } ) assert_match(/invalid_encoding\s+: R__sum__/, mail.encoded) end - test "should send email using ActionMailer" do + test 'should send email using ActionMailer' do + ActionMailer::Base.deliveries.clear + @email_notifier.call(@exception) + assert_equal 1, ActionMailer::Base.deliveries.count + end + + test 'should be able to specify ActionMailer::MessageDelivery method' do ActionMailer::Base.deliveries.clear + deliver_with = if ActionMailer.version < Gem::Version.new('4.2') + :deliver + else + :deliver_now + end + email_notifier = ExceptionNotifier::EmailNotifier.new( - :email_prefix => '[Dummy ERROR] ', - :sender_address => %{"Dummy Notifier" }, - :exception_recipients => %w{dummyexceptions@example.com}, - :delivery_method => :test + email_prefix: '[Dummy ERROR] ', + sender_address: %("Dummy Notifier" ), + exception_recipients: %w[dummyexceptions@example.com], + deliver_with: deliver_with ) email_notifier.call(@exception) @@ -183,53 +150,202 @@ class EmailNotifierTest < ActiveSupport::TestCase assert_equal 1, ActionMailer::Base.deliveries.count end - test "should be able to specify ActionMailer::MessageDelivery method" do - ActionMailer::Base.deliveries.clear + test 'should lazily evaluate exception_recipients' do + exception_recipients = %w[first@example.com second@example.com] + email_notifier = ExceptionNotifier::EmailNotifier.new( + email_prefix: '[Dummy ERROR] ', + sender_address: %("Dummy Notifier" ), + exception_recipients: -> { [exception_recipients.shift] }, + delivery_method: :test + ) - if ActionMailer.version < Gem::Version.new("4.2") - deliver_with = :deliver - else - deliver_with = :deliver_now - end + mail = email_notifier.call(@exception) + assert_equal %w[first@example.com], mail.to + mail = email_notifier.call(@exception) + assert_equal %w[second@example.com], mail.to + end + test 'should prepend accumulated_errors_count in email subject if accumulated_errors_count larger than 1' do email_notifier = ExceptionNotifier::EmailNotifier.new( - :email_prefix => '[Dummy ERROR] ', - :sender_address => %{"Dummy Notifier" }, - :exception_recipients => %w{dummyexceptions@example.com}, - :deliver_with => deliver_with + email_prefix: '[Dummy ERROR] ', + sender_address: %("Dummy Notifier" ), + exception_recipients: %w[dummyexceptions@example.com], + delivery_method: :test ) - email_notifier.call(@exception) - - assert_equal 1, ActionMailer::Base.deliveries.count + mail = email_notifier.call(@exception, accumulated_errors_count: 3) + assert mail.subject.start_with?('[Dummy ERROR] (3 times) (ZeroDivisionError)') end - test "should lazily evaluate exception_recipients" do - exception_recipients = %w{first@example.com second@example.com} + test 'should not include exception message in subject when verbose_subject: false' do email_notifier = ExceptionNotifier::EmailNotifier.new( - :email_prefix => '[Dummy ERROR] ', - :sender_address => %{"Dummy Notifier" }, - :exception_recipients => -> { [ exception_recipients.shift ] }, - :delivery_method => :test + sender_address: %("Dummy Notifier" ), + exception_recipients: %w[dummyexceptions@example.com], + verbose_subject: false ) mail = email_notifier.call(@exception) - assert_equal %w{first@example.com}, mail.to + + assert_equal '[ERROR] (ZeroDivisionError)', mail.subject + end + + test 'should send html email when selected html format' do + email_notifier = ExceptionNotifier::EmailNotifier.new( + sender_address: %("Dummy Notifier" ), + exception_recipients: %w[dummyexceptions@example.com], + email_format: :html + ) + mail = email_notifier.call(@exception) - assert_equal %w{second@example.com}, mail.to + + assert mail.multipart? end +end - test "should prepend accumulated_errors_count in email subject if accumulated_errors_count larger than 1" do - ActionMailer::Base.deliveries.clear +class EmailNotifierWithEnvTest < ActiveSupport::TestCase + class HomeController < ActionController::Metal + def index; end + end + + setup do + Time.stubs(:current).returns('Sat, 20 Apr 2013 20:58:55 UTC +00:00') + + @exception = ZeroDivisionError.new('divided by 0') + @exception.set_backtrace(['test/exception_notifier/email_notifier_test.rb:20']) + + @email_notifier = ExceptionNotifier::EmailNotifier.new( + email_prefix: '[Dummy ERROR] ', + sender_address: %("Dummy Notifier" ), + exception_recipients: %w[dummyexceptions@example.com], + email_headers: { 'X-Custom-Header' => 'foobar' }, + sections: %w[new_section request session environment backtrace], + background_sections: %w[new_bkg_section backtrace data], + pre_callback: proc { |_opts, _notifier, _backtrace, _message, message_opts| message_opts[:pre_callback_called] = 1 }, + post_callback: proc { |_opts, _notifier, _backtrace, _message, message_opts| message_opts[:post_callback_called] = 1 } + ) + + @controller = HomeController.new + @controller.process(:index) + + @test_env = Rack::MockRequest.env_for( + '/', + 'HTTP_HOST' => 'test.address', + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_USER_AGENT' => 'Rails Testing', + 'action_dispatch.parameter_filter' => ['secret'], + 'HTTPS' => 'on', + 'action_controller.instance' => @controller, + params: { id: 'foo', secret: 'secret' } + ) + + @mail = @email_notifier.call(@exception, env: @test_env, data: { message: 'My Custom Message' }) + end + + test 'sends mail with correct content' do + assert_equal %("Dummy Notifier" ), @mail[:from].value + assert_equal %w[dummyexceptions@example.com], @mail.to + assert_equal '[Dummy ERROR] home index (ZeroDivisionError) "divided by 0"', @mail.subject + assert_equal 'foobar', @mail['X-Custom-Header'].value + assert_equal 'text/plain; charset=UTF-8', @mail.content_type + assert_equal [], @mail.attachments + + body = <<-BODY.gsub(/^ /, '') + A ZeroDivisionError occurred in home#index: + + divided by 0 + test/exception_notifier/email_notifier_test.rb:20 + + + ------------------------------- + New section: + ------------------------------- + + * New text section for testing + + ------------------------------- + Request: + ------------------------------- + + * URL : https://test.address/?id=foo&secret=secret + * HTTP Method: GET + * IP address : 127.0.0.1 + * Parameters : {\"id\"=>\"foo\", \"secret\"=>\"[FILTERED]\"} + * Timestamp : Sat, 20 Apr 2013 20:58:55 UTC +00:00 + * Server : #{Socket.gethostname} + BODY + + body << " * Rails root : #{Rails.root}\n" if defined?(Rails) && Rails.respond_to?(:root) + + body << <<-BODY.gsub(/^ /, '') + * Process: #{Process.pid} + + ------------------------------- + Session: + ------------------------------- + * session id: [FILTERED] + * data: {} + + ------------------------------- + Environment: + ------------------------------- + + * CONTENT_LENGTH : 0 + * HTTPS : on + * HTTP_HOST : test.address + * HTTP_USER_AGENT : Rails Testing + * PATH_INFO : / + * QUERY_STRING : id=foo&secret=secret + * REMOTE_ADDR : 127.0.0.1 + * REQUEST_METHOD : GET + * SCRIPT_NAME : + * SERVER_NAME : example.org + * SERVER_PORT : 80 + * action_controller.instance : #{@controller} + * action_dispatch.parameter_filter : [\"secret\"] + * action_dispatch.request.content_type : + * action_dispatch.request.parameters : {"id"=>"foo", "secret"=>"[FILTERED]"} + * action_dispatch.request.path_parameters : {} + * action_dispatch.request.query_parameters : {"id"=>"foo", "secret"=>"[FILTERED]"} + * action_dispatch.request.request_parameters: {} + * rack.errors : #{@test_env['rack.errors']} + * rack.input : #{@test_env['rack.input']} + * rack.multiprocess : true + * rack.multithread : true + * rack.request.query_hash : {"id"=>"foo", "secret"=>"[FILTERED]"} + * rack.request.query_string : id=foo&secret=secret + * rack.run_once : false + * rack.session : {} + * rack.url_scheme : http + * rack.version : #{Rack::VERSION} + + ------------------------------- + Backtrace: + ------------------------------- + + test/exception_notifier/email_notifier_test.rb:20 + + ------------------------------- + Data: + ------------------------------- + + * data: {:message=>\"My Custom Message\"} + + + BODY + + assert_equal body, @mail.decode_body + end + + test 'should not include controller and action names in subject' do email_notifier = ExceptionNotifier::EmailNotifier.new( - :email_prefix => '[Dummy ERROR] ', - :sender_address => %{"Dummy Notifier" }, - :exception_recipients => %w{dummyexceptions@example.com}, - :delivery_method => :test + sender_address: %("Dummy Notifier" ), + exception_recipients: %w[dummyexceptions@example.com], + include_controller_and_action_names_in_subject: false ) - mail = email_notifier.call(@exception, { accumulated_errors_count: 3 }) - assert mail.subject.start_with?("[Dummy ERROR] (3 times) (ZeroDivisionError)") + mail = email_notifier.call(@exception, env: @test_env) + + assert_equal '[ERROR] (ZeroDivisionError) "divided by 0"', mail.subject end end diff --git a/test/exception_notifier/google_chat_notifier_test.rb b/test/exception_notifier/google_chat_notifier_test.rb new file mode 100644 index 00000000..dd02d782 --- /dev/null +++ b/test/exception_notifier/google_chat_notifier_test.rb @@ -0,0 +1,181 @@ +require 'test_helper' +require 'rack' +require 'httparty' +require 'timecop' +require 'json' + +class GoogleChatNotifierTest < ActiveSupport::TestCase + URL = 'http://localhost:8000'.freeze + + def setup + Timecop.freeze('2018-12-09 12:07:16 UTC') + end + + def teardown + Timecop.return + end + + test 'should send notification if properly configured' do + HTTParty.expects(:post).with(URL, post_opts("#{header}\n#{body}")) + notifier.call ArgumentError.new('foo') + end + + test 'shoud use errors count if accumulated_errors_count is provided' do + text = [ + '', + "Application: *#{app_name}*", + '5 *ArgumentError* occurred.', + '', + body + ].join("\n") + + HTTParty.expects(:post).with(URL, post_opts(text)) + + notifier.call(ArgumentError.new('foo'), accumulated_errors_count: 5) + end + + test 'Message request should be formatted as hash' do + text = [ + header, + body, + '', + '*Request:*', + '```', + '* url : http://test.address/?id=foo', + '* http_method : GET', + '* ip_address : 127.0.0.1', + '* parameters : {"id"=>"foo"}', + '* timestamp : 2018-12-09 12:07:16 UTC', + '```' + ].join("\n") + + HTTParty.expects(:post).with(URL, post_opts(text)) + + notifier.call(ArgumentError.new('foo'), env: test_env) + end + + test 'backtrace with less than 3 lines should be displayed fully' do + text = [ + header, + body, + '', + '*Backtrace:*', + '```', + "* app/controllers/my_controller.rb:53:in `my_controller_params'", + "* app/controllers/my_controller.rb:34:in `update'", + '```' + ].join("\n") + + HTTParty.expects(:post).with(URL, post_opts(text)) + + exception = ArgumentError.new('foo') + exception.set_backtrace([ + "app/controllers/my_controller.rb:53:in `my_controller_params'", + "app/controllers/my_controller.rb:34:in `update'" + ]) + + notifier.call(exception) + end + + test 'backtrace with more than 3 lines should display only top 3 lines' do + text = [ + header, + body, + '', + '*Backtrace:*', + '```', + "* app/controllers/my_controller.rb:99:in `specific_function'", + "* app/controllers/my_controller.rb:70:in `specific_param'", + "* app/controllers/my_controller.rb:53:in `my_controller_params'", + '```' + ].join("\n") + + HTTParty.expects(:post).with(URL, post_opts(text)) + + exception = ArgumentError.new('foo') + exception.set_backtrace([ + "app/controllers/my_controller.rb:99:in `specific_function'", + "app/controllers/my_controller.rb:70:in `specific_param'", + "app/controllers/my_controller.rb:53:in `my_controller_params'", + "app/controllers/my_controller.rb:34:in `update'" + ]) + + notifier.call(exception) + end + + test 'Get text with backtrace and request info' do + text = [ + header, + body, + '', + '*Request:*', + '```', + '* url : http://test.address/?id=foo', + '* http_method : GET', + '* ip_address : 127.0.0.1', + '* parameters : {"id"=>"foo"}', + '* timestamp : 2018-12-09 12:07:16 UTC', + '```', + '', + '*Backtrace:*', + '```', + "* app/controllers/my_controller.rb:53:in `my_controller_params'", + "* app/controllers/my_controller.rb:34:in `update'", + '```' + ].join("\n") + + HTTParty.expects(:post).with(URL, post_opts(text)) + + exception = ArgumentError.new('foo') + exception.set_backtrace([ + "app/controllers/my_controller.rb:53:in `my_controller_params'", + "app/controllers/my_controller.rb:34:in `update'" + ]) + + notifier.call(exception, env: test_env) + end + + private + + def notifier + ExceptionNotifier::GoogleChatNotifier.new(webhook_url: URL) + end + + def post_opts(text) + { + body: { text: text }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + def test_env + Rack::MockRequest.env_for( + '/', + 'HTTP_HOST' => 'test.address', + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_USER_AGENT' => 'Rails Testing', + params: { id: 'foo' } + ) + end + + def header + [ + '', + "Application: *#{app_name}*", + 'An *ArgumentError* occurred.', + '' + ].join("\n") + end + + def body + if defined?(::Rails) && ::Rails.respond_to?(:env) + "⚠️ Error occurred in test ⚠️\n*foo*" + else + "⚠️ Error occurred ⚠️\n*foo*" + end + end + + def app_name + 'dummy' if defined?(::Rails) && ::Rails.respond_to?(:application) + end +end diff --git a/test/exception_notifier/hipchat_notifier_test.rb b/test/exception_notifier/hipchat_notifier_test.rb index 09c44f3f..f9709cce 100644 --- a/test/exception_notifier/hipchat_notifier_test.rb +++ b/test/exception_notifier/hipchat_notifier_test.rb @@ -1,4 +1,5 @@ require 'test_helper' +require 'rack' # silence_warnings trick around require can be removed once # https://github.com/hipchat/hipchat-rb/pull/174 @@ -8,31 +9,31 @@ end class HipchatNotifierTest < ActiveSupport::TestCase - - test "should send hipchat notification if properly configured" do + test 'should send hipchat notification if properly configured' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :color => 'yellow', + api_token: 'good_token', + room_name: 'room_name', + color: 'yellow' } - HipChat::Room.any_instance.expects(:send).with('Exception', fake_body, { :color => 'yellow' }) + HipChat::Room.any_instance.expects(:send).with('Exception', fake_body, color: 'yellow') hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) end - test "should call pre/post_callback if specified" do - pre_callback_called, post_callback_called = 0,0 + test 'should call pre/post_callback if specified' do + pre_callback_called = 0 + post_callback_called = 0 options = { - :api_token => 'good_token', - :room_name => 'room_name', - :color => 'yellow', - :pre_callback => proc { |*| pre_callback_called += 1}, - :post_callback => proc { |*| post_callback_called += 1} + api_token: 'good_token', + room_name: 'room_name', + color: 'yellow', + pre_callback: proc { |*| pre_callback_called += 1 }, + post_callback: proc { |*| post_callback_called += 1 } } - HipChat::Room.any_instance.expects(:send).with('Exception', fake_body, { :color => 'yellow' }.merge(options.except(:api_token, :room_name))) + HipChat::Room.any_instance.expects(:send).with('Exception', fake_body, { color: 'yellow' }.merge(options.except(:api_token, :room_name))) hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) @@ -40,150 +41,150 @@ class HipchatNotifierTest < ActiveSupport::TestCase assert_equal(1, post_callback_called) end - test "should send hipchat notification without backtrace info if properly configured" do + test 'should send hipchat notification without backtrace info if properly configured' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :color => 'yellow', + api_token: 'good_token', + room_name: 'room_name', + color: 'yellow' } - HipChat::Room.any_instance.expects(:send).with('Exception', fake_body_without_backtrace, { :color => 'yellow' }) + HipChat::Room.any_instance.expects(:send).with('Exception', fake_body_without_backtrace, color: 'yellow') hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception_without_backtrace) end - test "should allow custom from value if set" do + test 'should allow custom from value if set' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :from => 'TrollFace', + api_token: 'good_token', + room_name: 'room_name', + from: 'TrollFace' } - HipChat::Room.any_instance.expects(:send).with('TrollFace', fake_body, { :color => 'red' }) + HipChat::Room.any_instance.expects(:send).with('TrollFace', fake_body, color: 'red') hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) end - test "should not send hipchat notification if badly configured" do + test 'should not send hipchat notification if badly configured' do wrong_params = { - :api_token => 'bad_token', - :room_name => 'test_room' + api_token: 'bad_token', + room_name: 'test_room' } - HipChat::Client.stubs(:new).with('bad_token', {:api_version => 'v1'}).returns(nil) + HipChat::Client.stubs(:new).with('bad_token', api_version: 'v1').returns(nil) hipchat = ExceptionNotifier::HipchatNotifier.new(wrong_params) assert_nil hipchat.room end - test "should not send hipchat notification if api_key is missing" do - wrong_params = {:room_name => 'test_room'} + test 'should not send hipchat notification if api_key is missing' do + wrong_params = { room_name: 'test_room' } - HipChat::Client.stubs(:new).with(nil, {:api_version => 'v1'}).returns(nil) + HipChat::Client.stubs(:new).with(nil, api_version: 'v1').returns(nil) hipchat = ExceptionNotifier::HipchatNotifier.new(wrong_params) assert_nil hipchat.room end - test "should not send hipchat notification if room_name is missing" do - wrong_params = {:api_token => 'good_token'} + test 'should not send hipchat notification if room_name is missing' do + wrong_params = { api_token: 'good_token' } - HipChat::Client.stubs(:new).with('good_token', {:api_version => 'v1'}).returns({}) + HipChat::Client.stubs(:new).with('good_token', api_version: 'v1').returns({}) hipchat = ExceptionNotifier::HipchatNotifier.new(wrong_params) assert_nil hipchat.room end - test "should send hipchat notification with message_template" do + test 'should send hipchat notification with message_template' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :color => 'yellow', - :message_template => ->(exception, _) { "This is custom message: '#{exception.message}'" } + api_token: 'good_token', + room_name: 'room_name', + color: 'yellow', + message_template: ->(exception, _) { "This is custom message: '#{exception.message}'" } } - HipChat::Room.any_instance.expects(:send).with('Exception', "This is custom message: '#{fake_exception.message}'", { :color => 'yellow' }) + HipChat::Room.any_instance.expects(:send).with('Exception', "This is custom message: '#{fake_exception.message}'", color: 'yellow') hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) end - test "should send hipchat notification exclude accumulated errors count" do + test 'should send hipchat notification exclude accumulated errors count' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :color => 'yellow' + api_token: 'good_token', + room_name: 'room_name', + color: 'yellow' } - HipChat::Room.any_instance.expects(:send).with{ |_, msg, _| msg.start_with?("A new exception occurred:") } + HipChat::Room.any_instance.expects(:send).with { |_, msg, _| msg.start_with?('A new exception occurred:') } hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) end - test "should send hipchat notification include accumulated errors count" do + test 'should send hipchat notification include accumulated errors count' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :color => 'yellow' + api_token: 'good_token', + room_name: 'room_name', + color: 'yellow' } - HipChat::Room.any_instance.expects(:send).with{ |_, msg, _| msg.start_with?("The exception occurred 3 times:") } + HipChat::Room.any_instance.expects(:send).with { |_, msg, _| msg.start_with?('The exception occurred 3 times:') } hipchat = ExceptionNotifier::HipchatNotifier.new(options) - hipchat.call(fake_exception, { accumulated_errors_count: 3 }) + hipchat.call(fake_exception, accumulated_errors_count: 3) end - test "should send hipchat notification with HTML-escaped meessage if using default message_template" do + test 'should send hipchat notification with HTML-escaped meessage if using default message_template' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :color => 'yellow', + api_token: 'good_token', + room_name: 'room_name', + color: 'yellow' } exception = fake_exception_with_html_characters body = "A new exception occurred: '#{Rack::Utils.escape_html(exception.message)}' on '#{exception.backtrace.first}'" - HipChat::Room.any_instance.expects(:send).with('Exception', body, { :color => 'yellow' }) + HipChat::Room.any_instance.expects(:send).with('Exception', body, color: 'yellow') hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(exception) end - test "should use APIv1 if api_version is not specified" do + test 'should use APIv1 if api_version is not specified' do options = { - :api_token => 'good_token', - :room_name => 'room_name', + api_token: 'good_token', + room_name: 'room_name' } - HipChat::Client.stubs(:new).with('good_token', {:api_version => 'v1'}).returns({}) + HipChat::Client.stubs(:new).with('good_token', api_version: 'v1').returns({}) hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) end - test "should use APIv2 when specified" do + test 'should use APIv2 when specified' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :api_version => 'v2', + api_token: 'good_token', + room_name: 'room_name', + api_version: 'v2' } - HipChat::Client.stubs(:new).with('good_token', {:api_version => 'v2'}).returns({}) + HipChat::Client.stubs(:new).with('good_token', api_version: 'v2').returns({}) hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) end - test "should allow server_url value (for a self-hosted HipChat Server) if set" do + test 'should allow server_url value (for a self-hosted HipChat Server) if set' do options = { - :api_token => 'good_token', - :room_name => 'room_name', - :api_version => 'v2', - :server_url => 'https://domain.com', + api_token: 'good_token', + room_name: 'room_name', + api_version: 'v2', + server_url: 'https://domain.com' } - HipChat::Client.stubs(:new).with('good_token', {:api_version => 'v2', :server_url => 'https://domain.com'}).returns({}) + HipChat::Client.stubs(:new).with('good_token', api_version: 'v2', server_url: 'https://domain.com').returns({}) hipchat = ExceptionNotifier::HipchatNotifier.new(options) hipchat.call(fake_exception) @@ -196,19 +197,15 @@ def fake_body end def fake_exception - begin - 5/0 - rescue Exception => e - e - end + 5 / 0 + rescue StandardError => e + e end def fake_exception_with_html_characters - begin - raise StandardError.new('an error with characters') - rescue Exception => e - e - end + raise StandardError, 'an error with characters' + rescue StandardError => e + e end def fake_body_without_backtrace diff --git a/test/exception_notifier/irc_notifier_test.rb b/test/exception_notifier/irc_notifier_test.rb index 745f91de..32d0095e 100644 --- a/test/exception_notifier/irc_notifier_test.rb +++ b/test/exception_notifier/irc_notifier_test.rb @@ -2,10 +2,9 @@ require 'carrier-pigeon' class IrcNotifierTest < ActiveSupport::TestCase - - test "should send irc notification if properly configured" do + test 'should send irc notification if properly configured' do options = { - :domain => 'irc.example.com' + domain: 'irc.example.com' } CarrierPigeon.expects(:send).with(has_key(:uri)) do |v| @@ -16,29 +15,30 @@ class IrcNotifierTest < ActiveSupport::TestCase irc.call(fake_exception) end - test "should exclude errors count in message if :accumulated_errors_count nil" do + test 'should exclude errors count in message if :accumulated_errors_count nil' do irc = ExceptionNotifier::IrcNotifier.new({}) irc.stubs(:active?).returns(true) - irc.expects(:send_message).with{ |message| message.include?("divided by 0") }.once + irc.expects(:send_message).with { |message| message.include?('divided by 0') }.once irc.call(fake_exception) end - test "should include errors count in message if :accumulated_errors_count is 3" do + test 'should include errors count in message if :accumulated_errors_count is 3' do irc = ExceptionNotifier::IrcNotifier.new({}) irc.stubs(:active?).returns(true) - irc.expects(:send_message).with{ |message| message.include?("(3 times)'divided by 0'") }.once + irc.expects(:send_message).with { |message| message.include?("(3 times)'divided by 0'") }.once irc.call(fake_exception, accumulated_errors_count: 3) end - test "should call pre/post_callback if specified" do - pre_callback_called, post_callback_called = 0,0 + test 'should call pre/post_callback if specified' do + pre_callback_called = 0 + post_callback_called = 0 options = { - :domain => 'irc.example.com', - :pre_callback => proc { |*| pre_callback_called += 1}, - :post_callback => proc { |*| post_callback_called += 1} + domain: 'irc.example.com', + pre_callback: proc { |*| pre_callback_called += 1 }, + post_callback: proc { |*| post_callback_called += 1 } } CarrierPigeon.expects(:send).with(has_key(:uri)) do |v| @@ -51,9 +51,9 @@ class IrcNotifierTest < ActiveSupport::TestCase assert_equal(1, post_callback_called) end - test "should send irc notification without backtrace info if properly configured" do + test 'should send irc notification without backtrace info if properly configured' do options = { - :domain => 'irc.example.com' + domain: 'irc.example.com' } CarrierPigeon.expects(:send).with(has_key(:uri)) do |v| @@ -64,25 +64,25 @@ class IrcNotifierTest < ActiveSupport::TestCase irc.call(fake_exception_without_backtrace) end - test "should properly construct URI from constituent parts" do + test 'should properly construct URI from constituent parts' do options = { - :nick => 'BadNewsBot', - :password => 'secret', - :domain => 'irc.example.com', - :port => 9999, - :channel => '#exceptions' + nick: 'BadNewsBot', + password: 'secret', + domain: 'irc.example.com', + port: 9999, + channel: '#exceptions' } - CarrierPigeon.expects(:send).with(has_entry(uri: "irc://BadNewsBot:secret@irc.example.com:9999/#exceptions")) + CarrierPigeon.expects(:send).with(has_entry(uri: 'irc://BadNewsBot:secret@irc.example.com:9999/#exceptions')) irc = ExceptionNotifier::IrcNotifier.new(options) irc.call(fake_exception) end - test "should properly add recipients if specified" do + test 'should properly add recipients if specified' do options = { domain: 'irc.example.com', - recipients: ['peter', 'michael', 'samir'] + recipients: %w[peter michael samir] } CarrierPigeon.expects(:send).with(has_key(:uri)) do |v| @@ -93,7 +93,7 @@ class IrcNotifierTest < ActiveSupport::TestCase irc.call(fake_exception) end - test "should properly set miscellaneous options" do + test 'should properly set miscellaneous options' do options = { domain: 'irc.example.com', ssl: true, @@ -102,11 +102,13 @@ class IrcNotifierTest < ActiveSupport::TestCase prefix: '[test notification]' } - CarrierPigeon.expects(:send).with(has_entries( + entries = { ssl: true, join: true, - notice: true, - )) do |v| + notice: true + } + + CarrierPigeon.expects(:send).with(has_entries(entries)) do |v| /\[test notification\]/.match(v[:message]) end @@ -114,8 +116,8 @@ class IrcNotifierTest < ActiveSupport::TestCase irc.call(fake_exception) end - test "should not send irc notification if badly configured" do - wrong_params = { domain: '##scriptkiddie.com###'} + test 'should not send irc notification if badly configured' do + wrong_params = { domain: '##scriptkiddie.com###' } irc = ExceptionNotifier::IrcNotifier.new(wrong_params) assert_nil irc.call(fake_exception) @@ -124,11 +126,9 @@ class IrcNotifierTest < ActiveSupport::TestCase private def fake_exception - begin - 5/0 - rescue Exception => e - e - end + 5 / 0 + rescue StandardError => e + e end def fake_exception_without_backtrace diff --git a/test/exception_notifier/mattermost_notifier_test.rb b/test/exception_notifier/mattermost_notifier_test.rb index adeb30b6..6ff8d784 100644 --- a/test/exception_notifier/mattermost_notifier_test.rb +++ b/test/exception_notifier/mattermost_notifier_test.rb @@ -1,105 +1,202 @@ require 'test_helper' require 'httparty' +require 'timecop' +require 'json' class MattermostNotifierTest < ActiveSupport::TestCase + URL = 'http://localhost:8000'.freeze - test "should send notification if properly configured" do - options = { - :webhook_url => 'http://localhost:8000' + def setup + Timecop.freeze('2018-12-09 12:07:16 UTC') + end + + def teardown + Timecop.return + end + + test 'should send notification if properly configured' do + opts = { + body: default_body.to_json, + headers: default_headers } - mattermost_notifier = ExceptionNotifier::MattermostNotifier.new - mattermost_notifier.httparty = FakeHTTParty.new - options = mattermost_notifier.call ArgumentError.new("foo"), options + HTTParty.expects(:post).with(URL, opts) + notifier.call ArgumentError.new('foo') + end - body = ActiveSupport::JSON.decode options[:body] - assert body.has_key? 'text' - assert body.has_key? 'username' + test 'should send notification with create issue link if specified' do + body = default_body.merge( + text: [ + '@channel', + error_occurred_in, + 'An *ArgumentError* occurred.', + '*foo*', + github_link + ].join("\n") + ) + + opts = { + body: body.to_json, + headers: default_headers + } - text = body['text'].split("\n") - assert_equal 4, text.size - assert_equal '@channel', text[0] - assert_equal 'An *ArgumentError* occured.', text[2] - assert_equal '*foo*', text[3] + HTTParty.expects(:post).with(URL, opts) + notifier.call ArgumentError.new('foo'), git_url: 'github.com/aschen' end - test "should send notification with create issue link if specified" do - options = { - :webhook_url => 'http://localhost:8000', - :git_url => 'github.com/aschen' + test 'should add username and icon_url params to the notification if specified' do + body = default_body.merge( + username: 'Test Bot', + icon_url: 'http://site.com/icon.png' + ) + + opts = { + body: body.to_json, + headers: default_headers } - mattermost_notifier = ExceptionNotifier::MattermostNotifier.new - mattermost_notifier.httparty = FakeHTTParty.new - options = mattermost_notifier.call ArgumentError.new("foo"), options + HTTParty.expects(:post).with(URL, opts) + notifier.call( + ArgumentError.new('foo'), + username: 'Test Bot', + avatar: 'http://site.com/icon.png' + ) + end - body = ActiveSupport::JSON.decode options[:body] + test 'should add other HTTParty options to params' do + opts = { + basic_auth: { + username: 'clara', + password: 'password' + }, + body: default_body.to_json, + headers: default_headers + } - text = body['text'].split("\n") - assert_equal 5, text.size - assert_equal '[Create an issue](github.com/aschen/dummy/issues/new/?issue%5Btitle%5D=%5BBUG%5D+Error+500+%3A++%28ArgumentError%29+foo)', text[4] + HTTParty.expects(:post).with(URL, opts) + notifier.call( + ArgumentError.new('foo'), + basic_auth: { + username: 'clara', + password: 'password' + } + ) end - test 'should add username and icon_url params to the notification if specified' do - options = { - :webhook_url => 'http://localhost:8000', - :username => "Test Bot", - :avatar => 'http://site.com/icon.png' + test "should use 'An' for exceptions count if :accumulated_errors_count option is nil" do + opts = { + body: default_body.to_json, + headers: default_headers } - mattermost_notifier = ExceptionNotifier::MattermostNotifier.new - mattermost_notifier.httparty = FakeHTTParty.new - options = mattermost_notifier.call ArgumentError.new("foo"), options + HTTParty.expects(:post).with(URL, opts) + notifier.call(ArgumentError.new('foo')) + end - body = ActiveSupport::JSON.decode options[:body] + test 'shoud use direct errors count if :accumulated_errors_count option is 5' do + body = default_body.merge( + text: [ + '@channel', + error_occurred_in, + '5 *ArgumentError* occurred.', + '*foo*' + ].join("\n") + ) + + opts = { + body: body.to_json, + headers: default_headers + } - assert_equal 'Test Bot', body['username'] - assert_equal 'http://site.com/icon.png', body['icon_url'] + HTTParty.expects(:post).with(URL, opts) + notifier.call(ArgumentError.new('foo'), accumulated_errors_count: 5) end - test 'should add other HTTParty options to params' do - options = { - :webhook_url => 'http://localhost:8000', - :username => "Test Bot", - :avatar => 'http://site.com/icon.png', - :basic_auth => { - :username => 'clara', - :password => 'password' - } + test 'should include backtrace and request info' do + body = default_body.merge( + text: [ + '@channel', + error_occurred_in, + 'An *ArgumentError* occurred.', + '*foo*', + '### Request', + '```', + '* url : http://test.address/?id=foo', + '* http_method : GET', + '* ip_address : 127.0.0.1', + '* parameters : {"id"=>"foo"}', + '* timestamp : 2018-12-09 12:07:16 UTC', + '```', + '### Backtrace', + '```', + "* app/controllers/my_controller.rb:53:in `my_controller_params'", + "* app/controllers/my_controller.rb:34:in `update'", + '```' + ].join("\n") + ) + + opts = { + body: body.to_json, + headers: default_headers } - mattermost_notifier = ExceptionNotifier::MattermostNotifier.new - mattermost_notifier.httparty = FakeHTTParty.new - options = mattermost_notifier.call ArgumentError.new("foo"), options + HTTParty.expects(:post).with(URL, opts) + + exception = ArgumentError.new('foo') + exception.set_backtrace([ + "app/controllers/my_controller.rb:53:in `my_controller_params'", + "app/controllers/my_controller.rb:34:in `update'" + ]) - assert options.has_key? :basic_auth - assert 'clara', options[:basic_auth][:username] - assert 'password', options[:basic_auth][:password] + notifier.call(exception, env: test_env) end - test "should use 'An' for exceptions count if :accumulated_errors_count option is nil" do - mattermost_notifier = ExceptionNotifier::MattermostNotifier.new - exception = ArgumentError.new("foo") - mattermost_notifier.instance_variable_set(:@exception, exception) - mattermost_notifier.instance_variable_set(:@options, {}) + private - assert_includes mattermost_notifier.send(:message_header), "An *ArgumentError* occured." + def notifier + ExceptionNotifier::MattermostNotifier.new(webhook_url: URL) end - test "shoud use direct errors count if :accumulated_errors_count option is 5" do - mattermost_notifier = ExceptionNotifier::MattermostNotifier.new - exception = ArgumentError.new("foo") - mattermost_notifier.instance_variable_set(:@exception, exception) - mattermost_notifier.instance_variable_set(:@options, { accumulated_errors_count: 5 }) + def default_body + { + text: [ + '@channel', + error_occurred_in, + 'An *ArgumentError* occurred.', + '*foo*' + ].join("\n"), + username: 'Exception Notifier' + } + end - assert_includes mattermost_notifier.send(:message_header), "5 *ArgumentError* occured." + def default_headers + { 'Content-Type' => 'application/json' } end -end -class FakeHTTParty + def test_env + Rack::MockRequest.env_for( + '/', + 'HTTP_HOST' => 'test.address', + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_USER_AGENT' => 'Rails Testing', + params: { id: 'foo' } + ) + end - def post(url, options) - return options + def error_occurred_in + if defined?(::Rails) && ::Rails.respond_to?(:env) + '### ⚠️ Error occurred in test ⚠️' + else + '### ⚠️ Error occurred ⚠️' + end end + def github_link + if defined?(::Rails) && ::Rails.respond_to?(:application) + '[Create an issue](github.com/aschen/dummy/issues/new/?issue%5Btitle%5D=%5BBUG%5D+Error+500+%3A++%28ArgumentError%29+foo)' + else + # TODO: fix missing app name + '[Create an issue](github.com/aschen//issues/new/?issue%5Btitle%5D=%5BBUG%5D+Error+500+%3A++%28ArgumentError%29+foo)' + end + end end diff --git a/test/exception_notifier/modules/error_grouping_test.rb b/test/exception_notifier/modules/error_grouping_test.rb index 0053cf11..3624ac4f 100644 --- a/test/exception_notifier/modules/error_grouping_test.rb +++ b/test/exception_notifier/modules/error_grouping_test.rb @@ -1,18 +1,17 @@ require 'test_helper' class ErrorGroupTest < ActiveSupport::TestCase - setup do module TestModule include ExceptionNotifier::ErrorGrouping - @@error_grouping_cache = ActiveSupport::Cache::FileStore.new("test/dummy/tmp/cache") + @@error_grouping_cache = ActiveSupport::Cache::FileStore.new('test/dummy/tmp/non_default_location') end - @exception = RuntimeError.new("ERROR") - @exception.stubs(:backtrace).returns(["/path/where/error/raised:1"]) + @exception = RuntimeError.new('ERROR') + @exception.stubs(:backtrace).returns(['/path/where/error/raised:1']) - @exception2 = RuntimeError.new("ERROR2") - @exception2.stubs(:backtrace).returns(["/path/where/error/found:2"]) + @exception2 = RuntimeError.new('ERROR2') + @exception2.stubs(:backtrace).returns(['/path/where/error/found:2']) end teardown do @@ -20,88 +19,88 @@ module TestModule TestModule.fallback_cache_store.clear end - test "should add additional option: error_grouping" do + test 'should add additional option: error_grouping' do assert_respond_to TestModule, :error_grouping assert_respond_to TestModule, :error_grouping= end - test "should set error_grouping to false default" do + test 'should set error_grouping to false default' do assert_equal false, TestModule.error_grouping end - test "should add additional option: error_grouping_cache" do + test 'should add additional option: error_grouping_cache' do assert_respond_to TestModule, :error_grouping_cache assert_respond_to TestModule, :error_grouping_cache= end - test "should add additional option: error_grouping_period" do + test 'should add additional option: error_grouping_period' do assert_respond_to TestModule, :error_grouping_period assert_respond_to TestModule, :error_grouping_period= end - test "shoud set error_grouping_period to 5.minutes default" do + test 'shoud set error_grouping_period to 5.minutes default' do assert_equal 300, TestModule.error_grouping_period end - test "should add additional option: notification_trigger" do + test 'should add additional option: notification_trigger' do assert_respond_to TestModule, :notification_trigger assert_respond_to TestModule, :notification_trigger= end - test "should return errors count nil when not same error for .error_count" do - assert_nil TestModule.error_count("something") + test 'should return errors count nil when not same error for .error_count' do + assert_nil TestModule.error_count('something') end - test "should return errors count when same error for .error_count" do - TestModule.error_grouping_cache.write("error_key", 13) - assert_equal 13, TestModule.error_count("error_key") + test 'should return errors count when same error for .error_count' do + TestModule.error_grouping_cache.write('error_key', 13) + assert_equal 13, TestModule.error_count('error_key') end - test "should fallback to memory store cache if specified cache store failed to read" do - TestModule.error_grouping_cache.stubs(:read).raises(RuntimeError.new "Failed to read") + test 'should fallback to memory store cache if specified cache store failed to read' do + TestModule.error_grouping_cache.stubs(:read).raises(RuntimeError.new('Failed to read')) original_fallback = TestModule.fallback_cache_store TestModule.expects(:fallback_cache_store).returns(original_fallback).at_least_once - assert_nil TestModule.error_count("something_to_read") + assert_nil TestModule.error_count('something_to_read') end - test "should save error with count for .save_error_count" do + test 'should save error with count for .save_error_count' do count = rand(1..10) - TestModule.save_error_count("error_key", count) - assert_equal count, TestModule.error_grouping_cache.read("error_key") + TestModule.save_error_count('error_key', count) + assert_equal count, TestModule.error_grouping_cache.read('error_key') end - test "should fallback to memory store cache if specified cache store failed to write" do - TestModule.error_grouping_cache.stubs(:write).raises(RuntimeError.new "Failed to write") + test 'should fallback to memory store cache if specified cache store failed to write' do + TestModule.error_grouping_cache.stubs(:write).raises(RuntimeError.new('Failed to write')) original_fallback = TestModule.fallback_cache_store TestModule.expects(:fallback_cache_store).returns(original_fallback).at_least_once - assert TestModule.save_error_count("something_to_cache", rand(1..10)) + assert TestModule.save_error_count('something_to_cache', rand(1..10)) end - test "should save accumulated_errors_count into options" do + test 'should save accumulated_errors_count into options' do options = {} TestModule.group_error!(@exception, options) assert_equal 1, options[:accumulated_errors_count] end - test "should not group error if different exception in .group_error!" do + test 'should not group error if different exception in .group_error!' do options1 = {} - TestModule.expects(:save_error_count).with{|key, count| key.is_a?(String) && count == 1}.times(4).returns(true) + TestModule.expects(:save_error_count).with { |key, count| key.is_a?(String) && count == 1 }.times(4).returns(true) TestModule.group_error!(@exception, options1) options2 = {} - TestModule.group_error!(NoMethodError.new("method not found"), options2) + TestModule.group_error!(NoMethodError.new('method not found'), options2) assert_equal 1, options1[:accumulated_errors_count] assert_equal 1, options2[:accumulated_errors_count] end - test "should not group error is same exception but different message or backtrace" do + test 'should not group error is same exception but different message or backtrace' do options1 = {} - TestModule.expects(:save_error_count).with{|key, count| key.is_a?(String) && count == 1}.times(4).returns(true) + TestModule.expects(:save_error_count).with { |key, count| key.is_a?(String) && count == 1 }.times(4).returns(true) TestModule.group_error!(@exception, options1) options2 = {} @@ -111,7 +110,7 @@ module TestModule assert_equal 1, options2[:accumulated_errors_count] end - test "should group error if same exception and message" do + test 'should group error if same exception and message' do options = {} 10.times do |i| @@ -122,7 +121,7 @@ module TestModule assert_equal 10, options[:accumulated_errors_count] end - test "should group error if same exception and backtrace" do + test 'should group error if same exception and backtrace' do options = {} 10.times do |i| @@ -133,9 +132,9 @@ module TestModule assert_equal 10, options[:accumulated_errors_count] end - test "should group error by that message have high priority" do + test 'should group error by that message have high priority' do message_based_key = "exception:#{Zlib.crc32("RuntimeError\nmessage:ERROR")}" - backtrace_based_key = "exception:#{Zlib.crc32("RuntimeError\n/path/where/error/raised:1")}" + backtrace_based_key = "exception:#{Zlib.crc32("RuntimeError\npath:/path/where/error/raised:1")}" TestModule.save_error_count(message_based_key, 1) TestModule.save_error_count(backtrace_based_key, 1) @@ -146,7 +145,7 @@ module TestModule TestModule.group_error!(@exception, {}) end - test "use default formula if not specify notification_trigger in .send_notification?" do + test 'use default formula if not specify notification_trigger in .send_notification?' do TestModule.stubs(:notification_trigger).returns(nil) count = 16 @@ -155,12 +154,12 @@ module TestModule assert TestModule.send_notification?(@exception, count) end - test "use specified trigger in .send_notification?" do - trigger = Proc.new { |exception, count| count % 4 == 0 } + test 'use specified trigger in .send_notification?' do + trigger = proc { |_exception, count| (count % 4).zero? } TestModule.stubs(:notification_trigger).returns(trigger) count = 16 trigger.expects(:call).with(@exception, count).returns(true) assert TestModule.send_notification?(@exception, count) end -end \ No newline at end of file +end diff --git a/test/exception_notifier/modules/formatter_test.rb b/test/exception_notifier/modules/formatter_test.rb new file mode 100644 index 00000000..fef51c0a --- /dev/null +++ b/test/exception_notifier/modules/formatter_test.rb @@ -0,0 +1,150 @@ +require 'test_helper' +require 'timecop' + +class FormatterTest < ActiveSupport::TestCase + setup do + @exception = RuntimeError.new('test') + Timecop.freeze('2018-12-09 12:07:16 UTC') + end + + teardown do + Timecop.return + end + + # + # #title + # + test 'title returns correct content' do + formatter = ExceptionNotifier::Formatter.new(@exception) + + title = if defined?(::Rails) && ::Rails.respond_to?(:env) + '⚠️ Error occurred in test ⚠️' + else + '⚠️ Error occurred ⚠️' + end + + assert_equal title, formatter.title + end + + # + # #subtitle + # + test 'subtitle without accumulated error' do + formatter = ExceptionNotifier::Formatter.new(@exception) + assert_equal 'A *RuntimeError* occurred.', formatter.subtitle + end + + test 'subtitle with accumulated error' do + formatter = ExceptionNotifier::Formatter.new(@exception, accumulated_errors_count: 3) + assert_equal '3 *RuntimeError* occurred.', formatter.subtitle + end + + test 'subtitle with controller' do + env = Rack::MockRequest.env_for( + '/', 'action_controller.instance' => test_controller + ) + + formatter = ExceptionNotifier::Formatter.new(@exception, env: env) + assert_equal 'A *RuntimeError* occurred in *home#index*.', formatter.subtitle + end + + # + # #app_name + # + test 'app_name defaults to Rails app name' do + formatter = ExceptionNotifier::Formatter.new(@exception) + + if defined?(::Rails) && ::Rails.respond_to?(:application) + assert_equal 'dummy', formatter.app_name + else + assert_nil formatter.app_name + end + end + + test 'app_name can be overwritten using options' do + formatter = ExceptionNotifier::Formatter.new(@exception, app_name: 'test') + assert_equal 'test', formatter.app_name + end + + # + # #request_message + # + test 'request_message when env set' do + text = [ + '```', + '* url : http://test.address/?id=foo', + '* http_method : GET', + '* ip_address : 127.0.0.1', + '* parameters : {"id"=>"foo"}', + '* timestamp : 2018-12-09 12:07:16 UTC', + '```' + ].join("\n") + + env = Rack::MockRequest.env_for( + '/', + 'HTTP_HOST' => 'test.address', + 'REMOTE_ADDR' => '127.0.0.1', + params: { id: 'foo' } + ) + + formatter = ExceptionNotifier::Formatter.new(@exception, env: env) + assert_equal text, formatter.request_message + end + + test 'request_message when env not set' do + formatter = ExceptionNotifier::Formatter.new(@exception) + assert_nil formatter.request_message + end + + # + # #backtrace_message + # + test 'backtrace_message when backtrace set' do + text = [ + '```', + "* app/controllers/my_controller.rb:53:in `my_controller_params'", + "* app/controllers/my_controller.rb:34:in `update'", + '```' + ].join("\n") + + @exception.set_backtrace([ + "app/controllers/my_controller.rb:53:in `my_controller_params'", + "app/controllers/my_controller.rb:34:in `update'" + ]) + + formatter = ExceptionNotifier::Formatter.new(@exception) + assert_equal text, formatter.backtrace_message + end + + test 'backtrace_message when no backtrace' do + formatter = ExceptionNotifier::Formatter.new(@exception) + assert_nil formatter.backtrace_message + end + + # + # #controller_and_action + # + test 'correct controller_and_action if controller is present' do + env = Rack::MockRequest.env_for( + '/', 'action_controller.instance' => test_controller + ) + + formatter = ExceptionNotifier::Formatter.new(@exception, env: env) + assert_equal 'home#index', formatter.controller_and_action + end + + test 'controller_and_action is nil if no controller' do + env = Rack::MockRequest.env_for('/') + + formatter = ExceptionNotifier::Formatter.new(@exception, env: env) + assert_nil formatter.controller_and_action + end + + def test_controller + controller = mock('controller') + controller.stubs(:action_name).returns('index') + controller.stubs(:controller_name).returns('home') + + controller + end +end diff --git a/test/exception_notifier/sidekiq_test.rb b/test/exception_notifier/sidekiq_test.rb index 6e770d28..6674240b 100644 --- a/test/exception_notifier/sidekiq_test.rb +++ b/test/exception_notifier/sidekiq_test.rb @@ -1,11 +1,11 @@ -require "test_helper" +require 'test_helper' # To allow sidekiq error handlers to be registered, sidekiq must be in # "server mode". This mode is triggered by loading sidekiq/cli. Note this # has to be loaded before exception_notification/sidekiq. -require "sidekiq/cli" +require 'sidekiq/cli' -require "exception_notification/sidekiq" +require 'exception_notification/sidekiq' class MockSidekiqServer include ::Sidekiq::ExceptionHandler @@ -19,14 +19,14 @@ class SidekiqTest < ActiveSupport::TestCase Sidekiq::Logging.logger = nil end - test "should call notify_exception when sidekiq raises an error" do + test 'should call notify_exception when sidekiq raises an error' do server = MockSidekiqServer.new - message = Hash.new + message = {} exception = RuntimeError.new ExceptionNotifier.expects(:notify_exception).with( exception, - :data => { :sidekiq => message } + data: { sidekiq: message } ) server.handle_exception(exception, message) diff --git a/test/exception_notifier/slack_notifier_test.rb b/test/exception_notifier/slack_notifier_test.rb index 14fda22d..72db960e 100644 --- a/test/exception_notifier/slack_notifier_test.rb +++ b/test/exception_notifier/slack_notifier_test.rb @@ -2,17 +2,17 @@ require 'slack-notifier' class SlackNotifierTest < ActiveSupport::TestCase - def setup @exception = fake_exception @exception.stubs(:backtrace).returns(fake_backtrace) @exception.stubs(:message).returns('exception message') + ExceptionNotifier::SlackNotifier.any_instance.stubs(:clean_backtrace).returns(fake_cleaned_backtrace) Socket.stubs(:gethostname).returns('example.com') end - test "should send a slack notification if properly configured" do + test 'should send a slack notification if properly configured' do options = { - webhook_url: "http://slack.webhook.url" + webhook_url: 'http://slack.webhook.url' } Slack::Notifier.any_instance.expects(:ping).with('', fake_notification) @@ -21,9 +21,9 @@ def setup slack_notifier.call(@exception) end - test "should send a slack notification without backtrace info if properly configured" do + test 'should send a slack notification without backtrace info if properly configured' do options = { - webhook_url: "http://slack.webhook.url" + webhook_url: 'http://slack.webhook.url' } Slack::Notifier.any_instance.expects(:ping).with('', fake_notification(fake_exception_without_backtrace)) @@ -32,10 +32,10 @@ def setup slack_notifier.call(fake_exception_without_backtrace) end - test "should send the notification to the specified channel" do + test 'should send the notification to the specified channel' do options = { - webhook_url: "http://slack.webhook.url", - channel: "channel" + webhook_url: 'http://slack.webhook.url', + channel: 'channel' } Slack::Notifier.any_instance.expects(:ping).with('', fake_notification) @@ -47,10 +47,10 @@ def setup assert_equal channel, options[:channel] end - test "should send the notification to the specified username" do + test 'should send the notification to the specified username' do options = { - webhook_url: "http://slack.webhook.url", - username: "username" + webhook_url: 'http://slack.webhook.url', + username: 'username' } Slack::Notifier.any_instance.expects(:ping).with('', fake_notification) @@ -62,9 +62,9 @@ def setup assert_equal username, options[:username] end - test "should send the notification with specific backtrace lines" do + test 'should send the notification with specific backtrace lines' do options = { - webhook_url: "http://slack.webhook.url", + webhook_url: 'http://slack.webhook.url', backtrace_lines: 1 } @@ -74,10 +74,10 @@ def setup slack_notifier.call(@exception) end - test "should send the notification with additional fields" do - field = {title: "Branch", value: "master", short: true} + test 'should send the notification with additional fields' do + field = { title: 'Branch', value: 'master', short: true } options = { - webhook_url: "http://slack.webhook.url", + webhook_url: 'http://slack.webhook.url', additional_fields: [field] } @@ -90,17 +90,17 @@ def setup assert_equal additional_fields, options[:additional_fields] end - test "should pass the additional parameters to Slack::Notifier.ping" do + test 'should pass the additional parameters to Slack::Notifier.ping' do options = { - webhook_url: "http://slack.webhook.url", - username: "test", - custom_hook: "hook", + webhook_url: 'http://slack.webhook.url', + username: 'test', + custom_hook: 'hook', additional_parameters: { - icon_url: "icon", + icon_url: 'icon' } } - Slack::Notifier.any_instance.expects(:ping).with('', options[:additional_parameters].merge(fake_notification) ) + Slack::Notifier.any_instance.expects(:ping).with('', options[:additional_parameters].merge(fake_notification)) slack_notifier = ExceptionNotifier::SlackNotifier.new(options) slack_notifier.call(@exception) @@ -115,22 +115,22 @@ def setup assert_nil slack_notifier.call(@exception) end - test "should pass along environment data" do + test 'should pass along environment data' do options = { - webhook_url: "http://slack.webhook.url", - ignore_data_if: lambda {|k,v| - "#{k}" == 'key_to_be_ignored' || v.is_a?(Hash) + webhook_url: 'http://slack.webhook.url', + ignore_data_if: lambda { |k, v| + k.to_s == 'key_to_be_ignored' || v.is_a?(Hash) } } notification_options = { env: { - 'exception_notifier.exception_data' => {foo: 'bar', john: 'doe'} + 'exception_notifier.exception_data' => { foo: 'bar', john: 'doe' } }, data: { 'user_id' => 5, 'key_to_be_ignored' => 'whatever', - 'ignore_as_well' => {what: 'ever'} + 'ignore_as_well' => { what: 'ever' } } } @@ -141,29 +141,29 @@ def setup slack_notifier.call(@exception, notification_options) end - test "should call pre/post_callback proc if specified" do + test 'should call pre/post_callback proc if specified' do post_callback_called = 0 options = { - webhook_url: "http://slack.webhook.url", - username: "test", - custom_hook: "hook", - :pre_callback => proc { |opts, notifier, backtrace, message, message_opts| - (message_opts[:attachments] = []) << { text: "#{backtrace.join("\n")}", color: 'danger' } + webhook_url: 'http://slack.webhook.url', + username: 'test', + custom_hook: 'hook', + pre_callback: proc { |_opts, _notifier, backtrace, _message, message_opts| + (message_opts[:attachments] = []) << { text: backtrace.join("\n").to_s, color: 'danger' } }, - :post_callback => proc { |opts, notifier, backtrace, message, message_opts| + post_callback: proc { |_opts, _notifier, _backtrace, _message, _message_opts| post_callback_called = 1 }, additional_parameters: { - icon_url: "icon", + icon_url: 'icon' } } Slack::Notifier.any_instance.expects(:ping).with('', - {:icon_url => 'icon', - :attachments => [ - {:text => fake_backtrace.join("\n"), - :color => 'danger'} - ]}) + icon_url: 'icon', + attachments: [{ + text: fake_backtrace.join("\n"), + color: 'danger' + }]) slack_notifier = ExceptionNotifier::SlackNotifier.new(options) slack_notifier.call(@exception) @@ -173,11 +173,9 @@ def setup private def fake_exception - begin - 5/0 - rescue Exception => e - e - end + 5 / 0 + rescue StandardError => e + e end def fake_exception_without_backtrace @@ -186,17 +184,21 @@ def fake_exception_without_backtrace def fake_backtrace [ - "backtrace line 1", - "backtrace line 2", - "backtrace line 3", - "backtrace line 4", - "backtrace line 5", - "backtrace line 6", + 'backtrace line 1', + 'backtrace line 2', + 'backtrace line 3', + 'backtrace line 4', + 'backtrace line 5', + 'backtrace line 6' ] end + def fake_cleaned_backtrace + fake_backtrace[2..-1] + end + def fake_notification(exception = @exception, notification_options = {}, data_string = nil, expected_backtrace_lines = 10, additional_fields = []) - exception_name = "*#{exception.class.to_s =~ /^[aeiou]/i ? 'An' : 'A'}* `#{exception.class.to_s}`" + exception_name = "*#{exception.class.to_s =~ /^[aeiou]/i ? 'An' : 'A'}* `#{exception.class}`" if notification_options[:env].nil? text = "#{exception_name} *occured in background*" else @@ -211,16 +213,15 @@ def fake_notification(exception = @exception, notification_options = {}, data_st text += "\n" - fields = [ { title: 'Exception', value: exception.message} ] - fields.push({ title: 'Hostname', value: 'example.com' }) + fields = [{ title: 'Exception', value: exception.message }] + fields.push(title: 'Hostname', value: 'example.com') if exception.backtrace - formatted_backtrace = "```#{exception.backtrace.first(expected_backtrace_lines).join("\n")}```" - fields.push({ title: 'Backtrace', value: formatted_backtrace }) + formatted_backtrace = "```#{fake_cleaned_backtrace.first(expected_backtrace_lines).join("\n")}```" + fields.push(title: 'Backtrace', value: formatted_backtrace) end - fields.push({ title: 'Data', value: "```#{data_string}```" }) if data_string + fields.push(title: 'Data', value: "```#{data_string}```") if data_string additional_fields.each { |f| fields.push(f) } - { attachments: [ color: 'danger', text: text, fields: fields, mrkdwn_in: %w(text fields) ] } + { attachments: [color: 'danger', text: text, fields: fields, mrkdwn_in: %w[text fields]] } end - end diff --git a/test/exception_notifier/sns_notifier_test.rb b/test/exception_notifier/sns_notifier_test.rb new file mode 100644 index 00000000..82abed8f --- /dev/null +++ b/test/exception_notifier/sns_notifier_test.rb @@ -0,0 +1,121 @@ +require 'test_helper' +require 'aws-sdk-sns' + +class SnsNotifierTest < ActiveSupport::TestCase + def setup + @exception = fake_exception + @exception.stubs(:class).returns('MyException') + @exception.stubs(:backtrace).returns(fake_backtrace) + @exception.stubs(:message).returns("undefined method 'method=' for Empty") + @options = { + access_key_id: 'my-access_key_id', + secret_access_key: 'my-secret_access_key', + region: 'us-east', + topic_arn: 'topicARN', + sns_prefix: '[App Exception]' + } + Socket.stubs(:gethostname).returns('example.com') + end + + # initialize + + test 'should initialize aws notifier with received params' do + Aws::SNS::Client.expects(:new).with( + region: 'us-east', + access_key_id: 'my-access_key_id', + secret_access_key: 'my-secret_access_key' + ) + + ExceptionNotifier::SnsNotifier.new(@options) + end + + test 'should raise an exception if region is not received' do + @options[:region] = nil + + error = assert_raises ArgumentError do + ExceptionNotifier::SnsNotifier.new(@options) + end + assert_equal "You must provide 'region' option", error.message + end + + test 'should raise an exception on publish if access_key_id is not received' do + @options[:access_key_id] = nil + error = assert_raises ArgumentError do + ExceptionNotifier::SnsNotifier.new(@options) + end + + assert_equal "You must provide 'access_key_id' option", error.message + end + + test 'should raise an exception on publish if secret_access_key is not received' do + @options[:secret_access_key] = nil + error = assert_raises ArgumentError do + ExceptionNotifier::SnsNotifier.new(@options) + end + + assert_equal "You must provide 'secret_access_key' option", error.message + end + + # call + + test 'should send a sns notification in background' do + Aws::SNS::Client.any_instance.expects(:publish).with( + topic_arn: 'topicARN', + message: "3 MyException occured in background\n"\ + "Exception: undefined method 'method=' for Empty\n"\ + "Hostname: example.com\n"\ + "Backtrace:\n#{fake_backtrace.join("\n")}\n", + subject: '[App Exception] - 3 MyException occurred' + ) + + sns_notifier = ExceptionNotifier::SnsNotifier.new(@options) + sns_notifier.call(@exception, accumulated_errors_count: 3) + end + + test 'should send a sns notification with controller#action information' do + controller = mock('controller') + controller.stubs(:action_name).returns('index') + controller.stubs(:controller_name).returns('examples') + + Aws::SNS::Client.any_instance.expects(:publish).with( + topic_arn: 'topicARN', + message: 'A MyException occurred while GET '\ + "was processed by examples#index\n"\ + "Exception: undefined method 'method=' for Empty\n"\ + "Hostname: example.com\n"\ + "Backtrace:\n#{fake_backtrace.join("\n")}\n", + subject: '[App Exception] - A MyException occurred' + ) + + sns_notifier = ExceptionNotifier::SnsNotifier.new(@options) + sns_notifier.call(@exception, + env: { + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/examples', + 'action_controller.instance' => controller + }) + end + + private + + def fake_exception + 1 / 0 + rescue StandardError => e + e + end + + def fake_exception_without_backtrace + StandardError.new('my custom error') + end + + def fake_backtrace + [ + 'backtrace line 1', + 'backtrace line 2', + 'backtrace line 3', + 'backtrace line 4', + 'backtrace line 5', + 'backtrace line 6' + ] + end +end diff --git a/test/exception_notifier/teams_notifier_test.rb b/test/exception_notifier/teams_notifier_test.rb new file mode 100644 index 00000000..d21b800b --- /dev/null +++ b/test/exception_notifier/teams_notifier_test.rb @@ -0,0 +1,90 @@ +require 'test_helper' +require 'httparty' + +class TeamsNotifierTest < ActiveSupport::TestCase + test 'should send notification if properly configured' do + options = { + webhook_url: 'http://localhost:8000' + } + teams_notifier = ExceptionNotifier::TeamsNotifier.new + teams_notifier.httparty = FakeHTTParty.new + + options = teams_notifier.call ArgumentError.new('foo'), options + + body = ActiveSupport::JSON.decode options[:body] + assert body.key? 'title' + assert body.key? 'sections' + + sections = body['sections'] + header = sections[0] + + assert_equal 2, sections.size + assert_equal 'A *ArgumentError* occurred.', header['activityTitle'] + assert_equal 'foo', header['activitySubtitle'] + end + + test 'should send notification with create gitlab issue link if specified' do + options = { + webhook_url: 'http://localhost:8000', + git_url: 'github.com/aschen' + } + teams_notifier = ExceptionNotifier::TeamsNotifier.new + teams_notifier.httparty = FakeHTTParty.new + + options = teams_notifier.call ArgumentError.new('foo'), options + + body = ActiveSupport::JSON.decode options[:body] + + potential_action = body['potentialAction'] + assert_equal 2, potential_action.size + assert_equal '🦊 View in GitLab', potential_action[0]['name'] + assert_equal '🦊 Create Issue in GitLab', potential_action[1]['name'] + end + + test 'should add other HTTParty options to params' do + options = { + webhook_url: 'http://localhost:8000', + username: 'Test Bot', + avatar: 'http://site.com/icon.png', + basic_auth: { + username: 'clara', + password: 'password' + } + } + teams_notifier = ExceptionNotifier::TeamsNotifier.new + teams_notifier.httparty = FakeHTTParty.new + + options = teams_notifier.call ArgumentError.new('foo'), options + + assert options.key? :basic_auth + assert 'clara', options[:basic_auth][:username] + assert 'password', options[:basic_auth][:password] + end + + test "should use 'A' for exceptions count if :accumulated_errors_count option is nil" do + teams_notifier = ExceptionNotifier::TeamsNotifier.new + exception = ArgumentError.new('foo') + teams_notifier.instance_variable_set(:@exception, exception) + teams_notifier.instance_variable_set(:@options, {}) + + message_text = teams_notifier.send(:message_text) + header = message_text['sections'][0] + assert_equal 'A *ArgumentError* occurred.', header['activityTitle'] + end + + test 'should use direct errors count if :accumulated_errors_count option is 5' do + teams_notifier = ExceptionNotifier::TeamsNotifier.new + exception = ArgumentError.new('foo') + teams_notifier.instance_variable_set(:@exception, exception) + teams_notifier.instance_variable_set(:@options, accumulated_errors_count: 5) + message_text = teams_notifier.send(:message_text) + header = message_text['sections'][0] + assert_equal '5 *ArgumentError* occurred.', header['activityTitle'] + end +end + +class FakeHTTParty + def post(_url, options) + options + end +end diff --git a/test/exception_notifier/webhook_notifier_test.rb b/test/exception_notifier/webhook_notifier_test.rb index 63f0b1c1..6aa0e69b 100644 --- a/test/exception_notifier/webhook_notifier_test.rb +++ b/test/exception_notifier/webhook_notifier_test.rb @@ -2,42 +2,41 @@ require 'httparty' class WebhookNotifierTest < ActiveSupport::TestCase - - test "should send webhook notification if properly configured" do + test 'should send webhook notification if properly configured' do ExceptionNotifier::WebhookNotifier.stubs(:new).returns(Object.new) - webhook = ExceptionNotifier::WebhookNotifier.new({:url => 'http://localhost:8000'}) + webhook = ExceptionNotifier::WebhookNotifier.new(url: 'http://localhost:8000') webhook.stubs(:call).returns(fake_response) response = webhook.call(fake_exception) refute_nil response assert_equal response[:status], 200 - assert_equal response[:body][:exception][:error_class], "ZeroDivisionError" - assert_includes response[:body][:exception][:message], "divided by 0" - assert_includes response[:body][:exception][:backtrace], "/exception_notification/test/webhook_notifier_test.rb:48" + assert_equal response[:body][:exception][:error_class], 'ZeroDivisionError' + assert_includes response[:body][:exception][:message], 'divided by 0' + assert_includes response[:body][:exception][:backtrace], '/exception_notification/test/webhook_notifier_test.rb:48' - assert response[:body][:request][:cookies].has_key?(:cookie_item1) - assert_equal response[:body][:request][:url], "http://example.com/example" - assert_equal response[:body][:request][:ip_address], "192.168.1.1" - assert response[:body][:request][:environment].has_key?(:env_item1) - assert_equal response[:body][:request][:controller], "#" - assert response[:body][:request][:session].has_key?(:session_item1) - assert response[:body][:request][:parameters].has_key?(:controller) - assert response[:body][:data][:extra_data].has_key?(:data_item1) + assert response[:body][:request][:cookies].key?(:cookie_item1) + assert_equal response[:body][:request][:url], 'http://example.com/example' + assert_equal response[:body][:request][:ip_address], '192.168.1.1' + assert response[:body][:request][:environment].key?(:env_item1) + assert_equal response[:body][:request][:controller], '#' + assert response[:body][:request][:session].key?(:session_item1) + assert response[:body][:request][:parameters].key?(:controller) + assert response[:body][:data][:extra_data].key?(:data_item1) end - test "should send webhook notification with correct params data" do + test 'should send webhook notification with correct params data' do url = 'http://localhost:8000' fake_exception.stubs(:backtrace).returns('the backtrace') - webhook = ExceptionNotifier::WebhookNotifier.new({:url => url}) + webhook = ExceptionNotifier::WebhookNotifier.new(url: url) HTTParty.expects(:send).with(:post, url, fake_params) webhook.call(fake_exception) end - test "should call pre/post_callback if specified" do + test 'should call pre/post_callback if specified' do HTTParty.stubs(:send).returns(fake_response) - webhook = ExceptionNotifier::WebhookNotifier.new({:url => 'http://localhost:8000'}) + webhook = ExceptionNotifier::WebhookNotifier.new(url: 'http://localhost:8000') webhook.call(fake_exception) end @@ -45,49 +44,52 @@ class WebhookNotifierTest < ActiveSupport::TestCase def fake_response { - :status => 200, - :body => { - :exception => { - :error_class => 'ZeroDivisionError', - :message => 'divided by 0', - :backtrace => '/exception_notification/test/webhook_notifier_test.rb:48:in `/' + status: 200, + body: { + exception: { + error_class: 'ZeroDivisionError', + message: 'divided by 0', + backtrace: '/exception_notification/test/webhook_notifier_test.rb:48:in `/' }, - :data => { - :extra_data => {:data_item1 => "datavalue1", :data_item2 => "datavalue2"} + data: { + extra_data: { data_item1: 'datavalue1', data_item2: 'datavalue2' } }, - :request => { - :cookies => {:cookie_item1 => 'cookieitemvalue1', :cookie_item2 => 'cookieitemvalue2'}, - :url => 'http://example.com/example', - :ip_address => '192.168.1.1', - :environment => {:env_item1 => "envitem1", :env_item2 => "envitem2"}, - :controller => '#', - :session => {:session_item1 => "sessionitem1", :session_item2 => "sessionitem2"}, - :parameters => {:action =>"index", :controller =>"projects"} + request: { + cookies: { cookie_item1: 'cookieitemvalue1', cookie_item2: 'cookieitemvalue2' }, + url: 'http://example.com/example', + ip_address: '192.168.1.1', + environment: { env_item1: 'envitem1', env_item2: 'envitem2' }, + controller: '#', + session: { session_item1: 'sessionitem1', session_item2: 'sessionitem2' }, + parameters: { action: 'index', controller: 'projects' } } } } end def fake_params - { - :body => { - :server => Socket.gethostname, - :process => $$, - :rails_root => Rails.root, - :exception => { - :error_class => 'ZeroDivisionError', - :message => 'divided by 0'.inspect, - :backtrace => 'the backtrace' + params = { + body: { + server: Socket.gethostname, + process: $PROCESS_ID, + exception: { + error_class: 'ZeroDivisionError', + message: 'divided by 0'.inspect, + backtrace: 'the backtrace' }, - :data => {} + data: {} } } + + params[:body][:rails_root] = Rails.root if defined?(::Rails) && Rails.respond_to?(:root) + + params end def fake_exception @fake_exception ||= begin - 5/0 - rescue Exception => e + 5 / 0 + rescue StandardError => e e end end diff --git a/test/exception_notifier_test.rb b/test/exception_notifier_test.rb index f1110e59..551ad38d 100644 --- a/test/exception_notifier_test.rb +++ b/test/exception_notifier_test.rb @@ -1,44 +1,47 @@ require 'test_helper' -class ExceptionOne < StandardError;end -class ExceptionTwo < StandardError;end +class ExceptionOne < StandardError; end +class ExceptionTwo < StandardError; end class ExceptionNotifierTest < ActiveSupport::TestCase setup do + ExceptionNotifier.register_exception_notifier(:email, exception_recipients: %w[dummyexceptions@example.com]) + @notifier_calls = 0 - @test_notifier = lambda { |exception, options| @notifier_calls += 1 } + @test_notifier = ->(_exception, _options) { @notifier_calls += 1 } end teardown do ExceptionNotifier.error_grouping = false ExceptionNotifier.notification_trigger = nil - ExceptionNotifier.class_eval("@@notifiers.delete_if { |k, _| k.to_s != \"email\"}") # reset notifiers - Rails.cache.clear + ExceptionNotifier.class_eval('@@notifiers.delete_if { |k, _| k.to_s != "email"}') # reset notifiers + + Rails.cache.clear if defined?(Rails) && Rails.respond_to?(:cache) end - test "should have default ignored exceptions" do + test 'should have default ignored exceptions' do assert_equal ExceptionNotifier.ignored_exceptions, - ['ActiveRecord::RecordNotFound', 'Mongoid::Errors::DocumentNotFound', 'AbstractController::ActionNotFound', - 'ActionController::RoutingError', 'ActionController::UnknownFormat', 'ActionController::UrlGenerationError'] + ['ActiveRecord::RecordNotFound', 'Mongoid::Errors::DocumentNotFound', 'AbstractController::ActionNotFound', + 'ActionController::RoutingError', 'ActionController::UnknownFormat', 'ActionController::UrlGenerationError'] end - test "should have email notifier registered" do + test 'should have email notifier registered' do assert_equal ExceptionNotifier.notifiers, [:email] end - test "should have a valid email notifier" do + test 'should have a valid email notifier' do @email_notifier = ExceptionNotifier.registered_exception_notifier(:email) refute_nil @email_notifier assert_equal @email_notifier.class, ExceptionNotifier::EmailNotifier assert_respond_to @email_notifier, :call end - test "should allow register/unregister another notifier" do + test 'should allow register/unregister another notifier' do called = false - proc_notifier = lambda { |exception, options| called = true } + proc_notifier = ->(_exception, _options) { called = true } ExceptionNotifier.register_exception_notifier(:proc, proc_notifier) - assert_equal ExceptionNotifier.notifiers.sort, [:email, :proc] + assert_equal ExceptionNotifier.notifiers.sort, %i[email proc] exception = StandardError.new @@ -49,27 +52,27 @@ class ExceptionNotifierTest < ActiveSupport::TestCase assert_equal ExceptionNotifier.notifiers, [:email] end - test "should allow select notifiers to send error to" do + test 'should allow select notifiers to send error to' do notifier1_calls = 0 - notifier1 = lambda { |exception, options| notifier1_calls += 1 } + notifier1 = ->(_exception, _options) { notifier1_calls += 1 } ExceptionNotifier.register_exception_notifier(:notifier1, notifier1) notifier2_calls = 0 - notifier2 = lambda { |exception, options| notifier2_calls += 1 } + notifier2 = ->(_exception, _options) { notifier2_calls += 1 } ExceptionNotifier.register_exception_notifier(:notifier2, notifier2) - assert_equal ExceptionNotifier.notifiers.sort, [:email, :notifier1, :notifier2] + assert_equal ExceptionNotifier.notifiers.sort, %i[email notifier1 notifier2] exception = StandardError.new ExceptionNotifier.notify_exception(exception) assert_equal notifier1_calls, 1 assert_equal notifier2_calls, 1 - ExceptionNotifier.notify_exception(exception, {:notifiers => :notifier1}) + ExceptionNotifier.notify_exception(exception, notifiers: :notifier1) assert_equal notifier1_calls, 2 assert_equal notifier2_calls, 1 - ExceptionNotifier.notify_exception(exception, {:notifiers => :notifier2}) + ExceptionNotifier.notify_exception(exception, notifiers: :notifier2) assert_equal notifier1_calls, 2 assert_equal notifier2_calls, 2 @@ -78,39 +81,68 @@ class ExceptionNotifierTest < ActiveSupport::TestCase assert_equal ExceptionNotifier.notifiers, [:email] end - test "should ignore exception if satisfies conditional ignore" do - env = "production" - ExceptionNotifier.ignore_if do |exception, options| - env != "production" + test 'should ignore exception if satisfies conditional ignore' do + env = 'production' + ExceptionNotifier.ignore_if do |_exception, _options| + env != 'production' end ExceptionNotifier.register_exception_notifier(:test, @test_notifier) exception = StandardError.new - ExceptionNotifier.notify_exception(exception, {:notifiers => :test}) + ExceptionNotifier.notify_exception(exception, notifiers: :test) assert_equal @notifier_calls, 1 - env = "development" - ExceptionNotifier.notify_exception(exception, {:notifiers => :test}) + env = 'development' + ExceptionNotifier.notify_exception(exception, notifiers: :test) assert_equal @notifier_calls, 1 ExceptionNotifier.clear_ignore_conditions! end - test "should not send notification if one of ignored exceptions" do + test 'should not send notification if one of ignored exceptions' do ExceptionNotifier.register_exception_notifier(:test, @test_notifier) exception = StandardError.new - ExceptionNotifier.notify_exception(exception, {:notifiers => :test}) + ExceptionNotifier.notify_exception(exception, notifiers: :test) + assert_equal @notifier_calls, 1 + + ExceptionNotifier.notify_exception(exception, notifiers: :test, ignore_exceptions: 'StandardError') + assert_equal @notifier_calls, 1 + end + + test 'should not send notification if subclass of one of ignored exceptions' do + ExceptionNotifier.register_exception_notifier(:test, @test_notifier) + + class StandardErrorSubclass < StandardError + end + + exception = StandardErrorSubclass.new + + ExceptionNotifier.notify_exception(exception, notifiers: :test) assert_equal @notifier_calls, 1 - ExceptionNotifier.notify_exception(exception, {:notifiers => :test, :ignore_exceptions => 'StandardError' }) + ExceptionNotifier.notify_exception(exception, notifiers: :test, ignore_exceptions: 'StandardError') assert_equal @notifier_calls, 1 end - test "should not call group_error! or send_notification? if error_grouping false" do + test 'should call received block' do + @block_called = false + notifier = ->(_exception, _options, &block) { block.call } + ExceptionNotifier.register_exception_notifier(:test, notifier) + + exception = ExceptionOne.new + + ExceptionNotifier.notify_exception(exception) do + @block_called = true + end + + assert @block_called + end + + test 'should not call group_error! or send_notification? if error_grouping false' do exception = StandardError.new ExceptionNotifier.expects(:group_error!).never ExceptionNotifier.expects(:send_notification?).never @@ -118,7 +150,7 @@ class ExceptionNotifierTest < ActiveSupport::TestCase ExceptionNotifier.notify_exception(exception) end - test "should call group_error! and send_notification? if error_grouping true" do + test 'should call group_error! and send_notification? if error_grouping true' do ExceptionNotifier.error_grouping = true exception = StandardError.new @@ -128,7 +160,7 @@ class ExceptionNotifierTest < ActiveSupport::TestCase ExceptionNotifier.notify_exception(exception) end - test "should skip notification if send_notification? is false" do + test 'should skip notification if send_notification? is false' do ExceptionNotifier.error_grouping = true exception = StandardError.new @@ -138,7 +170,7 @@ class ExceptionNotifierTest < ActiveSupport::TestCase refute ExceptionNotifier.notify_exception(exception) end - test "should send notification if send_notification? is true" do + test 'should send notification if send_notification? is true' do ExceptionNotifier.error_grouping = true exception = StandardError.new diff --git a/test/dummy/app/views/exception_notifier/_new_bkg_section.html.erb b/test/support/views/exception_notifier/_new_bkg_section.html.erb similarity index 100% rename from test/dummy/app/views/exception_notifier/_new_bkg_section.html.erb rename to test/support/views/exception_notifier/_new_bkg_section.html.erb diff --git a/test/dummy/app/views/exception_notifier/_new_bkg_section.text.erb b/test/support/views/exception_notifier/_new_bkg_section.text.erb similarity index 100% rename from test/dummy/app/views/exception_notifier/_new_bkg_section.text.erb rename to test/support/views/exception_notifier/_new_bkg_section.text.erb diff --git a/test/dummy/app/views/exception_notifier/_new_section.html.erb b/test/support/views/exception_notifier/_new_section.html.erb similarity index 100% rename from test/dummy/app/views/exception_notifier/_new_section.html.erb rename to test/support/views/exception_notifier/_new_section.html.erb diff --git a/test/dummy/app/views/exception_notifier/_new_section.text.erb b/test/support/views/exception_notifier/_new_section.text.erb similarity index 100% rename from test/dummy/app/views/exception_notifier/_new_section.text.erb rename to test/support/views/exception_notifier/_new_section.text.erb diff --git a/test/test_helper.rb b/test/test_helper.rb index 9b1f858c..dc590da3 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,18 +1,15 @@ -# Configure Rails Environment -ENV["RAILS_ENV"] = "test" +require 'coveralls' +Coveralls.wear! -begin - require "coveralls" - Coveralls.wear! -rescue LoadError - warn "warning: coveralls gem not found; skipping Coveralls" -end +$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) +require 'exception_notification' -require File.expand_path("../dummy/config/environment.rb", __FILE__) -require "rails/test_help" -require File.expand_path("../dummy/test/test_helper.rb", __FILE__) +require 'minitest/autorun' +require 'mocha/minitest' +require 'active_support/test_case' +require 'action_mailer' -require "mocha/setup" - -Rails.backtrace_cleaner.remove_silencers! ExceptionNotifier.testing_mode! +Time.zone = 'UTC' +ActionMailer::Base.delivery_method = :test +ActionMailer::Base.append_view_path "#{File.dirname(__FILE__)}/support/views"