Commit d015914f11243a333d1b3717d03711e5bfaec92f

Authored by Stephen Crosby
2 parents b03a0e45 bff20d76
Exists in master and in 1 other branch production

Merge remote-tracking branch 'errbit/master' into update_deployment_config

Conflicts:
	config/deploy.example.rb
@@ -18,5 +18,4 @@ MONGO_URL='mongodb://localhost' @@ -18,5 +18,4 @@ MONGO_URL='mongodb://localhost'
18 GITHUB_URL='https://github.com' 18 GITHUB_URL='https://github.com'
19 GITHUB_AUTHENTICATION=true 19 GITHUB_AUTHENTICATION=true
20 GITHUB_ACCESS_SCOPE='[repo]' 20 GITHUB_ACCESS_SCOPE='[repo]'
21 -EMAIL_DELIVERY_METHOD=sendmail  
22 DEVISE_MODULES='[database_authenticatable,recoverable,rememberable,trackable,validatable,omniauthable]' 21 DEVISE_MODULES='[database_authenticatable,recoverable,rememberable,trackable,validatable,omniauthable]'
1 GIT 1 GIT
2 remote: git://github.com/errbit/errbit_github_plugin.git 2 remote: git://github.com/errbit/errbit_github_plugin.git
3 - revision: 5900200e6d460fe94feaea3e59824437d54f1029 3 + revision: 7eb48d540b94d2fa4b8f9805ec0148322ae2bffe
4 specs: 4 specs:
5 errbit_github_plugin (0.1.0) 5 errbit_github_plugin (0.1.0)
6 errbit_plugin 6 errbit_plugin
app/controllers/users/omniauth_callbacks_controller.rb
@@ -8,7 +8,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController @@ -8,7 +8,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
8 # See if they are a member of the organization that we have access for 8 # See if they are a member of the organization that we have access for
9 # If they are, automatically create an account 9 # If they are, automatically create an account
10 client = Octokit::Client.new(access_token: github_token) 10 client = Octokit::Client.new(access_token: github_token)
11 - org_ids = client.organizations.map { |org| org.id.to_s } 11 + org_ids = client.organizations.map { |org| org.id }
12 if org_ids.include?(github_org_id) 12 if org_ids.include?(github_org_id)
13 github_user = User.create(name: env["omniauth.auth"].extra.raw_info.name, email: env["omniauth.auth"].extra.raw_info.email) 13 github_user = User.create(name: env["omniauth.auth"].extra.raw_info.name, email: env["omniauth.auth"].extra.raw_info.email)
14 end 14 end
app/models/issue.rb
@@ -2,15 +2,21 @@ class Issue @@ -2,15 +2,21 @@ class Issue
2 include ActiveModel::Model 2 include ActiveModel::Model
3 attr_accessor :problem, :user, :title, :body 3 attr_accessor :problem, :user, :title, :body
4 4
5 - def intialize(problem: nil, user: nil, title: nil, body: nil)  
6 - @problem, @user, @title, @body = problem, user, title, body  
7 - end  
8 -  
9 def issue_tracker 5 def issue_tracker
10 problem.app.issue_tracker 6 problem.app.issue_tracker
11 end 7 end
12 8
13 def save 9 def save
  10 + unless body
  11 + errors.add :base, "The issue has no body"
  12 + return false
  13 + end
  14 +
  15 + unless title
  16 + errors.add :base, "The issue has no title"
  17 + return false
  18 + end
  19 +
14 if issue_tracker 20 if issue_tracker
15 issue_tracker.tracker.errors.each do |k, err| 21 issue_tracker.tracker.errors.each do |k, err|
16 errors.add k, err 22 errors.add k, err
app/models/issue_tracker.rb
@@ -13,6 +13,7 @@ class IssueTracker @@ -13,6 +13,7 @@ class IssueTracker
13 @tracker ||= 13 @tracker ||=
14 begin 14 begin
15 klass = ErrbitPlugin::Registry.issue_trackers[self.type_tracker] || ErrbitPlugin::NoneIssueTracker 15 klass = ErrbitPlugin::Registry.issue_trackers[self.type_tracker] || ErrbitPlugin::NoneIssueTracker
  16 + # TODO: we need to find out a better way to pass those config to the issue tracker
16 klass.new(options.merge(github_repo: app.github_repo, bitbucket_repo: app.bitbucket_repo)) 17 klass.new(options.merge(github_repo: app.github_repo, bitbucket_repo: app.bitbucket_repo))
17 end 18 end
18 end 19 end
app/views/issue_trackers/bitbucket_issues_body.txt.erb
@@ -1,58 +0,0 @@ @@ -1,58 +0,0 @@
1 -[[<%= app_problem_url problem.app, problem %>| [See this exception on Errbit]]]  
2 -  
3 -----  
4 -  
5 -<% if notice = problem.notices.first %>  
6 - <%= notice.message %>  
7 -  
8 -----  
9 -  
10 - == Summary ==  
11 - <% if notice.request['url'].present? %>  
12 - === URL ===  
13 - [[<%= notice.request['url'] %>]]  
14 - <% end %>  
15 -  
16 -----  
17 -  
18 - === Where ===  
19 - <%= notice.where %>  
20 -  
21 -----  
22 -  
23 - === Occured ===  
24 - <%= notice.created_at.to_s(:micro) %>  
25 -  
26 -----  
27 -  
28 - === Similar ===  
29 - <%= (notice.problem.notices_count - 1).to_s %>  
30 -  
31 -----  
32 -  
33 - == Params ==  
34 -{{{  
35 -<%= pretty_hash(notice.params) %>  
36 -}}}  
37 -  
38 -----  
39 -  
40 - == Session ==  
41 -{{{  
42 -<%= pretty_hash(notice.session) %>  
43 -}}}  
44 -  
45 -----  
46 -  
47 - == Backtrace ==  
48 - <% notice.backtrace_lines.each do |line| %>| <%= line['number'] %>: | <%= line['file'].to_s.sub(/^\[PROJECT_ROOT\]/, '') %> -> **<%= line['method'] %>** |  
49 - <% end %>  
50 -  
51 -----  
52 -  
53 - == Environment ==  
54 - <% for key, val in notice.env_vars %>  
55 - | <%= key %>: | <%= val %> |  
56 - <% end %>  
57 -<% end %>  
58 -  
app/views/issue_trackers/fogbugz_body.txt.erb
@@ -1,31 +0,0 @@ @@ -1,31 +0,0 @@
1 -"See this exception on Errbit": <%= app_problem_url(problem.app, problem) %>  
2 -<% if notice = problem.notices.first %>  
3 - <%= notice.message %>  
4 -  
5 - Summary  
6 - - Where  
7 - <%= notice.where %>  
8 -  
9 - - Occured  
10 - <%= notice.created_at.to_s(:micro) %>  
11 -  
12 - - Similar  
13 - <%= (notice.problem.notices_count - 1).to_s %>  
14 -  
15 - Params  
16 - <%= pretty_hash(notice.params) %>  
17 -  
18 - Session  
19 - <%= pretty_hash(notice.session) %>  
20 -  
21 - Backtrace  
22 - <% notice.backtrace_lines.each do |line| %>  
23 - <%= line.number %>: <%= line.file_relative %>  
24 - <% end %>  
25 -  
26 - Environment  
27 - <% for key, val in notice.env_vars %>  
28 - <%= key %>: <%= val %>  
29 - <% end %>  
30 -<% end %>  
31 -  
app/views/issue_trackers/github_issues_body.txt.erb
@@ -1,45 +0,0 @@ @@ -1,45 +0,0 @@
1 -[See this exception on Errbit](<%= app_problem_url problem.app, problem %> "See this exception on Errbit")  
2 -<% if notice = problem.notices.first %>  
3 -# <%= notice.message %> #  
4 -## Summary ##  
5 -<% if notice.request['url'].present? %>  
6 - ### URL ###  
7 - [<%= notice.request['url'] %>](<%= notice.request['url'] %>)"  
8 -<% end %>  
9 -### Where ###  
10 -<%= notice.where %>  
11 -  
12 -### Occured ###  
13 -<%= notice.created_at.to_s(:micro) %>  
14 -  
15 -### Similar ###  
16 -<%= (notice.problem.notices_count - 1).to_s %>  
17 -  
18 -## Params ##  
19 -```  
20 -<%= pretty_hash(notice.params) %>  
21 -```  
22 -  
23 -## Session ##  
24 -```  
25 -<%= pretty_hash(notice.session) %>  
26 -```  
27 -  
28 -## Backtrace ##  
29 -```  
30 -<% notice.backtrace_lines.each do |line| %><%= line.number %>: <%= line.file_relative %> -> **<%= line.method %>**  
31 -<% end %>  
32 -```  
33 -  
34 -## Environment ##  
35 -  
36 -<table>  
37 -<% for key, val in notice.env_vars %>  
38 - <tr>  
39 - <td><%= key %>:</td>  
40 - <td><%= val %></td>  
41 - </tr>  
42 -<% end %>  
43 -</table>  
44 -<% end %>  
45 -  
app/views/issue_trackers/gitlab_body.txt.erb
@@ -1,29 +0,0 @@ @@ -1,29 +0,0 @@
1 -<% if notice = problem.notices.first %>  
2 -## Params ##  
3 -```  
4 -<%= pretty_hash(notice.params) %>  
5 -```  
6 -  
7 -## Session ##  
8 -```  
9 -<%= pretty_hash(notice.session) %>  
10 -```  
11 -  
12 -## Backtrace ##  
13 -```  
14 -<% notice.backtrace_lines.each do |line| %><%= line.number %>: <%= line.file_relative %> -> **<%= line.method %>**  
15 -<% end %>  
16 -```  
17 -  
18 -## Environment ##  
19 -  
20 -<table>  
21 -<% for key, val in notice.env_vars %>  
22 - <tr>  
23 - <td><%= key %>:</td>  
24 - <td><%= val %></td>  
25 - </tr>  
26 -<% end %>  
27 -</table>  
28 -<% end %>  
29 -  
app/views/issue_trackers/gitlab_summary.txt.erb
@@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
1 -[See this exception on Errbit](<%= app_problem_url problem.app, problem %> "See this exception on Errbit")  
2 -<% if notice = problem.notices.first %>  
3 -# <%= notice.message %> #  
4 -## Summary ##  
5 -<% if notice.request['url'].present? %>  
6 - ### URL ###  
7 - [<%= notice.request['url'] %>](<%= notice.request['url'] %>)"  
8 -<% end %>  
9 -### Where ###  
10 -<%= notice.where %>  
11 -  
12 -### Occured ###  
13 -<%= notice.created_at.to_s(:micro) %>  
14 -  
15 -### Similar ###  
16 -<%= (notice.problem.notices_count - 1).to_s %>  
17 -<% end %>  
18 \ No newline at end of file 0 \ No newline at end of file
app/views/issue_trackers/jira_body.txt.erb
@@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
1 -<% if notice = problem.notices.first %>  
2 -h2. Summary  
3 -<% if notice.request['url'].present? %>  
4 -h3. URL  
5 -  
6 -"<%= notice.request['url'] %>":<%= notice.request['url'] %>  
7 -<% end %>  
8 -h3. Where  
9 -  
10 -<%= notice.where %>  
11 -  
12 -h3. When  
13 -  
14 -<%= notice.created_at.to_s(:micro) %>  
15 -  
16 -"More Details on Errbit":<%= app_problem_url problem.app, problem %>  
17 -<% end %>  
18 \ No newline at end of file 0 \ No newline at end of file
app/views/issue_trackers/lighthouseapp_body.txt.erb
@@ -1,35 +0,0 @@ @@ -1,35 +0,0 @@
1 -[See this exception on Errbit](<%= app_problem_url problem.app, problem %> "See this exception on Errbit")  
2 -<% if notice = problem.notices.first %>  
3 - # <%= notice.message %> #  
4 - ## Summary ##  
5 - <% if notice.request['url'].present? %>  
6 - ### URL ###  
7 - [<%= notice.request['url'] %>](<%= notice.request['url'] %>)"  
8 - <% end %>  
9 - ### Where ###  
10 - <%= notice.where %>  
11 -  
12 - ### Occured ###  
13 - <%= notice.created_at.to_s(:micro) %>  
14 -  
15 - ### Similar ###  
16 - <%= (notice.problem.notices_count - 1).to_s %>  
17 -  
18 - ## Params ##  
19 - <code><%= pretty_hash(notice.params) %></code>  
20 -  
21 - ## Session ##  
22 - <code><%= pretty_hash(notice.session) %></code>  
23 -  
24 - ## Backtrace ##  
25 - <code>  
26 - <% notice.backtrace_lines.each do |line| %><%= line.number %>: <%= line.file_relative %> -> **<%= line.method %>**  
27 - <% end %>  
28 - </code>  
29 -  
30 - ## Environment ##  
31 - <% for key, val in notice.env_vars %>  
32 - <%= key %>: <%= val %>  
33 - <% end %>  
34 -<% end %>  
35 -  
app/views/issue_trackers/pivotal_body.txt.erb
@@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
1 -See this exception on Errbit: <%= app_problem_url problem.app, problem %>  
2 -<% if notice = problem.notices.first %>  
3 - <% if notice.request['url'].present? %>URL: <%= notice.request['url'] %><% end %>  
4 - Where: <%= notice.where %>  
5 - Occurred: <%= notice.created_at.to_s :micro %>  
6 - Similar: <%= (notice.problem.notices_count - 1).to_s %>  
7 -  
8 - Params:  
9 - <%= pretty_hash notice.params %>  
10 -  
11 - Session:  
12 - <%= pretty_hash notice.session %>  
13 -  
14 - Backtrace:  
15 - <%= notice.backtrace_lines[0..4].map { |line| "#{line.number}: #{line.file_relative} -> *#{line.method}*" }.join "\n" %>  
16 -<% end %>  
17 -  
app/views/issue_trackers/redmine_body.txt.erb
@@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
1 -<% if notice = problem.notices.first %>  
2 -h2. Summary  
3 -<% if notice.request['url'].present? %>  
4 -h3. URL  
5 -  
6 -"<%= notice.request['url'] %>":<%= notice.request['url'] %>  
7 -<% end %>  
8 -h3. Where  
9 -  
10 -<%= notice.where %>  
11 -  
12 -h3. When  
13 -  
14 -<%= notice.created_at.to_s(:micro) %>  
15 -  
16 -"More Details on Errbit":<%= app_problem_url problem.app, problem %>  
17 -<% end %>  
18 \ No newline at end of file 0 \ No newline at end of file
app/views/issue_trackers/textile_body.txt.erb
@@ -1,44 +0,0 @@ @@ -1,44 +0,0 @@
1 -<% if notice = problem.notices.first %>  
2 -h1. <%= notice.message %>  
3 -  
4 -h3. "See this exception on Errbit":<%= app_problem_url problem.app, problem %>  
5 -  
6 -h2. Summary  
7 -<% if notice.request['url'].present? %>  
8 -h3. URL  
9 -  
10 -"<%= notice.request['url'] %>":<%= notice.request['url'] %>  
11 -<% end %>  
12 -h3. Where  
13 -  
14 -<%= notice.where %>  
15 -  
16 -h3. Occurred  
17 -  
18 -<%= notice.created_at.to_s(:micro) %>  
19 -  
20 -h3. Similar  
21 -  
22 -<%= (notice.problem.notices_count - 1).to_s %>  
23 -  
24 -h2. Params  
25 -  
26 -<pre><%= pretty_hash(notice.params) %></pre>  
27 -  
28 -h2. Session  
29 -  
30 -<pre><%= pretty_hash(notice.session) %></pre>  
31 -  
32 -h2. Backtrace  
33 -  
34 -| Line | File | Method |  
35 -<% notice.backtrace_lines.each do |line| %>| <%= line.number %> | <%= line.file_relative %> | *<%= line.method %>* |  
36 -<% end %>  
37 -  
38 -h2. Environment  
39 -  
40 -<% for key, val in notice.env_vars %>| <%= key %> | <%= val %> |  
41 -<% end %>  
42 -  
43 -<% end %>  
44 -  
  1 +require 'securerandom'
  2 +
1 puts "Seeding database" 3 puts "Seeding database"
2 puts "-------------------------------" 4 puts "-------------------------------"
3 5
4 # Create an initial Admin User 6 # Create an initial Admin User
5 admin_username = "errbit" 7 admin_username = "errbit"
6 admin_email = "errbit@#{Errbit::Config.host}" 8 admin_email = "errbit@#{Errbit::Config.host}"
7 -admin_pass = 'password' 9 +admin_pass = SecureRandom.urlsafe_base64(12)[0,12]
8 10
9 puts "Creating an initial admin user:" 11 puts "Creating an initial admin user:"
10 puts "-- username: #{admin_username}" if Errbit::Config.user_has_username 12 puts "-- username: #{admin_username}" if Errbit::Config.user_has_username
11 puts "-- email: #{admin_email}" 13 puts "-- email: #{admin_email}"
12 puts "-- password: #{admin_pass}" 14 puts "-- password: #{admin_pass}"
13 puts "" 15 puts ""
14 -puts "Be sure to change these credentials ASAP!" 16 +puts "Be sure to note down these credentials now!"
15 user = User.find_or_initialize_by(:email => admin_email) do |u| 17 user = User.find_or_initialize_by(:email => admin_email) do |u|
16 u.name = 'Errbit Admin' 18 u.name = 'Errbit Admin'
17 u.password = admin_pass 19 u.password = admin_pass
docs/configuration.md
@@ -74,8 +74,7 @@ In order of precedence Errbit uses: @@ -74,8 +74,7 @@ In order of precedence Errbit uses:
74 <dd>OAuth scope to request from users when they sign-in through github 74 <dd>OAuth scope to request from users when they sign-in through github
75 <dd>defaults to [repo] 75 <dd>defaults to [repo]
76 <dt>EMAIL_DELIVERY_METHOD 76 <dt>EMAIL_DELIVERY_METHOD
77 -<dd>SMTP or sendmail, depending on how you want Errbit to send email  
78 -<dd>defaults to sendmail 77 +<dd>:smtp or :sendmail, depending on how you want Errbit to send email
79 <dt>SMTP_SERVER 78 <dt>SMTP_SERVER
80 <dd>Server address for outgoing SMTP messages 79 <dd>Server address for outgoing SMTP messages
81 <dt>SMTP_PORT 80 <dt>SMTP_PORT
lib/airbrake_api/v3/notice_parser.rb
@@ -32,7 +32,7 @@ module AirbrakeApi @@ -32,7 +32,7 @@ module AirbrakeApi
32 end 32 end
33 33
34 def backtrace 34 def backtrace
35 - error['backtrace'].map do |backtrace_line| 35 + (error['backtrace'] || []).map do |backtrace_line|
36 { 36 {
37 method: backtrace_line['function'], 37 method: backtrace_line['function'],
38 file: backtrace_line['file'], 38 file: backtrace_line['file'],
public/javascripts/notifier.js
@@ -1,1218 +0,0 @@ @@ -1,1218 +0,0 @@
1 -// Airbrake JavaScript Notifier Bundle  
2 -(function(window, document, undefined) {  
3 -// Domain Public by Eric Wendelin http://eriwen.com/ (2008)  
4 -// Luke Smith http://lucassmith.name/ (2008)  
5 -// Loic Dachary <loic@dachary.org> (2008)  
6 -// Johan Euphrosine <proppy@aminche.com> (2008)  
7 -// Øyvind Sean Kinsey http://kinsey.no/blog (2010)  
8 -// Victor Homyakov (2010)  
9 -//  
10 -// Information and discussions  
11 -// http://jspoker.pokersource.info/skin/test-printstacktrace.html  
12 -// http://eriwen.com/javascript/js-stack-trace/  
13 -// http://eriwen.com/javascript/stacktrace-update/  
14 -// http://pastie.org/253058  
15 -//  
16 -// guessFunctionNameFromLines comes from firebug  
17 -//  
18 -// Software License Agreement (BSD License)  
19 -//  
20 -// Copyright (c) 2007, Parakey Inc.  
21 -// All rights reserved.  
22 -//  
23 -// Redistribution and use of this software in source and binary forms, with or without modification,  
24 -// are permitted provided that the following conditions are met:  
25 -//  
26 -// * Redistributions of source code must retain the above  
27 -// copyright notice, this list of conditions and the  
28 -// following disclaimer.  
29 -//  
30 -// * Redistributions in binary form must reproduce the above  
31 -// copyright notice, this list of conditions and the  
32 -// following disclaimer in the documentation and/or other  
33 -// materials provided with the distribution.  
34 -//  
35 -// * Neither the name of Parakey Inc. nor the names of its  
36 -// contributors may be used to endorse or promote products  
37 -// derived from this software without specific prior  
38 -// written permission of Parakey Inc.  
39 -//  
40 -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR  
41 -// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND  
42 -// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR  
43 -// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL  
44 -// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,  
45 -// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER  
46 -// IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT  
47 -// OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.  
48 -  
49 -/**  
50 - * Main function giving a function stack trace with a forced or passed in Error  
51 - *  
52 - * @cfg {Error} e The error to create a stacktrace from (optional)  
53 - * @cfg {Boolean} guess If we should try to resolve the names of anonymous functions  
54 - * @return {Array} of Strings with functions, lines, files, and arguments where possible  
55 - */  
56 -function printStackTrace(options) {  
57 - options = options || {guess: true};  
58 - var ex = options.e || null, guess = !!options.guess;  
59 - var p = new printStackTrace.implementation(), result = p.run(ex);  
60 - return (guess) ? p.guessAnonymousFunctions(result) : result;  
61 -}  
62 -  
63 -if (typeof module !== "undefined" && module.exports) {  
64 - module.exports = printStackTrace;  
65 -}  
66 -  
67 -printStackTrace.implementation = function() {  
68 -};  
69 -  
70 -printStackTrace.implementation.prototype = {  
71 - /**  
72 - * @param {Error} ex The error to create a stacktrace from (optional)  
73 - * @param {String} mode Forced mode (optional, mostly for unit tests)  
74 - */  
75 - run: function(ex, mode) {  
76 - ex = ex || this.createException();  
77 - // examine exception properties w/o debugger  
78 - //for (var prop in ex) {alert("Ex['" + prop + "']=" + ex[prop]);}  
79 - mode = mode || this.mode(ex);  
80 - if (mode === 'other') {  
81 - return this.other(arguments.callee);  
82 - } else {  
83 - return this[mode](ex);  
84 - }  
85 - },  
86 -  
87 - createException: function() {  
88 - try {  
89 - this.undef();  
90 - } catch (e) {  
91 - return e;  
92 - }  
93 - },  
94 -  
95 - /**  
96 - * Mode could differ for different exception, e.g.  
97 - * exceptions in Chrome may or may not have arguments or stack.  
98 - *  
99 - * @return {String} mode of operation for the exception  
100 - */  
101 - mode: function(e) {  
102 - if (e['arguments'] && e.stack) {  
103 - return 'chrome';  
104 - } else if (e.stack && e.sourceURL) {  
105 - return 'safari';  
106 - } else if (e.stack && e.number) {  
107 - return 'ie';  
108 - } else if (typeof e.message === 'string' && typeof window !== 'undefined' && window.opera) {  
109 - // e.message.indexOf("Backtrace:") > -1 -> opera  
110 - // !e.stacktrace -> opera  
111 - if (!e.stacktrace) {  
112 - return 'opera9'; // use e.message  
113 - }  
114 - // 'opera#sourceloc' in e -> opera9, opera10a  
115 - if (e.message.indexOf('\n') > -1 && e.message.split('\n').length > e.stacktrace.split('\n').length) {  
116 - return 'opera9'; // use e.message  
117 - }  
118 - // e.stacktrace && !e.stack -> opera10a  
119 - if (!e.stack) {  
120 - return 'opera10a'; // use e.stacktrace  
121 - }  
122 - // e.stacktrace && e.stack -> opera10b  
123 - if (e.stacktrace.indexOf("called from line") < 0) {  
124 - return 'opera10b'; // use e.stacktrace, format differs from 'opera10a'  
125 - }  
126 - // e.stacktrace && e.stack -> opera11  
127 - return 'opera11'; // use e.stacktrace, format differs from 'opera10a', 'opera10b'  
128 - } else if (e.stack && !e.fileName) {  
129 - // Chrome 27 does not have e.arguments as earlier versions,  
130 - // but still does not have e.fileName as Firefox  
131 - return 'chrome';  
132 - } else if (e.stack) {  
133 - return 'firefox';  
134 - }  
135 - return 'other';  
136 - },  
137 -  
138 - /**  
139 - * Given a context, function name, and callback function, overwrite it so that it calls  
140 - * printStackTrace() first with a callback and then runs the rest of the body.  
141 - *  
142 - * @param {Object} context of execution (e.g. window)  
143 - * @param {String} functionName to instrument  
144 - * @param {Function} callback function to call with a stack trace on invocation  
145 - */  
146 - instrumentFunction: function(context, functionName, callback) {  
147 - context = context || window;  
148 - var original = context[functionName];  
149 - context[functionName] = function instrumented() {  
150 - callback.call(this, printStackTrace().slice(4));  
151 - return context[functionName]._instrumented.apply(this, arguments);  
152 - };  
153 - context[functionName]._instrumented = original;  
154 - },  
155 -  
156 - /**  
157 - * Given a context and function name of a function that has been  
158 - * instrumented, revert the function to it's original (non-instrumented)  
159 - * state.  
160 - *  
161 - * @param {Object} context of execution (e.g. window)  
162 - * @param {String} functionName to de-instrument  
163 - */  
164 - deinstrumentFunction: function(context, functionName) {  
165 - if (context[functionName].constructor === Function &&  
166 - context[functionName]._instrumented &&  
167 - context[functionName]._instrumented.constructor === Function) {  
168 - context[functionName] = context[functionName]._instrumented;  
169 - }  
170 - },  
171 -  
172 - /**  
173 - * Given an Error object, return a formatted Array based on Chrome's stack string.  
174 - *  
175 - * @param e - Error object to inspect  
176 - * @return Array<String> of function calls, files and line numbers  
177 - */  
178 - chrome: function(e) {  
179 - var stack = (e.stack + '\n').replace(/^\S[^\(]+?[\n$]/gm, '').  
180 - replace(/^\s+(at eval )?at\s+/gm, '').  
181 - replace(/^([^\(]+?)([\n$])/gm, '{anonymous}()@$1$2').  
182 - replace(/^Object.<anonymous>\s*\(([^\)]+)\)/gm, '{anonymous}()@$1').split('\n');  
183 - stack.pop();  
184 - return stack;  
185 - },  
186 -  
187 - /**  
188 - * Given an Error object, return a formatted Array based on Safari's stack string.  
189 - *  
190 - * @param e - Error object to inspect  
191 - * @return Array<String> of function calls, files and line numbers  
192 - */  
193 - safari: function(e) {  
194 - return e.stack.replace(/\[native code\]\n/m, '')  
195 - .replace(/^(?=\w+Error\:).*$\n/m, '')  
196 - .replace(/^@/gm, '{anonymous}()@')  
197 - .split('\n');  
198 - },  
199 -  
200 - /**  
201 - * Given an Error object, return a formatted Array based on IE's stack string.  
202 - *  
203 - * @param e - Error object to inspect  
204 - * @return Array<String> of function calls, files and line numbers  
205 - */  
206 - ie: function(e) {  
207 - var lineRE = /^.*at (\w+) \(([^\)]+)\)$/gm;  
208 - return e.stack.replace(/at Anonymous function /gm, '{anonymous}()@')  
209 - .replace(/^(?=\w+Error\:).*$\n/m, '')  
210 - .replace(lineRE, '$1@$2')  
211 - .split('\n');  
212 - },  
213 -  
214 - /**  
215 - * Given an Error object, return a formatted Array based on Firefox's stack string.  
216 - *  
217 - * @param e - Error object to inspect  
218 - * @return Array<String> of function calls, files and line numbers  
219 - */  
220 - firefox: function(e) {  
221 - return e.stack.replace(/(?:\n@:0)?\s+$/m, '').replace(/^[\(@]/gm, '{anonymous}()@').split('\n');  
222 - },  
223 -  
224 - opera11: function(e) {  
225 - var ANON = '{anonymous}', lineRE = /^.*line (\d+), column (\d+)(?: in (.+))? in (\S+):$/;  
226 - var lines = e.stacktrace.split('\n'), result = [];  
227 -  
228 - for (var i = 0, len = lines.length; i < len; i += 2) {  
229 - var match = lineRE.exec(lines[i]);  
230 - if (match) {  
231 - var location = match[4] + ':' + match[1] + ':' + match[2];  
232 - var fnName = match[3] || "global code";  
233 - fnName = fnName.replace(/<anonymous function: (\S+)>/, "$1").replace(/<anonymous function>/, ANON);  
234 - result.push(fnName + '@' + location + ' -- ' + lines[i + 1].replace(/^\s+/, ''));  
235 - }  
236 - }  
237 -  
238 - return result;  
239 - },  
240 -  
241 - opera10b: function(e) {  
242 - // "<anonymous function: run>([arguments not available])@file://localhost/G:/js/stacktrace.js:27\n" +  
243 - // "printStackTrace([arguments not available])@file://localhost/G:/js/stacktrace.js:18\n" +  
244 - // "@file://localhost/G:/js/test/functional/testcase1.html:15"  
245 - var lineRE = /^(.*)@(.+):(\d+)$/;  
246 - var lines = e.stacktrace.split('\n'), result = [];  
247 -  
248 - for (var i = 0, len = lines.length; i < len; i++) {  
249 - var match = lineRE.exec(lines[i]);  
250 - if (match) {  
251 - var fnName = match[1]? (match[1] + '()') : "global code";  
252 - result.push(fnName + '@' + match[2] + ':' + match[3]);  
253 - }  
254 - }  
255 -  
256 - return result;  
257 - },  
258 -  
259 - /**  
260 - * Given an Error object, return a formatted Array based on Opera 10's stacktrace string.  
261 - *  
262 - * @param e - Error object to inspect  
263 - * @return Array<String> of function calls, files and line numbers  
264 - */  
265 - opera10a: function(e) {  
266 - // " Line 27 of linked script file://localhost/G:/js/stacktrace.js\n"  
267 - // " Line 11 of inline#1 script in file://localhost/G:/js/test/functional/testcase1.html: In function foo\n"  
268 - var ANON = '{anonymous}', lineRE = /Line (\d+).*script (?:in )?(\S+)(?:: In function (\S+))?$/i;  
269 - var lines = e.stacktrace.split('\n'), result = [];  
270 -  
271 - for (var i = 0, len = lines.length; i < len; i += 2) {  
272 - var match = lineRE.exec(lines[i]);  
273 - if (match) {  
274 - var fnName = match[3] || ANON;  
275 - result.push(fnName + '()@' + match[2] + ':' + match[1] + ' -- ' + lines[i + 1].replace(/^\s+/, ''));  
276 - }  
277 - }  
278 -  
279 - return result;  
280 - },  
281 -  
282 - // Opera 7.x-9.2x only!  
283 - opera9: function(e) {  
284 - // " Line 43 of linked script file://localhost/G:/js/stacktrace.js\n"  
285 - // " Line 7 of inline#1 script in file://localhost/G:/js/test/functional/testcase1.html\n"  
286 - var ANON = '{anonymous}', lineRE = /Line (\d+).*script (?:in )?(\S+)/i;  
287 - var lines = e.message.split('\n'), result = [];  
288 -  
289 - for (var i = 2, len = lines.length; i < len; i += 2) {  
290 - var match = lineRE.exec(lines[i]);  
291 - if (match) {  
292 - result.push(ANON + '()@' + match[2] + ':' + match[1] + ' -- ' + lines[i + 1].replace(/^\s+/, ''));  
293 - }  
294 - }  
295 -  
296 - return result;  
297 - },  
298 -  
299 - // Safari 5-, IE 9-, and others  
300 - other: function(curr) {  
301 - var ANON = '{anonymous}', fnRE = /function\s*([\w\-$]+)?\s*\(/i, stack = [], fn, args, maxStackSize = 10;  
302 - while (curr && curr['arguments'] && stack.length < maxStackSize) {  
303 - fn = fnRE.test(curr.toString()) ? RegExp.$1 || ANON : ANON;  
304 - args = Array.prototype.slice.call(curr['arguments'] || []);  
305 - stack[stack.length] = fn + '(' + this.stringifyArguments(args) + ')';  
306 - curr = curr.caller;  
307 - }  
308 - return stack;  
309 - },  
310 -  
311 - /**  
312 - * Given arguments array as a String, substituting type names for non-string types.  
313 - *  
314 - * @param {Arguments,Array} args  
315 - * @return {String} stringified arguments  
316 - */  
317 - stringifyArguments: function(args) {  
318 - var result = [];  
319 - var slice = Array.prototype.slice;  
320 - for (var i = 0; i < args.length; ++i) {  
321 - var arg = args[i];  
322 - if (arg === undefined) {  
323 - result[i] = 'undefined';  
324 - } else if (arg === null) {  
325 - result[i] = 'null';  
326 - } else if (arg.constructor) {  
327 - if (arg.constructor === Array) {  
328 - if (arg.length < 3) {  
329 - result[i] = '[' + this.stringifyArguments(arg) + ']';  
330 - } else {  
331 - result[i] = '[' + this.stringifyArguments(slice.call(arg, 0, 1)) + '...' + this.stringifyArguments(slice.call(arg, -1)) + ']';  
332 - }  
333 - } else if (arg.constructor === Object) {  
334 - result[i] = '#object';  
335 - } else if (arg.constructor === Function) {  
336 - result[i] = '#function';  
337 - } else if (arg.constructor === String) {  
338 - result[i] = '"' + arg + '"';  
339 - } else if (arg.constructor === Number) {  
340 - result[i] = arg;  
341 - }  
342 - }  
343 - }  
344 - return result.join(',');  
345 - },  
346 -  
347 - sourceCache: {},  
348 -  
349 - /**  
350 - * @return the text from a given URL  
351 - */  
352 - ajax: function(url) {  
353 - var req = this.createXMLHTTPObject();  
354 - if (req) {  
355 - try {  
356 - req.open('GET', url, false);  
357 - //req.overrideMimeType('text/plain');  
358 - //req.overrideMimeType('text/javascript');  
359 - req.send(null);  
360 - //return req.status == 200 ? req.responseText : '';  
361 - return req.responseText;  
362 - } catch (e) {  
363 - }  
364 - }  
365 - return '';  
366 - },  
367 -  
368 - /**  
369 - * Try XHR methods in order and store XHR factory.  
370 - *  
371 - * @return <Function> XHR function or equivalent  
372 - */  
373 - createXMLHTTPObject: function() {  
374 - var xmlhttp, XMLHttpFactories = [  
375 - function() {  
376 - return new XMLHttpRequest();  
377 - }, function() {  
378 - return new ActiveXObject('Msxml2.XMLHTTP');  
379 - }, function() {  
380 - return new ActiveXObject('Msxml3.XMLHTTP');  
381 - }, function() {  
382 - return new ActiveXObject('Microsoft.XMLHTTP');  
383 - }  
384 - ];  
385 - for (var i = 0; i < XMLHttpFactories.length; i++) {  
386 - try {  
387 - xmlhttp = XMLHttpFactories[i]();  
388 - // Use memoization to cache the factory  
389 - this.createXMLHTTPObject = XMLHttpFactories[i];  
390 - return xmlhttp;  
391 - } catch (e) {  
392 - }  
393 - }  
394 - },  
395 -  
396 - /**  
397 - * Given a URL, check if it is in the same domain (so we can get the source  
398 - * via Ajax).  
399 - *  
400 - * @param url <String> source url  
401 - * @return <Boolean> False if we need a cross-domain request  
402 - */  
403 - isSameDomain: function(url) {  
404 - return typeof location !== "undefined" && url.indexOf(location.hostname) !== -1; // location may not be defined, e.g. when running from nodejs.  
405 - },  
406 -  
407 - /**  
408 - * Get source code from given URL if in the same domain.  
409 - *  
410 - * @param url <String> JS source URL  
411 - * @return <Array> Array of source code lines  
412 - */  
413 - getSource: function(url) {  
414 - // TODO reuse source from script tags?  
415 - if (!(url in this.sourceCache)) {  
416 - this.sourceCache[url] = this.ajax(url).split('\n');  
417 - }  
418 - return this.sourceCache[url];  
419 - },  
420 -  
421 - guessAnonymousFunctions: function(stack) {  
422 - for (var i = 0; i < stack.length; ++i) {  
423 - var reStack = /\{anonymous\}\(.*\)@(.*)/,  
424 - reRef = /^(.*?)(?::(\d+))(?::(\d+))?(?: -- .+)?$/,  
425 - frame = stack[i], ref = reStack.exec(frame);  
426 -  
427 - if (ref) {  
428 - var m = reRef.exec(ref[1]);  
429 - if (m) { // If falsey, we did not get any file/line information  
430 - var file = m[1], lineno = m[2], charno = m[3] || 0;  
431 - if (file && this.isSameDomain(file) && lineno) {  
432 - var functionName = this.guessAnonymousFunction(file, lineno, charno);  
433 - stack[i] = frame.replace('{anonymous}', functionName);  
434 - }  
435 - }  
436 - }  
437 - }  
438 - return stack;  
439 - },  
440 -  
441 - guessAnonymousFunction: function(url, lineNo, charNo) {  
442 - var ret;  
443 - try {  
444 - ret = this.findFunctionName(this.getSource(url), lineNo);  
445 - } catch (e) {  
446 - ret = 'getSource failed with url: ' + url + ', exception: ' + e.toString();  
447 - }  
448 - return ret;  
449 - },  
450 -  
451 - findFunctionName: function(source, lineNo) {  
452 - // FIXME findFunctionName fails for compressed source  
453 - // (more than one function on the same line)  
454 - // function {name}({args}) m[1]=name m[2]=args  
455 - var reFunctionDeclaration = /function\s+([^(]*?)\s*\(([^)]*)\)/;  
456 - // {name} = function ({args}) TODO args capture  
457 - // /['"]?([0-9A-Za-z_]+)['"]?\s*[:=]\s*function(?:[^(]*)/  
458 - var reFunctionExpression = /['"]?([$_A-Za-z][$_A-Za-z0-9]*)['"]?\s*[:=]\s*function\b/;  
459 - // {name} = eval()  
460 - var reFunctionEvaluation = /['"]?([$_A-Za-z][$_A-Za-z0-9]*)['"]?\s*[:=]\s*(?:eval|new Function)\b/;  
461 - // Walk backwards in the source lines until we find  
462 - // the line which matches one of the patterns above  
463 - var code = "", line, maxLines = Math.min(lineNo, 20), m, commentPos;  
464 - for (var i = 0; i < maxLines; ++i) {  
465 - // lineNo is 1-based, source[] is 0-based  
466 - line = source[lineNo - i - 1];  
467 - commentPos = line.indexOf('//');  
468 - if (commentPos >= 0) {  
469 - line = line.substr(0, commentPos);  
470 - }  
471 - // TODO check other types of comments? Commented code may lead to false positive  
472 - if (line) {  
473 - code = line + code;  
474 - m = reFunctionExpression.exec(code);  
475 - if (m && m[1]) {  
476 - return m[1];  
477 - }  
478 - m = reFunctionDeclaration.exec(code);  
479 - if (m && m[1]) {  
480 - //return m[1] + "(" + (m[2] || "") + ")";  
481 - return m[1];  
482 - }  
483 - m = reFunctionEvaluation.exec(code);  
484 - if (m && m[1]) {  
485 - return m[1];  
486 - }  
487 - }  
488 - }  
489 - return '(?)';  
490 - }  
491 -};// Airbrake JavaScript Notifier  
492 -(function() {  
493 - "use strict";  
494 -  
495 - var NOTICE_XML = '<?xml version="1.0" encoding="UTF-8"?>' +  
496 - '<notice version="2.0">' +  
497 - '<api-key>{key}</api-key>' +  
498 - '<notifier>' +  
499 - '<name>airbrake_js</name>' +  
500 - '<version>0.2.0</version>' +  
501 - '<url>http://airbrake.io</url>' +  
502 - '</notifier>' +  
503 - '<error>' +  
504 - '<class>{exception_class}</class>' +  
505 - '<message><![CDATA[{exception_message}]]></message>' +  
506 - '<backtrace>{backtrace_lines}</backtrace>' +  
507 - '</error>' +  
508 - '<request>' +  
509 - '<url><![CDATA[{request_url}]]></url>' +  
510 - '<component>{request_component}</component>' +  
511 - '<action>{request_action}</action>' +  
512 - '{request}' +  
513 - '</request>' +  
514 - '<server-environment>' +  
515 - '<project-root>{project_root}</project-root>' +  
516 - '<environment-name>{environment}</environment-name>' +  
517 - '<app-version>{appVersion}</app-version>' +  
518 - '</server-environment>' +  
519 - '<current-user>' +  
520 - '<id>{user_id}</id>' +  
521 - '<name>{user_name}</name>' +  
522 - '<email>{user_email}</email>' +  
523 - '</current-user>' +  
524 - '</notice>',  
525 - REQUEST_VARIABLE_GROUP_XML = '<{group_name}>{inner_content}</{group_name}>',  
526 - REQUEST_VARIABLE_XML = '<var key="{key}">{value}</var>',  
527 - BACKTRACE_LINE_XML = '<line method="{function}" file="{file}" number="{line}" />',  
528 - Config,  
529 - Global,  
530 - Util,  
531 - _publicAPI,  
532 -  
533 - NOTICE_JSON = {  
534 - "notifier": {  
535 - "name": "airbrake_js",  
536 - "version": "0.2.0",  
537 - "url": "http://airbrake.io"  
538 - },  
539 - "error": [  
540 - {  
541 - "type": "{exception_class}",  
542 - "message": "{exception_message}",  
543 - "backtrace": []  
544 -  
545 - }  
546 - ],  
547 - "context": {  
548 - "language": "JavaScript",  
549 - "environment": "{environment}",  
550 -  
551 - "version": "1.1.1",  
552 - "url": "{request_url}",  
553 - "rootDirectory": "{project_root}",  
554 - "action": "{request_action}",  
555 - "app-version": "{appVersion}",  
556 -  
557 - "userId": "{user_id}",  
558 - "userName": "{user_name}",  
559 - "userEmail": "{user_email}"  
560 - },  
561 - "environment": {},  
562 - //"session": "",  
563 - "params": {}  
564 - };  
565 -  
566 - Util = {  
567 - /*  
568 - * Merge a number of objects into one.  
569 - *  
570 - * Usage example:  
571 - * var obj1 = {  
572 - * a: 'a'  
573 - * },  
574 - * obj2 = {  
575 - * b: 'b'  
576 - * },  
577 - * obj3 = {  
578 - * c: 'c'  
579 - * },  
580 - * mergedObj = Util.merge(obj1, obj2, obj3);  
581 - *  
582 - * mergedObj is: {  
583 - * a: 'a',  
584 - * b: 'b',  
585 - * c: 'c'  
586 - * }  
587 - *  
588 - */  
589 - merge: (function() {  
590 - function processProperty (key, dest, src) {  
591 - if (src.hasOwnProperty(key)) {  
592 - dest[key] = src[key];  
593 - }  
594 - }  
595 -  
596 - return function() {  
597 - var objects = Array.prototype.slice.call(arguments),  
598 - obj,  
599 - key,  
600 - result = {};  
601 -  
602 - while (obj = objects.shift()) {  
603 - for (key in obj) {  
604 - processProperty(key, result, obj);  
605 - }  
606 - }  
607 -  
608 - return result;  
609 - };  
610 - })(),  
611 -  
612 - /*  
613 - * Replace &, <, >, ', " characters with correspondent HTML entities.  
614 - */  
615 - escape: function (text) {  
616 - return text.replace(/&/g, '&#38;').replace(/</g, '&#60;').replace(/>/g, '&#62;')  
617 - .replace(/'/g, '&#39;').replace(/"/g, '&#34;');  
618 - },  
619 -  
620 - /*  
621 - * Remove leading and trailing space characters.  
622 - */  
623 - trim: function (text) {  
624 - return text.toString().replace(/^\s+/, '').replace(/\s+$/, '');  
625 - },  
626 -  
627 - /*  
628 - * Fill 'text' pattern with 'data' values.  
629 - *  
630 - * e.g. Utils.substitute('<{tag}></{tag}>', {tag: 'div'}, true) will return '<div></div>'  
631 - *  
632 - * emptyForUndefinedData - a flag, if true, all matched {<name>} without data.<name> value specified will be  
633 - * replaced with empty string.  
634 - */  
635 - substitute: function (text, data, emptyForUndefinedData) {  
636 - return text.replace(/{([\w_.-]+)}/g, function(match, key) {  
637 - return (key in data) ? data[key] : (emptyForUndefinedData ? '' : match);  
638 - });  
639 - },  
640 -  
641 - /*  
642 - * Perform pattern rendering for an array of data objects.  
643 - * Returns a concatenation of rendered strings of all objects in array.  
644 - */  
645 - substituteArr: function (text, dataArr, emptyForUndefinedData) {  
646 - var _i = 0, _l = 0,  
647 - returnStr = '';  
648 -  
649 - for (_i = 0, _l = dataArr.length; _i < _l; _i += 1) {  
650 - returnStr += this.substitute(text, dataArr[_i], emptyForUndefinedData);  
651 - }  
652 -  
653 - return returnStr;  
654 - },  
655 -  
656 - /*  
657 - * Add hook for jQuery.fn.on function, to manualy call window.Airbrake.captureException() method  
658 - * for every exception occurred.  
659 - *  
660 - * Let function 'f' be binded as an event handler:  
661 - *  
662 - * $(window).on 'click', f  
663 - *  
664 - * If an exception is occurred inside f's body, it will be catched here  
665 - * and forwarded to captureException method.  
666 - *  
667 - * processjQueryEventHandlerWrapping is called every time window.Airbrake.setTrackJQ method is used,  
668 - * if it switches previously setted value.  
669 - */  
670 - processjQueryEventHandlerWrapping: function () {  
671 - if (Config.options.trackJQ === true) {  
672 - Config.jQuery_fn_on_original = Config.jQuery_fn_on_original || jQuery.fn.on;  
673 -  
674 - jQuery.fn.on = function () {  
675 - var args = Array.prototype.slice.call(arguments),  
676 - fnArgIdx = 4;  
677 -  
678 - // Search index of function argument  
679 - while((--fnArgIdx > -1) && (typeof args[fnArgIdx] !== 'function'));  
680 -  
681 - // If the function is not found, then subscribe original event handler function  
682 - if (fnArgIdx === -1) {  
683 - return Config.jQuery_fn_on_original.apply(this, arguments);  
684 - }  
685 -  
686 - // If the function is found, then subscribe wrapped event handler function  
687 - args[fnArgIdx] = (function (fnOriginHandler) {  
688 - return function() {  
689 - try {  
690 - fnOriginHandler.apply(this, arguments);  
691 - } catch (e) {  
692 - Global.captureException(e);  
693 - }  
694 - };  
695 - })(args[fnArgIdx]);  
696 -  
697 - // Call original jQuery.fn.on, with the same list of arguments, but  
698 - // a function replaced with a proxy.  
699 - return Config.jQuery_fn_on_original.apply(this, args);  
700 - };  
701 - } else {  
702 - // Recover original jQuery.fn.on if Config.options.trackJQ is set to false  
703 - (typeof Config.jQuery_fn_on_original === 'function') && (jQuery.fn.on = Config.jQuery_fn_on_original);  
704 - }  
705 - },  
706 -  
707 - isjQueryPresent: function () {  
708 - // Currently only 1.7.x version supported  
709 - return (typeof jQuery === 'function') && ('fn' in jQuery) && ('jquery' in jQuery.fn)  
710 - && (jQuery.fn.jquery.indexOf('1.7') === 0)  
711 - },  
712 -  
713 - /*  
714 - * Make first letter in a string capital. e.g. 'guessFunctionName' -> 'GuessFunctionName'  
715 - * Is used to generate getter and setter method names.  
716 - */  
717 - capitalizeFirstLetter: function (str) {  
718 - return str.charAt(0).toUpperCase() + str.slice(1);  
719 - },  
720 -  
721 - /*  
722 - * Generate public API from an array of specifically formated objects, e.g.  
723 - *  
724 - * - this will generate 'setEnvironment' and 'getEnvironment' API methods for configObj.xmlData.environment variable:  
725 - * {  
726 - * variable: 'environment',  
727 - * namespace: 'xmlData'  
728 - * }  
729 - *  
730 - * - this will define 'method' function as 'captureException' API method  
731 - * {  
732 - * methodName: 'captureException',  
733 - * method: (function (...) {...});  
734 - * }  
735 - *  
736 - */  
737 - generatePublicAPI: (function () {  
738 - function _generateSetter (variable, namespace, configObj) {  
739 - return function (value) {  
740 - configObj[namespace][variable] = value;  
741 - };  
742 - }  
743 -  
744 - function _generateGetter (variable, namespace, configObj) {  
745 - return function (value) {  
746 - return configObj[namespace][variable];  
747 - };  
748 - }  
749 -  
750 - /*  
751 - * publicAPI: array of specifically formated objects  
752 - * configObj: inner configuration object  
753 - */  
754 - return function (publicAPI, configObj) {  
755 - var _i = 0, _m = null, _capitalized = '',  
756 - returnObj = {};  
757 -  
758 - for (_i = 0; _i < publicAPI.length; _i += 1) {  
759 - _m = publicAPI[_i];  
760 -  
761 - switch (true) {  
762 - case (typeof _m.variable !== 'undefined') && (typeof _m.methodName === 'undefined'):  
763 - _capitalized = Util.capitalizeFirstLetter(_m.variable)  
764 - returnObj['set' + _capitalized] = _generateSetter(_m.variable, _m.namespace, configObj);  
765 - returnObj['get' + _capitalized] = _generateGetter(_m.variable, _m.namespace, configObj);  
766 -  
767 - break;  
768 - case (typeof _m.methodName !== 'undefined') && (typeof _m.method !== 'undefined'):  
769 - returnObj[_m.methodName] = _m.method  
770 -  
771 - break;  
772 -  
773 - default:  
774 - }  
775 - }  
776 -  
777 - return returnObj;  
778 - };  
779 - } ())  
780 - };  
781 -  
782 - /*  
783 - * The object to store settings. Allocated from the Global (windows scope) so that users can change settings  
784 - * only through the methods, rather than through a direct change of the object fileds. So that we can to handle  
785 - * change settings event (in setter method).  
786 - */  
787 - Config = {  
788 - xmlData: {  
789 - environment: 'environment'  
790 - },  
791 -  
792 - options: {  
793 - trackJQ: false, // jQuery.fn.jquery  
794 - host: 'api.airbrake.io',  
795 - errorDefaults: {},  
796 - guessFunctionName: false,  
797 - requestType: 'GET', // Can be 'POST' or 'GET'  
798 - outputFormat: 'XML' // Can be 'XML' or 'JSON'  
799 - }  
800 - };  
801 -  
802 - /*  
803 - * The public API definition object. If no 'methodName' and 'method' values specified,  
804 - * getter and setter for 'variable' will be defined.  
805 - */  
806 - _publicAPI = [  
807 - {  
808 - variable: 'environment',  
809 - namespace: 'xmlData'  
810 - }, {  
811 - variable: 'key',  
812 - namespace: 'xmlData'  
813 - }, {  
814 - variable: 'host',  
815 - namespace: 'options'  
816 - },{  
817 - variable: 'projectId',  
818 - namespace: 'options'  
819 - },{  
820 - variable: 'errorDefaults',  
821 - namespace: 'options'  
822 - }, {  
823 - variable: 'guessFunctionName',  
824 - namespace: 'options'  
825 - }, {  
826 - variable: 'outputFormat',  
827 - namespace: 'options'  
828 - }, {  
829 - methodName: 'setCurrentUser',  
830 - method: (function (value) {  
831 - for (var key in value) {  
832 - if (value.hasOwnProperty(key)) {  
833 - Config.xmlData['user_' + key] = value[key];  
834 - }  
835 - }  
836 - })  
837 - }, {  
838 - methodName: 'setTrackJQ',  
839 - variable: 'trackJQ',  
840 - namespace: 'options',  
841 - method: (function (value) {  
842 - if (!Util.isjQueryPresent()) {  
843 - throw Error('Please do not call \'Airbrake.setTrackJQ\' if jQuery does\'t present');  
844 - }  
845 -  
846 - value = !!value;  
847 -  
848 - if (Config.options.trackJQ === value) {  
849 - return;  
850 - }  
851 -  
852 - Config.options.trackJQ = value;  
853 -  
854 - Util.processjQueryEventHandlerWrapping();  
855 - })  
856 - }, {  
857 - methodName: 'captureException',  
858 - method: (function (e) {  
859 - new Notifier().notify({  
860 - message: e.message,  
861 - stack: e.stack  
862 - });  
863 - })  
864 - }, {  
865 - variable: 'appVersion',  
866 - namespace: 'xmlData'  
867 - }  
868 - ];  
869 -  
870 - // Share to global scope as Airbrake ("window.Hoptoad" for backward compatibility)  
871 - Global = window.Airbrake = window.Hoptoad = Util.generatePublicAPI(_publicAPI, Config);  
872 -  
873 - Global._filters = [];  
874 -  
875 - Global.addFilter = function (cb) {  
876 - Global._filters.push(cb);  
877 - };  
878 -  
879 - function Notifier() {  
880 - this.options = Util.merge({}, Config.options);  
881 - this.xmlData = Util.merge(this.DEF_XML_DATA, Config.xmlData);  
882 - }  
883 -  
884 - Notifier.prototype = {  
885 - constructor: Notifier,  
886 - VERSION: '0.2.0',  
887 - ROOT: window.location.protocol + '//' + window.location.host,  
888 - BACKTRACE_MATCHER: /^(.*)\@(.*)\:(\d+)$/,  
889 - backtrace_filters: [/notifier\.js/],  
890 - DEF_XML_DATA: {  
891 - request: {}  
892 - },  
893 -  
894 - notify: (function () {  
895 - /*  
896 - * Emit GET request via <iframe> element.  
897 - * Data is transmited as a part of query string.  
898 - */  
899 - function _sendGETRequest (url, data) {  
900 - var request = document.createElement('iframe');  
901 -  
902 - request.style.display = 'none';  
903 - request.src = url + '?data=' + data;  
904 -  
905 - // When request has been sent, delete iframe  
906 - request.onload = function () {  
907 - // To avoid infinite progress indicator  
908 - setTimeout(function() {  
909 - document.body.removeChild(request);  
910 - }, 0);  
911 - };  
912 -  
913 - document.body.appendChild(request);  
914 - }  
915 -  
916 - /*  
917 - * Cross-domain AJAX POST request.  
918 - *  
919 - * It requires a server setup as described in Cross-Origin Resource Sharing spec:  
920 - * http://www.w3.org/TR/cors/  
921 - */  
922 - function _sendPOSTRequest (url, data) {  
923 - var request = new XMLHttpRequest();  
924 - request.open('POST', url, true);  
925 - request.setRequestHeader('Content-Type', 'application/json');  
926 - request.send(data);  
927 - }  
928 -  
929 - return function (error) {  
930 - var outputData = '', jsonData,  
931 - url = '';  
932 - //  
933 -  
934 - /*  
935 - * Should be changed to url = '//' + ...  
936 - * to use the protocol of current page (http or https). Only sends 'secure' if page is secure.  
937 - * XML uses V2 API. http://collect.airbrake.io/notifier_api/v2/notices  
938 - */  
939 -  
940 -  
941 - switch (this.options['outputFormat']) {  
942 - case 'XML':  
943 - jsonData = this.generateDataJSON(error);  
944 - if (this.shouldSendData(jsonData)){  
945 - outputData = encodeURIComponent(this.generateXML(jsonData));  
946 - url = ('https:' == document.location.protocol ? 'https://' : 'http://') + this.options.host + '/notifier_api/v2/notices';  
947 - _sendGETRequest(url, outputData);  
948 - }  
949 - break;  
950 -  
951 - case 'JSON':  
952 - /*  
953 - * JSON uses API V3. Needs project in URL.  
954 - * http://collect.airbrake.io/api/v3/projects/[PROJECT_ID]/notices?key=[API_KEY]  
955 - * url = window.location.protocol + '://' + this.options.host + '/api/v3/projects' + this.options.projectId + '/notices?key=' + this.options.key;  
956 - */  
957 - jsonData = this.generateDataJSON(error);  
958 - if (this.shouldSendData(jsonData)){  
959 - outputData = JSON.stringify(this.generateJSON(jsonData));  
960 - url = ('https:' == document.location.protocol ? 'https://' : 'http://') + this.options.host + '/api/v3/projects/' + this.options.projectId + '/notices?key=' + this.xmlData.key;  
961 - _sendPOSTRequest(url, outputData);  
962 - }  
963 - break;  
964 -  
965 - default:  
966 - }  
967 -  
968 - };  
969 - } ()),  
970 -  
971 - /*  
972 - * Generate inner JSON representation of exception data that can be rendered as XML or JSON.  
973 - */  
974 - generateDataJSON: (function () {  
975 - /*  
976 - * Generate variables array for inputObj object.  
977 - *  
978 - * e.g.  
979 - *  
980 - * _generateVariables({a: 'a'}) -> [{key: 'a', value: 'a'}]  
981 - *  
982 - */  
983 - function _generateVariables (inputObj) {  
984 - var key = '', returnArr = [];  
985 -  
986 - for (key in inputObj) {  
987 - if (inputObj.hasOwnProperty(key)) {  
988 - returnArr.push({  
989 - key: key,  
990 - value: inputObj[key]  
991 - });  
992 - }  
993 - }  
994 -  
995 - return returnArr;  
996 - }  
997 -  
998 - /*  
999 - * Generate Request part of notification.  
1000 - */  
1001 - function _composeRequestObj (methods, errorObj) {  
1002 - var _i = 0,  
1003 - returnObj = {},  
1004 - type = '';  
1005 -  
1006 - for (_i = 0; _i < methods.length; _i += 1) {  
1007 - type = methods[_i];  
1008 - if (typeof errorObj[type] !== 'undefined') {  
1009 - returnObj[type] = _generateVariables(errorObj[type]);  
1010 - }  
1011 - }  
1012 -  
1013 - return returnObj;  
1014 - }  
1015 -  
1016 - return function (errorWithoutDefaults) {  
1017 - /*  
1018 - * A constructor line:  
1019 - *  
1020 - * this.xmlData = Util.merge(this.DEF_XML_DATA, Config.xmlData);  
1021 - */  
1022 - var outputData = this.xmlData,  
1023 - error = Util.merge(this.options.errorDefaults, errorWithoutDefaults),  
1024 -  
1025 - component = error.component || '',  
1026 - request_url = (error.url || '' + location.href),  
1027 -  
1028 - methods = ['cgi-data', 'params', 'session'],  
1029 - _outputData = null;  
1030 -  
1031 - _outputData = {  
1032 - request_url: request_url,  
1033 - request_action: (error.action || ''),  
1034 - request_component: component,  
1035 - request: (function () {  
1036 - if (request_url || component) {  
1037 - error['cgi-data'] = error['cgi-data'] || {};  
1038 - error['cgi-data'].HTTP_USER_AGENT = navigator.userAgent;  
1039 - return Util.merge(outputData.request, _composeRequestObj(methods, error));  
1040 - } else {  
1041 - return {}  
1042 - }  
1043 - } ()),  
1044 -  
1045 - project_root: this.ROOT,  
1046 - exception_class: (error.type || errorWithoutDefaults.type ||  
1047 - (errorWithoutDefaults.constructor.name != "Object" ? errorWithoutDefaults.constructor.name : 'Error')),  
1048 - exception_message: (error.message || errorWithoutDefaults.message || 'Unknown error.'),  
1049 - backtrace_lines: this.generateBacktrace(errorWithoutDefaults)  
1050 - }  
1051 -  
1052 - outputData = Util.merge(outputData, _outputData);  
1053 -  
1054 - return outputData;  
1055 - };  
1056 - } ()),  
1057 -  
1058 - /*  
1059 - * Generate XML notification from inner JSON representation.  
1060 - * NOTICE_XML is used as pattern.  
1061 - */  
1062 - generateXML: (function () {  
1063 - function _generateRequestVariableGroups (requestObj) {  
1064 - var _group = '',  
1065 - returnStr = '';  
1066 -  
1067 - for (_group in requestObj) {  
1068 - if (requestObj.hasOwnProperty(_group)) {  
1069 - returnStr += Util.substitute(REQUEST_VARIABLE_GROUP_XML, {  
1070 - group_name: _group,  
1071 - inner_content: Util.substituteArr(REQUEST_VARIABLE_XML, requestObj[_group], true)  
1072 - }, true);  
1073 - }  
1074 - }  
1075 -  
1076 - return returnStr;  
1077 - }  
1078 -  
1079 - return function (JSONdataObj) {  
1080 - JSONdataObj.request = _generateRequestVariableGroups(JSONdataObj.request);  
1081 - JSONdataObj.backtrace_lines = Util.substituteArr(BACKTRACE_LINE_XML, JSONdataObj.backtrace_lines, true);  
1082 -  
1083 - return Util.substitute(NOTICE_XML, JSONdataObj, true);  
1084 - };  
1085 - } ()),  
1086 -  
1087 - /*  
1088 - * Generate JSON notification from inner JSON representation.  
1089 - * NOTICE_JSON is used as pattern.  
1090 - */  
1091 - generateJSON: function (JSONdataObj) {  
1092 - // Pattern string is JSON.stringify(NOTICE_JSON)  
1093 - // The rendered string is parsed back as JSON.  
1094 - var outputJSON = JSON.parse(Util.substitute(JSON.stringify(NOTICE_JSON), JSONdataObj, true));  
1095 -  
1096 - // REMOVED - Request from JSON.  
1097 - outputJSON.request = Util.merge(outputJSON.request, JSONdataObj.request);  
1098 - outputJSON.error.backtrace = JSONdataObj.backtrace_lines;  
1099 -  
1100 - return outputJSON;  
1101 - },  
1102 -  
1103 - generateBacktrace: function (error) {  
1104 - var backtrace = [],  
1105 - file,  
1106 - i,  
1107 - matches,  
1108 - stacktrace;  
1109 -  
1110 - error = error || {};  
1111 -  
1112 - if (typeof error.stack !== 'string') {  
1113 - try {  
1114 - (0)();  
1115 - } catch (e) {  
1116 - error.stack = e.stack;  
1117 - }  
1118 - }  
1119 -  
1120 - stacktrace = this.getStackTrace(error);  
1121 -  
1122 - for (i = 0; i < stacktrace.length; i++) {  
1123 - matches = stacktrace[i].match(this.BACKTRACE_MATCHER);  
1124 -  
1125 - if (matches && this.validBacktraceLine(stacktrace[i])) {  
1126 - file = matches[2].replace(this.ROOT, '[PROJECT_ROOT]');  
1127 -  
1128 - if (i === 0 && matches[2].match(document.location.href)) {  
1129 - // backtrace.push('<line method="" file="internal: " number=""/>');  
1130 -  
1131 - backtrace.push({  
1132 - // Updated to fit in with V3 new terms for Backtrace data.  
1133 - 'function': '',  
1134 - file: 'internal: ',  
1135 - line: ''  
1136 - });  
1137 - }  
1138 -  
1139 - // backtrace.push('<line method="' + Util.escape(matches[1]) + '" file="' + Util.escape(file) +  
1140 - // '" number="' + matches[3] + '" />');  
1141 -  
1142 - backtrace.push({  
1143 - 'function': Util.escape(matches[1]),  
1144 - file: Util.escape(file),  
1145 - line: matches[3]  
1146 - });  
1147 - }  
1148 - }  
1149 -  
1150 - return backtrace;  
1151 - },  
1152 -  
1153 - getStackTrace: function (error) {  
1154 - var i,  
1155 - stacktrace = printStackTrace({  
1156 - e: error,  
1157 - guess: this.options.guessFunctionName  
1158 - });  
1159 -  
1160 - for (i = 0; i < stacktrace.length; i++) {  
1161 - if (stacktrace[i].match(/\:\d+$/)) {  
1162 - continue;  
1163 - }  
1164 -  
1165 - // Special case for sprocket coffee stacktrace:  
1166 - // "Function.foo (http://host/file.js?body=1:666:42)" becomes "Function.foo @http://host/file.js?body=1:666"  
1167 - if (stacktrace[i].match(/\([^\s]+:(\d+):(\d+)\)$/)) {  
1168 - stacktrace[i] = stacktrace[i].replace(/\((.+):(\d+):(\d+)\)$/, '@$1:$2')  
1169 - continue;  
1170 - }  
1171 -  
1172 - if (stacktrace[i].indexOf('@') === -1) {  
1173 - stacktrace[i] += '@unsupported.js';  
1174 - }  
1175 -  
1176 - stacktrace[i] += ':0';  
1177 - }  
1178 -  
1179 - return stacktrace;  
1180 - },  
1181 -  
1182 - validBacktraceLine: function (line) {  
1183 - for (var i = 0; i < this.backtrace_filters.length; i++) {  
1184 - if (line.match(this.backtrace_filters[i])) {  
1185 - return false;  
1186 - }  
1187 - }  
1188 -  
1189 - return true;  
1190 - },  
1191 -  
1192 - shouldSendData: function (jsonData) {  
1193 - var shouldSend = true, i;  
1194 -  
1195 - for ( i = 0; i < Global._filters.length; i++ ) {  
1196 - if ( ! Global._filters[i](jsonData) ){  
1197 - shouldSend = false;  
1198 - }  
1199 - }  
1200 -  
1201 - return shouldSend;  
1202 - }  
1203 - };  
1204 -  
1205 - var oldOnerror = window.onerror;  
1206 - window.onerror = function (message, file, line, code, error) {  
1207 - setTimeout(function () {  
1208 - var e = error || {stack: '()@' + file + ':' + line}  
1209 - e.message = message  
1210 - new Notifier().notify(e);  
1211 - }, 0);  
1212 - if (oldOnerror) {  
1213 - return oldOnerror(message, file, line, code, error);  
1214 - }  
1215 - return true;  
1216 - };  
1217 -})();  
1218 -})(window, document);  
spec/fixtures/api_v3_request_without_backtrace.json 0 → 100644
@@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
  1 +{
  2 + "notifier":{"name":"airbrake-js-v8","version":"0.3.10","url":"https://github.com/airbrake/airbrake-js"},
  3 + "errors":[
  4 + {
  5 + "type":"Error",
  6 + "message":"Error: TestError",
  7 + "backtrace":null
  8 + }
  9 + ],
  10 + "context":{
  11 + "language":"JavaScript",
  12 + "sourceMapEnabled":true,
  13 + "userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.99 Safari/537.36",
  14 + "url":"http://localhost:3000/kontakt",
  15 + "userId":1,"userUsername":"john",
  16 + "userName":"John Doe",
  17 + "userUsername": "john",
  18 + "userEmail":"john.doe@example.org",
  19 + "version":"1.0",
  20 + "component":"ContactsController",
  21 + "action":"show"
  22 + },
  23 + "params":{"returnTo":"dashboard"},
  24 + "environment":{"navigator_vendor":"Google Inc."},
  25 + "session":{"isAdmin":true},
  26 + "key":"a3969bfbf65f073921e",
  27 + "project_id":"a3969bfbf65f073921e"
  28 +}
0 \ No newline at end of file 29 \ No newline at end of file
spec/initializers/action_mailer_spec.rb 0 → 100644
@@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
  1 +describe 'initializers/action_mailer' do
  2 + def load_initializer
  3 + load File.join(Rails.root, 'config', 'initializers', 'action_mailer.rb')
  4 + end
  5 +
  6 + describe 'delivery method' do
  7 + it 'sets the delivery method to :smtp' do
  8 + allow(Errbit::Config).to receive(:email_delivery_method).and_return(:smtp)
  9 + load_initializer
  10 +
  11 + expect(ActionMailer::Base.delivery_method).to be(:smtp)
  12 + end
  13 +
  14 + it 'sets the delivery method to :sendmail' do
  15 + allow(Errbit::Config).to receive(:email_delivery_method).and_return(:sendmail)
  16 + load_initializer
  17 +
  18 + expect(ActionMailer::Base.delivery_method).to be(:sendmail)
  19 + end
  20 + end
  21 +
  22 + describe 'smtp settings' do
  23 + it 'lets smtp settings be set' do
  24 + allow(Errbit::Config).to receive(:email_delivery_method).and_return(:smtp)
  25 + allow(Errbit::Config).to receive(:smtp_address).and_return('smtp.somedomain.com')
  26 + allow(Errbit::Config).to receive(:smtp_port).and_return(998)
  27 + allow(Errbit::Config).to receive(:smtp_authentication).and_return(:login)
  28 + allow(Errbit::Config).to receive(:smtp_user_name).and_return('my-username')
  29 + allow(Errbit::Config).to receive(:smtp_password).and_return('my-password')
  30 + allow(Errbit::Config).to receive(:smtp_domain).and_return('someotherdomain.com')
  31 + load_initializer
  32 +
  33 + expect(ActionMailer::Base.smtp_settings).to eq({
  34 + address: 'smtp.somedomain.com',
  35 + port: 998,
  36 + authentication: :login,
  37 + user_name: 'my-username',
  38 + password: 'my-password',
  39 + domain: 'someotherdomain.com',
  40 + })
  41 + end
  42 + end
  43 +end
spec/lib/airbrake_api/v3/notice_parser_spec.rb
@@ -36,6 +36,19 @@ describe AirbrakeApi::V3::NoticeParser do @@ -36,6 +36,19 @@ describe AirbrakeApi::V3::NoticeParser do
36 expect(report).to be_valid 36 expect(report).to be_valid
37 end 37 end
38 38
  39 + it 'parses JSON payload with missing backtrace' do
  40 + json = Rails.root.join('spec', 'fixtures', 'api_v3_request_without_backtrace.json').read
  41 + params = JSON.parse(json)
  42 + params['key'] = app.api_key
  43 +
  44 + report = AirbrakeApi::V3::NoticeParser.new(params).report
  45 + notice = report.generate_notice!
  46 +
  47 + expect(report.error_class).to eq('Error')
  48 + expect(report.message).to eq('Error: TestError')
  49 + expect(report.backtrace.lines.size).to eq(0)
  50 + end
  51 +
39 def build_params(options = {}) 52 def build_params(options = {})
40 json = Rails.root.join('spec', 'fixtures', 'api_v3_request.json').read 53 json = Rails.root.join('spec', 'fixtures', 'api_v3_request.json').read
41 data = JSON.parse(json) 54 data = JSON.parse(json)
spec/lib/configurator_spec.rb
@@ -34,4 +34,28 @@ describe Configurator do @@ -34,4 +34,28 @@ describe Configurator do
34 }) 34 })
35 expect(result.one).to eq('zoom') 35 expect(result.one).to eq('zoom')
36 end 36 end
  37 +
  38 + it 'extracts symbol values' do
  39 + allow(ENV).to receive(:[]).with('MYSYMBOL').and_return(':asymbol')
  40 + result = Configurator.run({ mysymbol: ['MYSYMBOL'] })
  41 + expect(result.mysymbol).to be(:asymbol)
  42 + end
  43 +
  44 + it 'extracts array values' do
  45 + allow(ENV).to receive(:[]).with('MYARRAY').and_return('[one,two,three]')
  46 + result = Configurator.run({ myarray: ['MYARRAY'] })
  47 + expect(result.myarray).to eq(['one', 'two', 'three'])
  48 + end
  49 +
  50 + it 'extracts booleans' do
  51 + allow(ENV).to receive(:[]).with('MYBOOLEAN').and_return('true')
  52 + result = Configurator.run({ myboolean: ['MYBOOLEAN'] })
  53 + expect(result.myboolean).to be(true)
  54 + end
  55 +
  56 + it 'extracts numbers' do
  57 + allow(ENV).to receive(:[]).with('MYNUMBER').and_return('0')
  58 + result = Configurator.run({ mynumber: ['MYNUMBER'] })
  59 + expect(result.mynumber).to be(0)
  60 + end
37 end 61 end
spec/models/issue_spec.rb
@@ -28,15 +28,35 @@ describe Issue, type: &#39;model&#39; do @@ -28,15 +28,35 @@ describe Issue, type: &#39;model&#39; do
28 end 28 end
29 29
30 context "when has no title" do 30 context "when has no title" do
  31 + let(:title) { nil }
31 let(:body) { "barrr" } 32 let(:body) { "barrr" }
32 33
33 - pending "returns an error" 34 + context "#save" do
  35 + it "returns false" do
  36 + expect(issue.save).to be false
  37 + end
  38 +
  39 + it "returns an error" do
  40 + issue.save
  41 + expect(errors).to include("The issue has no title")
  42 + end
  43 + end
34 end 44 end
35 45
36 context "when has no body" do 46 context "when has no body" do
37 let(:title) { "Foo" } 47 let(:title) { "Foo" }
  48 + let(:body) { nil }
38 49
39 - pending "returns an error" 50 + context "#save" do
  51 + it "returns false" do
  52 + expect(issue.save).to be false
  53 + end
  54 +
  55 + it "returns an error" do
  56 + issue.save
  57 + expect(errors).to include("The issue has no body")
  58 + end
  59 + end
40 end 60 end
41 61
42 context "when app has a issue tracker" do 62 context "when app has a issue tracker" do