kensho-cucumber-ruby 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b0c834a2f3e16b91817b5ff5a0f3501db6a4bce89b6be298daa6c276cc1a3c29
4
+ data.tar.gz: 1de5746a0de6e3df020b69d13b34ec7d40c9d428afe1d4ec5e5f0ad09b9bbcd1
5
+ SHA512:
6
+ metadata.gz: 24e3668821a7b5c15a5cd615c1ff0ccc1c0a4ccdc7ef1354c15cca658a8778b4160b7ca9490cd80f591984dcfd26c4a3e005e46c85429866fcc6f559257ec47d
7
+ data.tar.gz: eb708f88ed8a8764587979513f8162af62ee9ecd7ed77c71f3e305d00c87d1cc2356872eb249b32e2fdc3aa2fb8440edfc86f7a2f8125883b79c42a2a90cbf69
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # kensho-cucumber-ruby
2
+
3
+ A Cucumber 7+ formatter that emits the canonical [Kensho v1](../schema)
4
+ JSON format. Run your suite, point the `kensho` CLI at `kensho-results/`,
5
+ and get a self-contained static HTML report.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem 'cucumber'
12
+ gem 'kensho-cucumber-ruby', require: false, group: :test
13
+ ```
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ## Run
20
+
21
+ ```bash
22
+ bundle exec cucumber --require kensho/cucumber --format Kensho::Cucumber::Formatter
23
+
24
+ # render the HTML report (uses the JS CLI from the same monorepo):
25
+ npx kensho generate
26
+ npx kensho open
27
+ ```
28
+
29
+ You can pin the formatter from `cucumber.yml`:
30
+
31
+ ```yaml
32
+ default: --require kensho/cucumber --format Kensho::Cucumber::Formatter --format pretty
33
+ ```
34
+
35
+ ## What it produces
36
+
37
+ - `kensho-results/run.json` — run manifest (project, env, totals, timing).
38
+ - `kensho-results/cases/<stableId>.json` — one file per scenario.
39
+ - `kensho-results/attachments/<caseId>/...` — anything `attach`ed in a step.
40
+
41
+ Each case gets a **stable id** (`tc_<16 hex>`) hashed from
42
+ `feature › scenario` + file path so test history correlates across runs
43
+ and across adapters (the JS adapters use the same FNV-1a-based hash).
44
+
45
+ ## Mapping
46
+
47
+ | Gherkin | Kensho field |
48
+ | ----------------------------------------- | ----------------------------------------- |
49
+ | Feature | `case.behavior.feature` and `case.suite[0]` |
50
+ | Rule (parent of the scenario) | `case.behavior.epic` |
51
+ | Scenario name | `case.name` and `case.behavior.scenario` |
52
+ | Each Given/When/Then | one entry in `case.steps[]` (`title` includes the keyword) |
53
+ | Data table on a step | `step.parameters[]` (kind: data-row) |
54
+ | Doc string on a step | `step.parameters[]` (kind: argument) |
55
+
56
+ ## Tag conventions
57
+
58
+ | Tag | Effect |
59
+ | -------------------------------------- | ------------------------------------------------------ |
60
+ | `@critical`, `@blocker`, `@normal`, `@minor`, `@trivial` | Sets `case.severity`. |
61
+ | `@severity:critical` | Same as above, explicit form. |
62
+ | `@kensho.label.<key>=<value>` | Adds `case.labels.<key> = '<value>'`. |
63
+ | `@kensho.link.<kind>=<label>` | Adds `case.links += { kind, label, url: label }`. Use for ticket IDs. |
64
+ | `@kensho.url.<kind>=<https://…>` | Adds `case.links += { kind, url }`. Use for full URLs. |
65
+ | any other `@tag` | Becomes a tag on `case.tags`. |
66
+
67
+ ```gherkin
68
+ @critical @smoke @kensho.label.team=growth @kensho.link.jira=PROJ-123
69
+ Scenario: User can log in
70
+ Given a registered user
71
+ When they submit valid credentials
72
+ Then they land on the home page
73
+ ```
74
+
75
+ ## Status mapping
76
+
77
+ | Cucumber outcome | Kensho status |
78
+ | ----------------------------- | ------------- |
79
+ | passed | `pass` |
80
+ | failed | `fail` |
81
+ | pending | `skip` |
82
+ | skipped | `skip` |
83
+ | undefined / ambiguous | `broken` |
84
+
85
+ ## Environment auto-detected
86
+
87
+ GitHub Actions, CircleCI, GitLab CI, Jenkins, Buildkite, Azure DevOps —
88
+ CI provider, branch, commit, run URL, OS, architecture, Ruby version.
89
+
90
+ Pass `KR_AUTHOR`, `KR_COMMIT_MSG`, `KR_STAGE`, `KR_BASE_URL`,
91
+ `KR_APP_VERSION`, `KR_BUILD_NUMBER`, `KR_RELEASE`, `KR_REGION`,
92
+ `KR_LOCALE`, `KR_TRIGGER`, or `KR_FEATURE` as env vars to populate the
93
+ matching fields on `run.env`.
94
+
95
+ ## CLI / env flags
96
+
97
+ ```
98
+ KENSHO_OUTPUT output dir (default ./kensho-results)
99
+ KENSHO_PROJECT_NAME project name in run.json
100
+ KENSHO_PROJECT_SLUG project slug
101
+ KENSHO_RUN_ID override the auto-generated run id
102
+ ```
103
+
104
+ ## Design notes
105
+
106
+ - Zero runtime dependencies beyond `cucumber` itself. The schema lives
107
+ in the JS workspace; we vendor the minimum (id-hashing, env capture)
108
+ inline so the gem installs in seconds.
109
+ - The formatter never raises — broken adapters must not break a test
110
+ run.
111
+ - Stable IDs match the JS `stableCaseId` byte-for-byte, so
112
+ `cucumber-ruby` and `cucumber-js` runs of the same `.feature` file
113
+ roll up to the same history on the platform.
114
+
115
+ ## License
116
+
117
+ Apache-2.0.
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vendored slice of the Kensho v1 schema contract.
4
+ #
5
+ # We deliberately do not depend on the JS @kaizenreport/kensho-schema package.
6
+ # Adapters need to stay tiny and standalone, so we re-implement the few things
7
+ # the formatter cares about: the stable case-id hash, the status enum, the
8
+ # attachment kind/MIME tables, and the environment-capture function.
9
+
10
+ require 'rbconfig'
11
+
12
+ module Kensho
13
+ module Schema
14
+ SCHEMA_VERSION = 'kensho/v1'
15
+
16
+ STATUS = %w[pass fail broken skip].freeze
17
+ STEP_STATUS = %w[pass fail skip].freeze
18
+ SEVERITY = %w[blocker critical normal minor trivial].freeze
19
+ ATTACHMENT_KINDS = %w[
20
+ screenshot video trace har text json html dom-snapshot log
21
+ ].freeze
22
+
23
+ MIME_BY_EXT = {
24
+ '.png' => 'image/png',
25
+ '.jpg' => 'image/jpeg',
26
+ '.jpeg' => 'image/jpeg',
27
+ '.webp' => 'image/webp',
28
+ '.webm' => 'video/webm',
29
+ '.mp4' => 'video/mp4',
30
+ '.zip' => 'application/zip',
31
+ '.html' => 'text/html',
32
+ '.json' => 'application/json',
33
+ '.txt' => 'text/plain',
34
+ '.log' => 'text/plain',
35
+ '.har' => 'application/json'
36
+ }.freeze
37
+
38
+ KIND_BY_EXT = {
39
+ '.png' => 'screenshot',
40
+ '.jpg' => 'screenshot',
41
+ '.jpeg' => 'screenshot',
42
+ '.webp' => 'screenshot',
43
+ '.webm' => 'video',
44
+ '.mp4' => 'video',
45
+ '.zip' => 'trace',
46
+ '.html' => 'html',
47
+ '.json' => 'json',
48
+ '.txt' => 'text',
49
+ '.log' => 'log',
50
+ '.har' => 'har'
51
+ }.freeze
52
+
53
+ FNV_OFFSET_1 = 0x811c9dc5
54
+ FNV_OFFSET_2 = 0x01000193
55
+ FNV_PRIME_1 = 0x01000193
56
+ FNV_PRIME_2 = 0x85ebca6b
57
+ MASK32 = 0xffffffff
58
+
59
+ # Mirrors stableCaseId from packages/schema/index.js byte-for-byte:
60
+ # double FNV-1a with two different secondary primes so the two 32-bit
61
+ # chunks come from independent rolling states. Must stay byte-compatible
62
+ # with the JS implementation or test history won't line up across
63
+ # adapters.
64
+ def self.stable_case_id(full_name, file_path)
65
+ s = "#{full_name || ''}::#{file_path || ''}"
66
+ h1 = FNV_OFFSET_1
67
+ h2 = FNV_OFFSET_2
68
+ s.each_codepoint do |c|
69
+ h1 = ((h1 ^ c) * FNV_PRIME_1) & MASK32
70
+ h2 = ((h2 ^ c) * FNV_PRIME_2) & MASK32
71
+ end
72
+ format('tc_%08x%08x', h1, h2)
73
+ end
74
+
75
+ def self.kind_and_mime_for(path)
76
+ ext = File.extname(path.to_s).downcase
77
+ [KIND_BY_EXT[ext] || 'text', MIME_BY_EXT[ext] || 'application/octet-stream']
78
+ end
79
+
80
+ def self.normalize_os
81
+ host = RbConfig::CONFIG['host_os'].to_s.downcase
82
+ return 'linux' if host.include?('linux')
83
+ return 'darwin' if host.include?('darwin')
84
+ return 'win32' if host.include?('mswin') || host.include?('mingw') || host.include?('cygwin')
85
+
86
+ host
87
+ end
88
+
89
+ # Normalize SSH-style git URLs to https. ``git@github.com:foo/bar.git``
90
+ # becomes ``https://github.com/foo/bar``; trailing ``.git`` is dropped.
91
+ def self.normalize_git_url(u)
92
+ return nil if u.nil? || u.empty?
93
+
94
+ m = u.match(%r{^(?:ssh://)?git@([^:/]+)[:/](.+?)(?:\.git)?$})
95
+ return "https://#{m[1]}/#{m[2]}" if m
96
+
97
+ u.sub(/\.git\z/, '')
98
+ end
99
+
100
+ # CI / environment metadata for run.env. Matches the helpers in the JS
101
+ # adapters so a Kensho report looks the same regardless of language.
102
+ def self.env_info(framework_version: nil)
103
+ ci_env = ENV['CI']
104
+ ci =
105
+ if ci_env && ENV['GITHUB_ACTIONS']
106
+ 'github-actions'
107
+ elsif ci_env && ENV['CIRCLECI']
108
+ 'circleci'
109
+ elsif ci_env && ENV['GITLAB_CI']
110
+ 'gitlab'
111
+ elsif ci_env && ENV['JENKINS_URL']
112
+ 'jenkins'
113
+ elsif ci_env && ENV['BUILDKITE']
114
+ 'buildkite'
115
+ elsif ci_env && ENV['TF_BUILD']
116
+ 'azure-devops'
117
+ elsif ci_env
118
+ 'unknown'
119
+ else
120
+ 'local'
121
+ end
122
+
123
+ branch = ENV['GITHUB_REF_NAME'] || ENV['CIRCLE_BRANCH'] ||
124
+ ENV['CI_COMMIT_REF_NAME'] || ENV['BUILDKITE_BRANCH']
125
+ commit = ENV['GITHUB_SHA'] || ENV['CIRCLE_SHA1'] ||
126
+ ENV['CI_COMMIT_SHA'] || ENV['BUILDKITE_COMMIT']
127
+ author = ENV['KR_AUTHOR'] || ENV['GITHUB_ACTOR']
128
+ commit_msg = ENV['KR_COMMIT_MSG']
129
+
130
+ run_url = ENV['KR_RUN_URL'] ||
131
+ if ENV['GITHUB_SERVER_URL'] && ENV['GITHUB_REPOSITORY'] && ENV['GITHUB_RUN_ID']
132
+ "#{ENV['GITHUB_SERVER_URL']}/#{ENV['GITHUB_REPOSITORY']}/actions/runs/#{ENV['GITHUB_RUN_ID']}"
133
+ elsif ENV['CIRCLE_BUILD_URL']
134
+ ENV['CIRCLE_BUILD_URL']
135
+ elsif ENV['CI_JOB_URL']
136
+ ENV['CI_JOB_URL']
137
+ elsif ENV['BUILD_URL']
138
+ ENV['BUILD_URL']
139
+ elsif ENV['BUILDKITE_BUILD_URL']
140
+ ENV['BUILDKITE_BUILD_URL']
141
+ end
142
+
143
+ # repoUrl — KR_REPO_URL override → GitHub Actions / GitLab / Bitbucket /
144
+ # Azure / SSH-style URLs from CircleCI / Buildkite / Jenkins (normalized).
145
+ gh_server = ENV['GITHUB_SERVER_URL']
146
+ gh_repo = ENV['GITHUB_REPOSITORY']
147
+ repo_url = ENV['KR_REPO_URL'] ||
148
+ (gh_server && gh_repo ? "#{gh_server}/#{gh_repo}" : nil) ||
149
+ ENV['CI_PROJECT_URL'] ||
150
+ ENV['BITBUCKET_GIT_HTTP_ORIGIN'] ||
151
+ normalize_git_url(ENV['BUILD_REPOSITORY_URI']) ||
152
+ normalize_git_url(ENV['CIRCLE_REPOSITORY_URL'] ||
153
+ ENV['BUILDKITE_REPO'] ||
154
+ ENV['GIT_URL'])
155
+
156
+ info = {
157
+ 'ci' => ci,
158
+ 'os' => normalize_os,
159
+ 'arch' => RbConfig::CONFIG['host_cpu'].to_s
160
+ }
161
+
162
+ ruby_version = "#{RUBY_VERSION}p#{defined?(RUBY_PATCHLEVEL) ? RUBY_PATCHLEVEL : '0'}"
163
+ info['vars'] = { 'rubyVersion' => ruby_version }
164
+ info['vars']['frameworkVersion'] = framework_version if framework_version
165
+
166
+ info['branch'] = branch if branch
167
+ info['commit'] = commit if commit
168
+ info['commitMsg'] = commit_msg if commit_msg
169
+ info['author'] = author if author
170
+ info['runUrl'] = run_url if run_url
171
+ info['repoUrl'] = repo_url if repo_url
172
+ os_version = RbConfig::CONFIG['host_os']
173
+ info['osVersion'] = os_version if os_version
174
+
175
+ [
176
+ ['KR_STAGE', 'stage'],
177
+ ['KR_BASE_URL', 'baseUrl'],
178
+ ['KR_APP_VERSION', 'appVersion'],
179
+ ['KR_BUILD_NUMBER', 'buildNumber'],
180
+ ['KR_RELEASE', 'release'],
181
+ ['KR_REGION', 'region'],
182
+ ['KR_LOCALE', 'locale'],
183
+ ['KR_TRIGGER', 'trigger'],
184
+ ['KR_FEATURE', 'feature']
185
+ ].each do |env_var, key|
186
+ v = ENV[env_var]
187
+ info[key] = v if v && !v.empty?
188
+ end
189
+
190
+ info
191
+ end
192
+
193
+ def self.iso_now
194
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')
195
+ end
196
+
197
+ def self.iso_from_seconds(secs)
198
+ Time.at(secs).utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')
199
+ end
200
+
201
+ def self.slugify(name)
202
+ s = name.to_s.downcase.strip
203
+ s = s.gsub(/[^a-z0-9_-]+/, '-').gsub(/\A-+|-+\z/, '')
204
+ s.empty? ? 'unknown' : s
205
+ end
206
+
207
+ def self.default_run_id
208
+ "run_#{Time.now.utc.strftime('%Y%m%d%H%M%S')}"
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,482 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+ require_relative '../_schema'
7
+
8
+ # Cucumber 7+ formatter that emits Kensho v1 results.
9
+ #
10
+ # Cucumber-Ruby's formatter API gives us a `Cucumber::Configuration` and a
11
+ # `config.on_event(:event_name)` event bus. We subscribe to:
12
+ #
13
+ # :test_run_started — open the output dir
14
+ # :test_case_started — start accumulating per-scenario data
15
+ # :test_step_finished — append a step result
16
+ # :test_case_finished — write cases/<id>.json
17
+ # :test_run_finished — write run.json
18
+ #
19
+ # Each scenario becomes a Kensho case; each Gherkin step becomes a Kensho
20
+ # step. Data tables on a step are emitted as `step.parameters[]`. The
21
+ # scenario's `attach` calls (Cucumber's built-in attachment API) are
22
+ # routed into `case.attachments[]`.
23
+ #
24
+ # Tags drive metadata:
25
+ #
26
+ # @severity:critical → case.severity
27
+ # @critical / @blocker / ... → case.severity (shorthand)
28
+ # @kensho.label.team=growth → case.labels.team = 'growth'
29
+ # @kensho.link.jira=PROJ-123 → case.links += { kind: 'jira', label: 'PROJ-123', url: 'PROJ-123' }
30
+ # @kensho.url.jira=https://… → case.links (full url form)
31
+ # any other @tag → case.tags
32
+
33
+ module Kensho
34
+ module Cucumber
35
+ class Formatter
36
+ SEVERITY_TAGS = Kensho::Schema::SEVERITY.dup.freeze
37
+
38
+ attr_reader :config
39
+
40
+ def initialize(config)
41
+ @config = config
42
+ @ast_lookup = nil
43
+ if defined?(::Cucumber::Formatter::AstLookup)
44
+ begin
45
+ @ast_lookup = ::Cucumber::Formatter::AstLookup.new(config)
46
+ rescue StandardError
47
+ @ast_lookup = nil
48
+ end
49
+ end
50
+ @output_dir = File.expand_path(ENV['KENSHO_OUTPUT'] || (defined?(Kensho::Cucumber::DEFAULT_OUTPUT) ? Kensho::Cucumber::DEFAULT_OUTPUT : 'kensho-results'))
51
+ @cases_dir = File.join(@output_dir, 'cases')
52
+ @attachments_dir = File.join(@output_dir, 'attachments')
53
+ @project = {
54
+ 'name' => ENV['KENSHO_PROJECT_NAME'] || 'Unknown project',
55
+ 'slug' => ENV['KENSHO_PROJECT_SLUG'] || Kensho::Schema.slugify(ENV['KENSHO_PROJECT_NAME'] || 'unknown')
56
+ }
57
+ @run_id = ENV['KENSHO_RUN_ID'] || Kensho::Schema.default_run_id
58
+ @started_at = Kensho::Schema.iso_now
59
+ @started_perf = Process.clock_gettime(Process::CLOCK_MONOTONIC)
60
+ @rootpath = Dir.pwd
61
+ @cases_by_id = {}
62
+ @ids_seen = Hash.new(0)
63
+
64
+ # Per-test-case scratch keyed by test_case (Cucumber's TestCase obj).
65
+ @scratch = {}
66
+
67
+ FileUtils.mkdir_p(@cases_dir)
68
+ FileUtils.mkdir_p(@attachments_dir)
69
+
70
+ bind_events!
71
+ end
72
+
73
+ def bind_events!
74
+ return unless @config.respond_to?(:on_event)
75
+
76
+ @config.on_event(:test_case_started) { |event| on_test_case_started(event) }
77
+ @config.on_event(:test_step_finished) { |event| on_test_step_finished(event) }
78
+ @config.on_event(:test_case_finished) { |event| on_test_case_finished(event) }
79
+ @config.on_event(:test_run_finished) { |_event| on_test_run_finished }
80
+ end
81
+
82
+ # ------- per-event handlers ------- #
83
+
84
+ def on_test_case_started(event)
85
+ test_case = event.test_case
86
+ @current_test_case = test_case
87
+ scenario_name = test_case.name.to_s
88
+ feature_uri = test_case.location.file rescue nil
89
+ feature_uri ||= (test_case.respond_to?(:source_location) ? test_case.source_location.file : nil)
90
+ feature_name, rule_name = feature_and_rule(test_case)
91
+
92
+ full_name = [feature_name, scenario_name].compact.reject(&:empty?).join(' › ')
93
+ file_path = relpath(feature_uri, @rootpath)
94
+ line = (test_case.location.lines.first rescue nil) || (test_case.location.line rescue nil)
95
+
96
+ base_id = Kensho::Schema.stable_case_id(full_name, file_path)
97
+ seen = @ids_seen[base_id]
98
+ case_id = seen.zero? ? base_id : "#{base_id}_#{seen + 1}"
99
+ @ids_seen[base_id] = seen + 1
100
+
101
+ tags = (test_case.tags || []).map { |t| t.respond_to?(:name) ? t.name.to_s : t.to_s }
102
+ clean_tags = tags.map { |t| t.start_with?('@') ? t[1..] : t }
103
+
104
+ severity = severity_from_tags(clean_tags)
105
+ labels = labels_from_tags(clean_tags)
106
+ links = links_from_tags(clean_tags)
107
+
108
+ plain_tags = clean_tags.reject do |t|
109
+ t.start_with?('kensho.label.') || t.start_with?('kensho.link.') ||
110
+ t.start_with?('kensho.url.') || t.start_with?('severity:') ||
111
+ SEVERITY_TAGS.include?(t)
112
+ end
113
+
114
+ behavior = {}
115
+ behavior['feature'] = feature_name.to_s if feature_name && !feature_name.empty?
116
+ behavior['epic'] = rule_name.to_s if rule_name && !rule_name.empty?
117
+ behavior['scenario'] = scenario_name if scenario_name && !scenario_name.empty?
118
+
119
+ case_obj = {
120
+ 'id' => case_id,
121
+ 'name' => scenario_name,
122
+ 'fullName' => full_name,
123
+ 'status' => 'skip',
124
+ 'startedAt' => Kensho::Schema.iso_now,
125
+ 'duration' => 0,
126
+ 'retries' => 0,
127
+ 'platform' => Kensho::Schema.normalize_os
128
+ }
129
+ case_obj['filePath'] = file_path if file_path
130
+ case_obj['line'] = line.to_i if line
131
+ case_obj['suite'] = [feature_name].compact.reject(&:empty?)
132
+ case_obj.delete('suite') if case_obj['suite'].empty?
133
+ case_obj['tags'] = plain_tags unless plain_tags.empty?
134
+ case_obj['severity'] = severity if severity
135
+ case_obj['labels'] = labels unless labels.empty?
136
+ case_obj['links'] = links unless links.empty?
137
+ case_obj['behavior'] = behavior unless behavior.empty?
138
+
139
+ scratch = {
140
+ case_obj: case_obj,
141
+ started_perf: Process.clock_gettime(Process::CLOCK_MONOTONIC),
142
+ started_iso: case_obj['startedAt'],
143
+ steps: [],
144
+ step_index: 0,
145
+ worst_status: 'pass',
146
+ first_error: nil,
147
+ first_exception: nil,
148
+ attachments: []
149
+ }
150
+ @scratch[test_case.object_id] = scratch
151
+ end
152
+
153
+ def on_test_step_finished(event)
154
+ test_case = @current_test_case
155
+ return unless test_case
156
+
157
+ scratch = @scratch[test_case.object_id]
158
+ return unless scratch
159
+
160
+ step = event.test_step
161
+ result = event.result
162
+
163
+ title, parameters = step_title_and_params(step)
164
+ # Cucumber emits hooks as test steps with no Gherkin source; skip
165
+ # those from the timeline so the report shows just the user steps.
166
+ return if title.nil?
167
+
168
+ status = map_step_status(result)
169
+ case_status = map_case_status(result)
170
+ if case_status == 'fail' || case_status == 'broken'
171
+ scratch[:worst_status] = case_status
172
+ elsif case_status == 'skip' && scratch[:worst_status] == 'pass'
173
+ scratch[:worst_status] = 'skip'
174
+ end
175
+
176
+ if (result.respond_to?(:failed?) && result.failed?) ||
177
+ (result.respond_to?(:exception) && result.exception)
178
+ scratch[:first_exception] ||= result.exception if result.respond_to?(:exception)
179
+ scratch[:first_error] ||= (result.respond_to?(:exception) && result.exception) ?
180
+ result.exception.message.to_s :
181
+ result.to_s
182
+ end
183
+
184
+ duration_ms = duration_ms_from_result(result)
185
+ idx = scratch[:step_index]
186
+ scratch[:step_index] += 1
187
+
188
+ step_obj = {
189
+ 'id' => "step_#{idx}_#{SecureRandom.hex(3)}",
190
+ 'title' => title,
191
+ 'status' => status,
192
+ 'startedAt' => Kensho::Schema.iso_now,
193
+ 'duration' => duration_ms
194
+ }
195
+ step_obj['parameters'] = parameters unless parameters.empty?
196
+ scratch[:steps] << step_obj
197
+ end
198
+
199
+ def on_test_case_finished(event)
200
+ test_case = event.test_case
201
+ scratch = @scratch.delete(test_case.object_id)
202
+ return unless scratch
203
+
204
+ case_obj = scratch[:case_obj]
205
+ result = event.result
206
+
207
+ status =
208
+ if result.respond_to?(:passed?) && result.passed?
209
+ scratch[:worst_status] == 'pass' ? 'pass' : scratch[:worst_status]
210
+ elsif result.respond_to?(:failed?) && result.failed?
211
+ 'fail'
212
+ elsif result.respond_to?(:undefined?) && result.undefined?
213
+ 'broken'
214
+ elsif result.respond_to?(:skipped?) && result.skipped?
215
+ 'skip'
216
+ elsif result.respond_to?(:pending?) && result.pending?
217
+ 'skip'
218
+ else
219
+ scratch[:worst_status]
220
+ end
221
+
222
+ duration_ms = [(((Process.clock_gettime(Process::CLOCK_MONOTONIC) - scratch[:started_perf]) * 1000.0).round), 0].max
223
+
224
+ case_obj['status'] = status
225
+ case_obj['finishedAt'] = Kensho::Schema.iso_now
226
+ case_obj['duration'] = duration_ms
227
+ case_obj['steps'] = scratch[:steps] unless scratch[:steps].empty?
228
+
229
+ if scratch[:first_exception] || scratch[:first_error]
230
+ ex = scratch[:first_exception]
231
+ message = ex ? ex.message.to_s.lines.first.to_s.strip : scratch[:first_error].to_s.lines.first.to_s.strip
232
+ err = { 'message' => message.empty? ? 'failure' : message }
233
+ if ex
234
+ err['type'] = ex.class.name.to_s
235
+ stack = [ex.message.to_s]
236
+ stack.concat(Array(ex.backtrace).first(20))
237
+ err['stack'] = stack.join("\n")
238
+ elsif scratch[:first_error] && scratch[:first_error].to_s != message
239
+ err['stack'] = scratch[:first_error].to_s
240
+ end
241
+ case_obj['errors'] = [err]
242
+ end
243
+
244
+ case_obj['attachments'] = scratch[:attachments] unless scratch[:attachments].empty?
245
+
246
+ @cases_by_id[case_obj['id']] = case_obj
247
+ write_case(case_obj)
248
+ end
249
+
250
+ def on_test_run_finished
251
+ write_run_json
252
+ rescue StandardError => e
253
+ warn "[kensho] failed to write run.json: #{e.message}"
254
+ end
255
+
256
+ # ------- helpers ------- #
257
+
258
+ def step_title_and_params(step)
259
+ return [nil, []] if step.respond_to?(:hook?) && step.hook?
260
+
261
+ text =
262
+ if step.respond_to?(:text)
263
+ step.text.to_s
264
+ elsif step.respond_to?(:name)
265
+ step.name.to_s
266
+ else
267
+ ''
268
+ end
269
+ return [nil, []] if text.empty?
270
+
271
+ keyword = ''
272
+ gherkin_step = nil
273
+ if @ast_lookup
274
+ begin
275
+ src = @ast_lookup.step_source(step)
276
+ gherkin_step = src && src.respond_to?(:step) ? src.step : nil
277
+ rescue StandardError
278
+ gherkin_step = nil
279
+ end
280
+ keyword = gherkin_step.keyword.to_s if gherkin_step && gherkin_step.respond_to?(:keyword)
281
+ end
282
+ title = keyword && !keyword.empty? ? "#{keyword}#{text}" : text
283
+
284
+ params = []
285
+ if gherkin_step && gherkin_step.respond_to?(:data_table) && gherkin_step.data_table
286
+ rows = gherkin_step.data_table.rows
287
+ rows.each_with_index do |row, ri|
288
+ cells = row.respond_to?(:cells) ? row.cells : []
289
+ cells.each_with_index do |cell, ci|
290
+ value = cell.respond_to?(:value) ? cell.value.to_s : cell.to_s
291
+ params << {
292
+ 'name' => "row#{ri}.col#{ci}",
293
+ 'value' => value,
294
+ 'kind' => 'data-row'
295
+ }
296
+ end
297
+ end
298
+ end
299
+ if gherkin_step && gherkin_step.respond_to?(:doc_string) && gherkin_step.doc_string
300
+ ds = gherkin_step.doc_string
301
+ params << {
302
+ 'name' => 'docstring',
303
+ 'value' => ds.respond_to?(:content) ? ds.content.to_s : ds.to_s,
304
+ 'kind' => 'argument'
305
+ }
306
+ end
307
+
308
+ [title, params]
309
+ end
310
+
311
+ def feature_and_rule(test_case)
312
+ feature = nil
313
+ rule = nil
314
+ if @ast_lookup
315
+ uri = test_case.location.file rescue nil
316
+ if uri
317
+ doc = nil
318
+ begin
319
+ doc = @ast_lookup.gherkin_document(uri)
320
+ rescue StandardError
321
+ doc = nil
322
+ end
323
+ if doc && doc.respond_to?(:feature) && doc.feature
324
+ feature = doc.feature.name.to_s if doc.feature.respond_to?(:name)
325
+ rule = walk_for_rule(doc.feature, test_case.location.lines.max) if doc.feature.respond_to?(:children)
326
+ end
327
+ end
328
+ end
329
+ [feature, rule]
330
+ end
331
+
332
+ def walk_for_rule(feature, line)
333
+ feature.children.each do |child|
334
+ next unless child.respond_to?(:rule) && child.rule
335
+
336
+ rule = child.rule
337
+ # Lines covered by this rule include all its scenarios.
338
+ covered = false
339
+ if rule.respond_to?(:children)
340
+ rule.children.each do |sc_child|
341
+ next unless sc_child.respond_to?(:scenario) && sc_child.scenario
342
+
343
+ if sc_child.scenario.location.line == line ||
344
+ sc_child.scenario.steps.any? { |s| s.location.line == line }
345
+ covered = true
346
+ break
347
+ end
348
+ end
349
+ end
350
+ return rule.name.to_s if covered && rule.respond_to?(:name)
351
+ end
352
+ nil
353
+ end
354
+
355
+ def severity_from_tags(tags)
356
+ tags.each do |t|
357
+ return t.split(':', 2)[1].to_s.downcase if t.start_with?('severity:') &&
358
+ SEVERITY_TAGS.include?(t.split(':', 2)[1].to_s.downcase)
359
+ return t.downcase if SEVERITY_TAGS.include?(t.downcase)
360
+ end
361
+ nil
362
+ end
363
+
364
+ def labels_from_tags(tags)
365
+ labels = {}
366
+ tags.each do |t|
367
+ next unless t.start_with?('kensho.label.')
368
+
369
+ rest = t.sub('kensho.label.', '')
370
+ key, value = rest.split('=', 2)
371
+ next if key.nil? || key.empty?
372
+
373
+ labels[key] = (value || 'true').to_s
374
+ end
375
+ labels
376
+ end
377
+
378
+ def links_from_tags(tags)
379
+ links = []
380
+ tags.each do |t|
381
+ if t.start_with?('kensho.url.')
382
+ rest = t.sub('kensho.url.', '')
383
+ kind, url = rest.split('=', 2)
384
+ next if url.nil? || url.empty?
385
+
386
+ link = { 'url' => url }
387
+ link['kind'] = kind if kind && !kind.empty?
388
+ links << link
389
+ elsif t.start_with?('kensho.link.')
390
+ rest = t.sub('kensho.link.', '')
391
+ kind, label = rest.split('=', 2)
392
+ next if label.nil? || label.empty?
393
+
394
+ link = { 'url' => label, 'label' => label }
395
+ link['kind'] = kind if kind && !kind.empty?
396
+ links << link
397
+ end
398
+ end
399
+ links
400
+ end
401
+
402
+ def map_case_status(result)
403
+ return 'pass' if result.respond_to?(:passed?) && result.passed?
404
+ return 'fail' if result.respond_to?(:failed?) && result.failed?
405
+ return 'broken' if result.respond_to?(:undefined?) && result.undefined?
406
+ return 'broken' if result.class.name.to_s.end_with?('::Undefined', '::Ambiguous')
407
+ return 'skip' if result.respond_to?(:skipped?) && result.skipped?
408
+ return 'skip' if result.respond_to?(:pending?) && result.pending?
409
+
410
+ 'pass'
411
+ end
412
+
413
+ def map_step_status(result)
414
+ s = map_case_status(result)
415
+ s == 'broken' ? 'fail' : s
416
+ end
417
+
418
+ def duration_ms_from_result(result)
419
+ return 0 unless result.respond_to?(:duration) && result.duration
420
+
421
+ d = result.duration
422
+ if d.respond_to?(:nanoseconds)
423
+ (d.nanoseconds / 1_000_000.0).round
424
+ elsif d.respond_to?(:to_f)
425
+ (d.to_f * 1000.0).round
426
+ else
427
+ 0
428
+ end
429
+ end
430
+
431
+ def write_case(case_obj)
432
+ path = File.join(@cases_dir, "#{case_obj['id']}.json")
433
+ File.write(path, JSON.pretty_generate(case_obj))
434
+ rescue StandardError => e
435
+ warn "[kensho] could not write #{File.basename(path)}: #{e.message}"
436
+ end
437
+
438
+ def write_run_json
439
+ finished_at = Kensho::Schema.iso_now
440
+ cases = @cases_by_id.values
441
+ totals = { 'pass' => 0, 'fail' => 0, 'broken' => 0, 'skip' => 0 }
442
+ cases.each { |c| totals[c['status']] += 1 if totals.key?(c['status']) }
443
+ duration_ms = [((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_perf) * 1000.0).round, 0].max
444
+ framework_version = cucumber_version
445
+
446
+ run = {
447
+ 'schemaVersion' => Kensho::Schema::SCHEMA_VERSION,
448
+ 'id' => @run_id,
449
+ 'project' => @project,
450
+ 'framework' => { 'name' => 'cucumber-ruby', 'version' => framework_version },
451
+ 'env' => Kensho::Schema.env_info(framework_version: framework_version),
452
+ 'startedAt' => @started_at,
453
+ 'finishedAt' => finished_at,
454
+ 'totals' => totals,
455
+ 'durationMs' => duration_ms,
456
+ 'testCases' => cases
457
+ }
458
+ File.write(File.join(@output_dir, 'run.json'), JSON.pretty_generate(run))
459
+ end
460
+
461
+ def cucumber_version
462
+ if defined?(::Cucumber::VERSION)
463
+ ::Cucumber::VERSION.to_s
464
+ else
465
+ 'unknown'
466
+ end
467
+ end
468
+
469
+ def relpath(path, root)
470
+ return nil unless path
471
+
472
+ absolute_path = File.absolute_path(path.to_s)
473
+ absolute_root = File.absolute_path(root.to_s)
474
+ if absolute_path.start_with?(absolute_root + File::SEPARATOR)
475
+ absolute_path[(absolute_root.length + 1)..]
476
+ else
477
+ path.to_s.sub(%r{\A\./}, '')
478
+ end
479
+ end
480
+ end
481
+ end
482
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kensho
4
+ module Cucumber
5
+ VERSION = '0.1.1'
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public entrypoint for kensho-cucumber-ruby.
4
+ #
5
+ # Usage:
6
+ # bundle exec cucumber --format Kensho::Cucumber::Formatter
7
+
8
+ require_relative '_schema'
9
+ require_relative 'cucumber/version'
10
+ require_relative 'cucumber/formatter'
11
+
12
+ module Kensho
13
+ module Cucumber
14
+ DEFAULT_OUTPUT = 'kensho-results'
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kensho-cucumber-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - KaizenReport
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: cucumber
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ description: |
28
+ Cucumber 7+ formatter that writes a Kensho v1 result bundle
29
+ (run.json + cases/<id>.json + attachments/) into ./kensho-results.
30
+ Each scenario becomes a Kensho case; each Gherkin step becomes a
31
+ Kensho step.
32
+ email:
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - README.md
38
+ - lib/kensho/_schema.rb
39
+ - lib/kensho/cucumber.rb
40
+ - lib/kensho/cucumber/formatter.rb
41
+ - lib/kensho/cucumber/version.rb
42
+ homepage: https://github.com/brandon1794/kensho
43
+ licenses:
44
+ - Apache-2.0
45
+ metadata:
46
+ homepage_uri: https://github.com/brandon1794/kensho
47
+ source_code_uri: https://github.com/brandon1794/kensho
48
+ bug_tracker_uri: https://github.com/brandon1794/kensho/issues
49
+ rubygems_mfa_required: 'true'
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '2.6'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.5.22
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: Kensho reporter for Cucumber-Ruby — emits Kensho v1 results.
69
+ test_files: []