gitlab-qa 10.6.0 → 11.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +0 -12
  3. data/Gemfile.lock +1 -1
  4. data/lib/gitlab/qa/component/gitlab.rb +10 -9
  5. data/lib/gitlab/qa/runtime/env.rb +0 -40
  6. data/lib/gitlab/qa/scenario/test/integration/registry_with_cdn.rb +2 -2
  7. data/lib/gitlab/qa/version.rb +1 -1
  8. data/lib/gitlab/qa.rb +0 -1
  9. data/support/data/admin_access_token_seed.rb +4 -1
  10. metadata +2 -37
  11. data/bin/slack +0 -14
  12. data/exe/gitlab-qa-report +0 -10
  13. data/lib/gitlab/qa/report/base_test_results.rb +0 -39
  14. data/lib/gitlab/qa/report/find_set_dri.rb +0 -43
  15. data/lib/gitlab/qa/report/generate_test_session.rb +0 -275
  16. data/lib/gitlab/qa/report/gitlab_issue_client.rb +0 -190
  17. data/lib/gitlab/qa/report/gitlab_issue_dry_client.rb +0 -28
  18. data/lib/gitlab/qa/report/j_unit_test_results.rb +0 -27
  19. data/lib/gitlab/qa/report/json_test_results.rb +0 -29
  20. data/lib/gitlab/qa/report/prepare_stage_reports.rb +0 -86
  21. data/lib/gitlab/qa/report/relate_failure_issue.rb +0 -374
  22. data/lib/gitlab/qa/report/report_as_issue.rb +0 -176
  23. data/lib/gitlab/qa/report/report_results.rb +0 -64
  24. data/lib/gitlab/qa/report/results_in_issues.rb +0 -126
  25. data/lib/gitlab/qa/report/results_in_testcases.rb +0 -111
  26. data/lib/gitlab/qa/report/results_reporter_shared.rb +0 -70
  27. data/lib/gitlab/qa/report/summary_table.rb +0 -43
  28. data/lib/gitlab/qa/report/test_result.rb +0 -184
  29. data/lib/gitlab/qa/report/update_screenshot_path.rb +0 -63
  30. data/lib/gitlab/qa/reporter.rb +0 -131
  31. data/lib/gitlab/qa/runtime/token_finder.rb +0 -44
  32. data/lib/gitlab/qa/slack/post_to_slack.rb +0 -30
  33. data/lib/gitlab/qa/system_logs/finders/json_log_finder.rb +0 -65
  34. data/lib/gitlab/qa/system_logs/finders/rails/api_log_finder.rb +0 -21
  35. data/lib/gitlab/qa/system_logs/finders/rails/application_log_finder.rb +0 -21
  36. data/lib/gitlab/qa/system_logs/finders/rails/exception_log_finder.rb +0 -21
  37. data/lib/gitlab/qa/system_logs/finders/rails/graphql_log_finder.rb +0 -21
  38. data/lib/gitlab/qa/system_logs/log_types/log.rb +0 -38
  39. data/lib/gitlab/qa/system_logs/log_types/rails/api_log.rb +0 -34
  40. data/lib/gitlab/qa/system_logs/log_types/rails/application_log.rb +0 -27
  41. data/lib/gitlab/qa/system_logs/log_types/rails/exception_log.rb +0 -23
  42. data/lib/gitlab/qa/system_logs/log_types/rails/graphql_log.rb +0 -30
  43. data/lib/gitlab/qa/system_logs/shared_fields.rb +0 -29
  44. data/lib/gitlab/qa/system_logs/system_logs_formatter.rb +0 -65
@@ -1,275 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'erb'
4
- require 'date'
5
-
6
- module Gitlab
7
- module QA
8
- module Report
9
- class GenerateTestSession < ReportAsIssue
10
- def initialize(**kwargs)
11
- super
12
- @issue_type = 'issue'
13
- end
14
-
15
- private
16
-
17
- # rubocop:disable Metrics/AbcSize
18
- def run!
19
- puts "Generating test results in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
20
-
21
- tests = Dir.glob(files).flat_map do |path|
22
- puts "Loading tests in #{path}"
23
-
24
- Report::JsonTestResults.new(path).to_a
25
- end
26
-
27
- issue = gitlab.create_issue(
28
- title: "#{Time.now.strftime('%Y-%m-%d')} Test session report | #{Runtime::Env.qa_run_type}",
29
- description: generate_description(tests),
30
- labels: ['Quality', 'QA', 'triage report', pipeline_name_label]
31
- )
32
-
33
- # Workaround for https://gitlab.com/gitlab-org/gitlab/-/issues/295493
34
- unless Runtime::Env.qa_issue_url.to_s.empty?
35
- gitlab.create_issue_note(
36
- iid: issue.iid,
37
- note: "/relate #{Runtime::Env.qa_issue_url}")
38
- end
39
-
40
- File.write('REPORT_ISSUE_URL', issue.web_url)
41
- end
42
- # rubocop:enable Metrics/AbcSize
43
-
44
- def generate_description(tests)
45
- <<~MARKDOWN.rstrip
46
- ## Session summary
47
-
48
- * Deploy version: #{Runtime::Env.deploy_version}
49
- * Deploy environment: #{Runtime::Env.deploy_environment}
50
- * Pipeline: #{Runtime::Env.pipeline_from_project_name} [#{Runtime::Env.ci_pipeline_id}](#{Runtime::Env.ci_pipeline_url})
51
- #{generate_summary(tests: tests)}
52
-
53
- #{generate_failed_jobs_listing}
54
-
55
- #{generate_stages_listing(tests)}
56
-
57
- #{generate_qa_issue_relation}
58
-
59
- #{generate_link_to_dashboard}
60
- MARKDOWN
61
- end
62
-
63
- def generate_summary(tests:, tests_by_status: nil)
64
- tests_by_status ||= tests.group_by(&:status)
65
- total = tests.size
66
- passed = tests_by_status['passed']&.size || 0
67
- failed = tests_by_status['failed']&.size || 0
68
- others = total - passed - failed
69
-
70
- <<~MARKDOWN.chomp
71
- * Total #{total} tests
72
- * Passed #{passed} tests
73
- * Failed #{failed} tests
74
- * #{others} other tests (usually skipped)
75
- MARKDOWN
76
- end
77
-
78
- def generate_failed_jobs_listing
79
- failed_jobs = []
80
-
81
- client = Gitlab.client(
82
- endpoint: Runtime::Env.ci_api_v4_url,
83
- private_token: Runtime::Env.gitlab_ci_api_token)
84
-
85
- gitlab.handle_gitlab_client_exceptions do
86
- failed_jobs = client.pipeline_jobs(
87
- Runtime::Env.ci_project_id,
88
- Runtime::Env.ci_pipeline_id,
89
- scope: 'failed')
90
- end
91
-
92
- listings = failed_jobs.map do |job|
93
- allowed_to_fail = ' (allowed to fail)' if job.allow_failure
94
-
95
- "* [#{job.name}](#{job.web_url})#{allowed_to_fail}"
96
- end.join("\n")
97
-
98
- <<~MARKDOWN.chomp if failed_jobs.any?
99
- ## Failed jobs
100
-
101
- #{listings}
102
- MARKDOWN
103
- end
104
-
105
- def generate_stages_listing(tests)
106
- generate_tests_by_stage(tests).map do |stage, tests_for_stage|
107
- tests_by_status = tests_for_stage.group_by(&:status)
108
-
109
- <<~MARKDOWN.chomp
110
- ### #{stage&.capitalize || 'Unknown'}
111
-
112
- #{generate_summary(
113
- tests: tests_for_stage, tests_by_status: tests_by_status)}
114
-
115
- #{generate_testcase_listing_by_status(
116
- tests: tests_for_stage, tests_by_status: tests_by_status)}
117
- MARKDOWN
118
- end.join("\n\n")
119
- end
120
-
121
- def generate_tests_by_stage(tests)
122
- # https://about.gitlab.com/handbook/product/product-categories/#devops-stages
123
- ordering = %w[
124
- manage
125
- plan
126
- create
127
- verify
128
- package
129
- release
130
- configure
131
- monitor
132
- secure
133
- defend
134
- growth
135
- fulfillment
136
- enablement
137
- ]
138
-
139
- tests.sort_by do |test|
140
- ordering.index(test.stage) || ordering.size
141
- end.group_by(&:stage)
142
- end
143
-
144
- def generate_testcase_listing_by_status(tests:, tests_by_status:)
145
- failed_tests = tests_by_status['failed']
146
- passed_tests = tests_by_status['passed']
147
- other_tests = tests.reject do |test|
148
- test.status == 'failed' || test.status == 'passed'
149
- end
150
-
151
- [
152
- (failed_listings(failed_tests) if failed_tests),
153
- (passed_listings(passed_tests) if passed_tests),
154
- (other_listings(other_tests) if other_tests.any?)
155
- ].compact.join("\n\n")
156
- end
157
-
158
- def failed_listings(failed_tests)
159
- generate_testcase_listing(failed_tests)
160
- end
161
-
162
- def passed_listings(passed_tests)
163
- <<~MARKDOWN.chomp
164
- <details><summary>Passed tests:</summary>
165
-
166
- #{generate_testcase_listing(passed_tests, passed: true)}
167
-
168
- </details>
169
- MARKDOWN
170
- end
171
-
172
- def other_listings(other_tests)
173
- <<~MARKDOWN.chomp
174
- <details><summary>Other tests:</summary>
175
-
176
- #{generate_testcase_listing(other_tests)}
177
-
178
- </details>
179
- MARKDOWN
180
- end
181
-
182
- def generate_testcase_listing(tests, passed: false)
183
- body = tests.group_by(&:testcase).map do |testcase, tests_with_same_testcase|
184
- tests_with_same_testcase.sort_by!(&:name)
185
- [
186
- generate_test_text(testcase, tests_with_same_testcase, passed),
187
- generate_test_job(tests_with_same_testcase),
188
- generate_test_status(tests_with_same_testcase),
189
- generate_test_actions(tests_with_same_testcase)
190
- ].join(' | ')
191
- end.join("\n")
192
-
193
- <<~MARKDOWN.chomp
194
- | Test | Job | Status | Action |
195
- | - | - | - | - |
196
- #{body}
197
- MARKDOWN
198
- end
199
-
200
- def generate_test_text(testcase, tests_with_same_testcase, passed)
201
- text = tests_with_same_testcase.map(&:name).uniq.join(', ')
202
- encoded_text = ERB::Util.url_encode(text)
203
-
204
- if testcase && !passed
205
- # Workaround for reducing system notes on testcase issues
206
- # The first regex extracts the link to the issues list page from a link to a single issue show page by removing the issue id.
207
- "[#{text}](#{testcase.match(%r{[\s\S]+\/[^\/\d]+})}?state=opened&search=#{encoded_text})"
208
- else
209
- text
210
- end
211
- end
212
-
213
- def generate_test_job(tests_with_same_testcase)
214
- tests_with_same_testcase.map do |test|
215
- ci_job_id = test.ci_job_url[/\d+\z/]
216
-
217
- "[#{ci_job_id}](#{test.ci_job_url})#{' ~"quarantine"' if test.quarantine?}"
218
- end.uniq.join(', ')
219
- end
220
-
221
- def generate_test_status(tests_with_same_testcase)
222
- tests_with_same_testcase.map(&:status).uniq.map do |status|
223
- %(~"#{status}")
224
- end.join(', ')
225
- end
226
-
227
- def generate_test_actions(tests_with_same_testcase)
228
- # All failed tests would be grouped together, meaning that
229
- # if one failed, all the tests here would be failed too.
230
- # So this check is safe. Same applies to 'passed'.
231
- # But all other status might be mixing together,
232
- # we cannot assume other statuses.
233
- if tests_with_same_testcase.first.status == 'failed'
234
- tests_having_failure_issue =
235
- tests_with_same_testcase.select(&:failure_issue)
236
-
237
- if tests_having_failure_issue.any?
238
- items = tests_having_failure_issue.uniq(&:failure_issue).map do |test|
239
- "<li>[ ] [failure issue](#{test.failure_issue})</li>"
240
- end.join(' ')
241
-
242
- "<ul>#{items}</ul>"
243
- else
244
- '<ul><li>[ ] failure issue exists or was created</li></ul>'
245
- end
246
- else
247
- '-'
248
- end
249
- end
250
-
251
- def generate_qa_issue_relation
252
- return unless Runtime::Env.qa_issue_url
253
-
254
- <<~MARKDOWN.chomp
255
- ## Release QA issue
256
-
257
- * #{Runtime::Env.qa_issue_url}
258
-
259
- /relate #{Runtime::Env.qa_issue_url}
260
- MARKDOWN
261
- end
262
-
263
- def generate_link_to_dashboard
264
- return unless Runtime::Env.qa_run_type
265
-
266
- <<~MARKDOWN.chomp
267
- ## Link to Grafana dashboard for run-type of #{Runtime::Env.qa_run_type}
268
-
269
- * https://dashboards.quality.gitlab.net/d/kuNYMgDnz/test-run-metrics?orgId=1&refresh=1m&var-run_type=#{Runtime::Env.qa_run_type}
270
- MARKDOWN
271
- end
272
- end
273
- end
274
- end
275
- end
@@ -1,190 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'gitlab'
4
-
5
- module Gitlab
6
- # Monkey patch the Gitlab client to use the correct API path and add required methods
7
- class Client
8
- def team_member(project, id)
9
- get("/projects/#{url_encode(project)}/members/all/#{id}")
10
- end
11
-
12
- def issue_discussions(project, issue_id, options = {})
13
- get("/projects/#{url_encode(project)}/issues/#{issue_id}/discussions", query: options)
14
- end
15
-
16
- def add_note_to_issue_discussion_as_thread(project, issue_id, discussion_id, options = {})
17
- post("/projects/#{url_encode(project)}/issues/#{issue_id}/discussions/#{discussion_id}/notes", query: options)
18
- end
19
- end
20
-
21
- module QA
22
- module Report
23
- # The GitLab client is used for API access: https://github.com/NARKOZ/gitlab
24
- class GitlabIssueClient
25
- MAINTAINER_ACCESS_LEVEL = 40
26
- RETRY_BACK_OFF_DELAY = 60
27
- MAX_RETRY_ATTEMPTS = 3
28
-
29
- def initialize(token:, project:)
30
- @token = token
31
- @project = project
32
- @retry_backoff = 0
33
-
34
- configure_gitlab_client
35
- end
36
-
37
- def assert_user_permission!
38
- handle_gitlab_client_exceptions do
39
- user = Gitlab.user
40
- member = Gitlab.team_member(project, user.id)
41
-
42
- abort_not_permitted if member.access_level < MAINTAINER_ACCESS_LEVEL
43
- end
44
- rescue Gitlab::Error::NotFound
45
- abort_not_permitted
46
- end
47
-
48
- def find_issues(iid: nil, options: {}, &select)
49
- select ||= :itself
50
-
51
- handle_gitlab_client_exceptions do
52
- return [Gitlab.issue(project, iid)].select(&select) if iid
53
-
54
- Gitlab.issues(project, options)
55
- .auto_paginate
56
- .select(&select)
57
- end
58
- end
59
-
60
- def find_issue_discussions(iid:)
61
- handle_gitlab_client_exceptions do
62
- Gitlab.issue_discussions(project, iid, order_by: 'created_at', sort: 'asc').auto_paginate
63
- end
64
- end
65
-
66
- def create_issue(title:, description:, labels:, issue_type: 'issue')
67
- attrs = { issue_type: issue_type, description: description, labels: labels }
68
-
69
- handle_gitlab_client_exceptions do
70
- Gitlab.create_issue(project, title, attrs)
71
- end
72
- end
73
-
74
- def edit_issue(iid:, options: {})
75
- handle_gitlab_client_exceptions do
76
- Gitlab.edit_issue(project, iid, options)
77
- end
78
- end
79
-
80
- def find_issue_notes(iid:)
81
- handle_gitlab_client_exceptions do
82
- Gitlab.issue_notes(project, iid, order_by: 'created_at', sort: 'asc')&.auto_paginate
83
- end
84
- end
85
-
86
- def create_issue_note(iid:, note:)
87
- handle_gitlab_client_exceptions do
88
- Gitlab.create_issue_note(project, iid, note)
89
- end
90
- end
91
-
92
- def edit_issue_note(issue_iid:, note_id:, note:)
93
- handle_gitlab_client_exceptions do
94
- Gitlab.edit_issue_note(project, issue_iid, note_id, note)
95
- end
96
- end
97
-
98
- def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, body:)
99
- handle_gitlab_client_exceptions do
100
- Gitlab.add_note_to_issue_discussion_as_thread(project, iid, discussion_id, body: body)
101
- end
102
- end
103
-
104
- def find_user_id(username:)
105
- handle_gitlab_client_exceptions do
106
- user = Gitlab.users(username: username)&.first
107
- user['id'] unless user.nil?
108
- end
109
- end
110
-
111
- def upload_file(file_fullpath:)
112
- ignore_gitlab_client_exceptions do
113
- Gitlab.upload_file(project, file_fullpath)
114
- end
115
- end
116
-
117
- def ignore_gitlab_client_exceptions
118
- yield
119
- rescue StandardError, SystemCallError, OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout, Gitlab::Error::Error => e
120
- puts "Ignoring the following error: #{e}"
121
- end
122
-
123
- def handle_gitlab_client_exceptions
124
- yield
125
- rescue Gitlab::Error::NotFound
126
- # This error could be raised in assert_user_permission!
127
- # If so, we want it to terminate at that point
128
- raise
129
- rescue SystemCallError, OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout, Gitlab::Error::InternalServerError, Gitlab::Error::Parsing => e
130
- @retry_backoff += RETRY_BACK_OFF_DELAY
131
-
132
- raise if @retry_backoff > RETRY_BACK_OFF_DELAY * MAX_RETRY_ATTEMPTS
133
-
134
- warn_exception(e)
135
- warn("Sleeping for #{@retry_backoff} seconds before retrying...")
136
- sleep @retry_backoff
137
-
138
- retry
139
- rescue StandardError => e
140
- pipeline = QA::Runtime::Env.pipeline_from_project_name
141
- channel = case pipeline
142
- when "canary"
143
- "qa-production"
144
- when "staging-canary"
145
- "qa-staging"
146
- else
147
- "qa-#{pipeline}"
148
- end
149
- error_msg = warn_exception(e)
150
-
151
- return unless QA::Runtime::Env.ci_commit_ref_name == QA::Runtime::Env.default_branch
152
-
153
- slack_options = {
154
- channel: channel,
155
- icon_emoji: ':ci_failing:',
156
- message: <<~MSG
157
- An unexpected error occurred while reporting test results in issues.
158
- The error occurred in job: #{QA::Runtime::Env.ci_job_url}
159
- `#{error_msg}`
160
- MSG
161
- }
162
- puts "Posting Slack message to channel: #{channel}"
163
-
164
- Gitlab::QA::Slack::PostToSlack.new(**slack_options).invoke!
165
- end
166
-
167
- private
168
-
169
- attr_reader :token, :project
170
-
171
- def configure_gitlab_client
172
- Gitlab.configure do |config|
173
- config.endpoint = Runtime::Env.gitlab_api_base
174
- config.private_token = token
175
- end
176
- end
177
-
178
- def abort_not_permitted
179
- abort "You must have at least Maintainer access to the project to use this feature."
180
- end
181
-
182
- def warn_exception(error)
183
- error_msg = "#{error.class.name} #{error.message}"
184
- warn(error_msg)
185
- error_msg
186
- end
187
- end
188
- end
189
- end
190
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gitlab
4
- module QA
5
- module Report
6
- class GitlabIssueDryClient < GitlabIssueClient
7
- def create_issue(title:, description:, labels:, issue_type: 'issue')
8
- attrs = { description: description, labels: labels }
9
-
10
- puts "The following #{issue_type} would have been created:"
11
- puts "project: #{project}, title: #{title}, attrs: #{attrs}"
12
- end
13
-
14
- def edit_issue(iid:, options: {})
15
- puts "The #{project}##{iid} issue would have been updated with: #{options}"
16
- end
17
-
18
- def create_issue_note(iid:, note:)
19
- puts "The following note would have been posted on #{project}##{iid} issue: #{note}"
20
- end
21
-
22
- def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, body:)
23
- puts "The following discussion note would have been posted on #{project}##{iid} (discussion #{discussion_id}) issue: #{body}"
24
- end
25
- end
26
- end
27
- end
28
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'nokogiri'
4
-
5
- module Gitlab
6
- module QA
7
- module Report
8
- class JUnitTestResults < BaseTestResults
9
- def write
10
- # Ignore it for now
11
- end
12
-
13
- private
14
-
15
- def parse
16
- Nokogiri::XML.parse(File.read(path))
17
- end
18
-
19
- def process
20
- results.xpath('//testcase').map do |test|
21
- TestResult.from_junit(test)
22
- end
23
- end
24
- end
25
- end
26
- end
27
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
-
5
- module Gitlab
6
- module QA
7
- module Report
8
- class JsonTestResults < BaseTestResults
9
- def write
10
- json = results.merge('examples' => testcases.map(&:report))
11
-
12
- File.write(path, JSON.pretty_generate(json))
13
- end
14
-
15
- private
16
-
17
- def parse
18
- JSON.parse(File.read(path))
19
- end
20
-
21
- def process
22
- results['examples'].map do |test|
23
- TestResult.from_json(test)
24
- end
25
- end
26
- end
27
- end
28
- end
29
- end
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'nokogiri'
4
-
5
- module Gitlab
6
- module QA
7
- module Report
8
- class PrepareStageReports
9
- def initialize(input_files:)
10
- @input_files = input_files
11
- end
12
-
13
- # Create a new JUnit report file for each Stage, containing tests from that Stage alone
14
- def invoke!
15
- collate_test_cases(@input_files).each do |stage, tests|
16
- filename = "#{stage}.xml"
17
-
18
- File.write(filename, new_junit_report(tests))
19
-
20
- puts "Saved #{filename}"
21
- end
22
- end
23
-
24
- private
25
-
26
- def collate_test_cases(input_files)
27
- # Collect the test cases from the original reports and group them by Stage
28
- testcases = {}
29
-
30
- Dir.glob(input_files).each do |rspec_report_file|
31
- report = Nokogiri::XML(File.open(rspec_report_file))
32
- report.xpath('//testcase').each do |test|
33
- # The test file paths could start with any of
34
- # /qa/specs/features/api/<stage>
35
- # /qa/specs/features/browser_ui/<stage>
36
- # /qa/specs/features/ee/api/<stage>
37
- # /qa/specs/features/ee/browser_ui/<stage>
38
- # For now we assume the Stage is whatever follows api/ or browser_ui/
39
- test_path_match = test['file'].match(%r{(api|browser_ui)/([a-z0-9_]+)}i)
40
- next unless test_path_match
41
-
42
- stage = strip_number_prefix(test_path_match[2])
43
- testcases[stage] = [] unless testcases.key?(stage)
44
- testcases[stage] << test
45
- end
46
- end
47
-
48
- testcases
49
- end
50
-
51
- def strip_number_prefix(stage)
52
- stage.sub(/^\d+_/, '')
53
- end
54
-
55
- def new_junit_report(testcases)
56
- report = Nokogiri::XML::Document.new
57
- testsuite_node = report.create_element('testsuite', name: 'rspec', **collect_stats(testcases))
58
- report.root = testsuite_node
59
-
60
- testcases.each do |test|
61
- testsuite_node.add_child(test)
62
- end
63
-
64
- report.to_s
65
- end
66
-
67
- def collect_stats(testcases)
68
- stats = {
69
- tests: testcases.size,
70
- failures: 0,
71
- errors: 0,
72
- skipped: 0
73
- }
74
-
75
- testcases.each do |test|
76
- stats[:failures] += 1 unless test.search('failure').empty?
77
- stats[:errors] += 1 unless test.search('error').empty?
78
- stats[:skipped] += 1 unless test.search('skipped').empty?
79
- end
80
-
81
- stats
82
- end
83
- end
84
- end
85
- end
86
- end