executable-stories-ruby 0.1.0

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: ca5f49335f6bb83179d6c6d3710fa24fc7bc03250dfe0af5722a301e439485ff
4
+ data.tar.gz: 5e2fee0c6f9ab67787d7f89af3fec3a5e9074a805fbbc577884eba6fc805404a
5
+ SHA512:
6
+ metadata.gz: 4df5210a06292fe29466f57fb91887450249c6562b239489d90d0ff2259d5581c922782b75d9e66ac0882c9360fb61cb3e4f0ea56940fd7658967b5954a25c27
7
+ data.tar.gz: ca7e21e81ae8999d8b1117b5fc91706bb85ee9706ff52a6410e878b651d2052fcb32b30c1e1a473c894bd333c57a06ea35f49ca7a614acb555c6ec409595ba02
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types"
4
+
5
+ module ExecutableStories
6
+ @mutex = Mutex.new
7
+ @collected = []
8
+ @order_seq = 0
9
+
10
+ module Collector
11
+ module_function
12
+
13
+ def record(test_case)
14
+ @mutex ||= Mutex.new
15
+ @collected ||= []
16
+ @mutex.synchronize do
17
+ @collected << test_case
18
+ end
19
+ end
20
+
21
+ def all
22
+ @mutex ||= Mutex.new
23
+ @collected ||= []
24
+ @mutex.synchronize do
25
+ @collected.dup
26
+ end
27
+ end
28
+
29
+ def next_order
30
+ @mutex ||= Mutex.new
31
+ @order_seq ||= 0
32
+ @mutex.synchronize do
33
+ n = @order_seq
34
+ @order_seq += 1
35
+ n
36
+ end
37
+ end
38
+
39
+ def reset
40
+ @mutex ||= Mutex.new
41
+ @mutex.synchronize do
42
+ @collected = []
43
+ @order_seq = 0
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExecutableStories
4
+ module DocEntry
5
+ module_function
6
+
7
+ def note(text, children: nil)
8
+ entry = { "kind" => "note", "text" => text, "phase" => "runtime" }
9
+ apply_children(entry, children)
10
+ end
11
+
12
+ def tag(*names, children: nil)
13
+ entry = { "kind" => "tag", "names" => names.flatten, "phase" => "runtime" }
14
+ apply_children(entry, children)
15
+ end
16
+
17
+ def kv(label, value, children: nil)
18
+ entry = { "kind" => "kv", "label" => label, "value" => value, "phase" => "runtime" }
19
+ apply_children(entry, children)
20
+ end
21
+
22
+ def json_doc(label, value, children: nil)
23
+ require "json"
24
+ content = JSON.pretty_generate(value)
25
+ entry = { "kind" => "code", "label" => label, "content" => content, "lang" => "json", "phase" => "runtime" }
26
+ apply_children(entry, children)
27
+ end
28
+
29
+ def code(label, content, lang: nil, children: nil)
30
+ entry = { "kind" => "code", "label" => label, "content" => content, "phase" => "runtime" }
31
+ entry["lang"] = lang if lang
32
+ apply_children(entry, children)
33
+ end
34
+
35
+ def table(label, columns, rows, children: nil)
36
+ entry = { "kind" => "table", "label" => label, "columns" => columns, "rows" => rows, "phase" => "runtime" }
37
+ apply_children(entry, children)
38
+ end
39
+
40
+ def link(label, url, children: nil)
41
+ entry = { "kind" => "link", "label" => label, "url" => url, "phase" => "runtime" }
42
+ apply_children(entry, children)
43
+ end
44
+
45
+ def section(title, markdown, children: nil)
46
+ entry = { "kind" => "section", "title" => title, "markdown" => markdown, "phase" => "runtime" }
47
+ apply_children(entry, children)
48
+ end
49
+
50
+ def mermaid(code, title: nil, children: nil)
51
+ entry = { "kind" => "mermaid", "code" => code, "phase" => "runtime" }
52
+ entry["title"] = title if title
53
+ apply_children(entry, children)
54
+ end
55
+
56
+ def screenshot(path, alt: nil, children: nil)
57
+ entry = { "kind" => "screenshot", "path" => path, "phase" => "runtime" }
58
+ entry["alt"] = alt if alt
59
+ apply_children(entry, children)
60
+ end
61
+
62
+ def custom(type, data, children: nil)
63
+ entry = { "kind" => "custom", "type" => type, "data" => data, "phase" => "runtime" }
64
+ apply_children(entry, children)
65
+ end
66
+
67
+ def apply_children(entry, children)
68
+ return entry unless children && !children.empty?
69
+
70
+ entry["children"] = children.map { |c| c.is_a?(Hash) ? c : c.to_h }
71
+ entry
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "types"
6
+
7
+ module ExecutableStories
8
+ module JsonWriter
9
+ module_function
10
+
11
+ def write_raw_run(run, output_path)
12
+ dir = File.dirname(output_path)
13
+ FileUtils.mkdir_p(dir)
14
+
15
+ json = "#{JSON.pretty_generate(ExecutableStories.run_to_h(run))}\n"
16
+ File.write(output_path, json)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "minitest"
6
+ require_relative "story"
7
+ require_relative "collector"
8
+ require_relative "json_writer"
9
+ require_relative "types"
10
+
11
+ module ExecutableStories
12
+ module MinitestPlugin
13
+ module_function
14
+
15
+ def install!
16
+ return if @installed
17
+
18
+ Minitest.after_run do
19
+ write_results
20
+ end
21
+
22
+ @installed = true
23
+ end
24
+
25
+ def write_results(output_path: nil)
26
+ cases = Collector.all
27
+ return if cases.empty?
28
+
29
+ output_path ||= ENV.fetch("EXECUTABLE_STORIES_OUTPUT", ".executable-stories/raw-run.json")
30
+
31
+ started = cases.map(&:start_time).compact.min
32
+ finished = cases.map(&:end_time).compact.max
33
+
34
+ run = RawRun.new(
35
+ schema_version: 1,
36
+ test_cases: cases,
37
+ project_root: Dir.pwd,
38
+ started_at_ms: started,
39
+ finished_at_ms: finished,
40
+ package_version: nil,
41
+ git_sha: nil,
42
+ ci: detect_ci
43
+ )
44
+
45
+ JsonWriter.write_raw_run(run, output_path)
46
+ end
47
+
48
+ def detect_ci
49
+ if ENV["GITHUB_ACTIONS"] == "true"
50
+ url = nil
51
+ server = ENV["GITHUB_SERVER_URL"]
52
+ repo = ENV["GITHUB_REPOSITORY"]
53
+ run_id = ENV["GITHUB_RUN_ID"]
54
+ url = "#{server}/#{repo}/actions/runs/#{run_id}" if server && repo && run_id
55
+ RawCIInfo.new(name: "github", url: url, build_number: ENV["GITHUB_RUN_NUMBER"])
56
+ elsif ENV["CIRCLECI"] == "true"
57
+ RawCIInfo.new(name: "circleci", url: ENV["CIRCLE_BUILD_URL"], build_number: ENV["CIRCLE_BUILD_NUM"])
58
+ elsif !ENV["JENKINS_URL"].nil?
59
+ RawCIInfo.new(name: "jenkins", url: ENV["BUILD_URL"], build_number: ENV["BUILD_NUMBER"])
60
+ elsif ENV["TRAVIS"] == "true"
61
+ RawCIInfo.new(name: "travis", url: ENV["TRAVIS_BUILD_WEB_URL"], build_number: ENV["TRAVIS_BUILD_NUMBER"])
62
+ elsif ENV["GITLAB_CI"] == "true"
63
+ RawCIInfo.new(name: "gitlab", url: ENV["CI_PIPELINE_URL"], build_number: ENV["CI_PIPELINE_IID"])
64
+ elsif ENV["CI"] == "true"
65
+ RawCIInfo.new(name: "ci")
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ ExecutableStories::MinitestPlugin.install!
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "types"
6
+ require_relative "doc_entry"
7
+ require_relative "collector"
8
+
9
+ module ExecutableStories
10
+ class Story
11
+ attr_accessor :scenario, :steps, :tags, :tickets, :meta, :docs,
12
+ :current_step, :seen_primary, :start_time, :end_time,
13
+ :source_order, :step_counter, :attachments, :active_timers,
14
+ :timer_counter, :otel_spans, :trace_url_template
15
+
16
+ def initialize(scenario, tags: nil, ticket: nil, meta: nil, trace_url_template: nil)
17
+ @scenario = scenario
18
+ @steps = []
19
+ @tags = tags
20
+ @tickets = normalize_tickets(ticket)
21
+ @meta = meta&.dup
22
+ @docs = []
23
+ @current_step = nil
24
+ @seen_primary = {}
25
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
26
+ @source_order = Collector.next_order
27
+ @step_counter = 0
28
+ @attachments = []
29
+ @active_timers = {}
30
+ @timer_counter = 0
31
+ @otel_spans = nil
32
+ @trace_url_template = trace_url_template
33
+
34
+ bridge_otel
35
+ end
36
+
37
+ def given(text)
38
+ add_step("Given", text)
39
+ self
40
+ end
41
+
42
+ def when(text)
43
+ add_step("When", text)
44
+ self
45
+ end
46
+
47
+ def then(text)
48
+ add_step("Then", text)
49
+ self
50
+ end
51
+
52
+ def and(text)
53
+ add_explicit_step("And", text)
54
+ self
55
+ end
56
+
57
+ def but(text)
58
+ add_explicit_step("But", text)
59
+ self
60
+ end
61
+
62
+ def arrange(text)
63
+ add_step("Given", text)
64
+ self
65
+ end
66
+
67
+ def act(text)
68
+ add_step("When", text)
69
+ self
70
+ end
71
+
72
+ def assert_that(text)
73
+ add_step("Then", text)
74
+ self
75
+ end
76
+
77
+ def setup(text)
78
+ add_step("Given", text)
79
+ self
80
+ end
81
+
82
+ def context(text)
83
+ add_step("Given", text)
84
+ self
85
+ end
86
+
87
+ def execute(text)
88
+ add_step("When", text)
89
+ self
90
+ end
91
+
92
+ def action(text)
93
+ add_step("When", text)
94
+ self
95
+ end
96
+
97
+ def verify(text)
98
+ add_step("Then", text)
99
+ self
100
+ end
101
+
102
+ def fn(keyword, text, &body)
103
+ add_step(keyword, text)
104
+ @current_step.wrapped = true
105
+
106
+ start_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
107
+ begin
108
+ result = body.call
109
+ @current_step.duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0) - start_ms
110
+ result
111
+ rescue StandardError => e
112
+ @current_step.duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0) - start_ms
113
+ raise e
114
+ end
115
+ end
116
+
117
+ def expect(text, &)
118
+ fn("Then", text, &)
119
+ end
120
+
121
+ def start_timer
122
+ token = @timer_counter
123
+ @timer_counter += 1
124
+
125
+ step_index = @current_step ? @steps.index(@current_step) : nil
126
+ entry = {
127
+ start: Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0,
128
+ step_index: step_index,
129
+ step_id: @current_step&.id,
130
+ consumed: false
131
+ }
132
+ @active_timers[token] = entry
133
+ token
134
+ end
135
+
136
+ def end_timer(token)
137
+ entry = @active_timers[token]
138
+ return unless entry && !entry[:consumed]
139
+
140
+ entry[:consumed] = true
141
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0) - entry[:start]
142
+
143
+ step = nil
144
+ step = @steps.find { |s| s.id == entry[:step_id] } if entry[:step_id]
145
+ step = @steps[entry[:step_index]] if !step && entry[:step_index] && entry[:step_index] < @steps.length
146
+
147
+ step.duration_ms = duration_ms if step
148
+ end
149
+
150
+ def note(text, children: nil)
151
+ entry = DocEntry.note(text, children: children)
152
+ attach_doc(entry)
153
+ entry
154
+ end
155
+
156
+ def tag(*names, children: nil)
157
+ entry = DocEntry.tag(*names, children: children)
158
+ attach_doc(entry)
159
+ entry
160
+ end
161
+
162
+ def kv(label, value, children: nil)
163
+ entry = DocEntry.kv(label, value, children: children)
164
+ attach_doc(entry)
165
+ entry
166
+ end
167
+
168
+ def json(label, value, children: nil)
169
+ entry = DocEntry.json_doc(label, value, children: children)
170
+ attach_doc(entry)
171
+ entry
172
+ end
173
+
174
+ def code(label, content, lang: nil, children: nil)
175
+ entry = DocEntry.code(label, content, lang: lang, children: children)
176
+ attach_doc(entry)
177
+ entry
178
+ end
179
+
180
+ def table(label, columns, rows, children: nil)
181
+ entry = DocEntry.table(label, columns, rows, children: children)
182
+ attach_doc(entry)
183
+ entry
184
+ end
185
+
186
+ def link(label, url, children: nil)
187
+ entry = DocEntry.link(label, url, children: children)
188
+ attach_doc(entry)
189
+ entry
190
+ end
191
+
192
+ def section(title, markdown, children: nil)
193
+ entry = DocEntry.section(title, markdown, children: children)
194
+ attach_doc(entry)
195
+ entry
196
+ end
197
+
198
+ def mermaid(code, title: nil, children: nil)
199
+ entry = DocEntry.mermaid(code, title: title, children: children)
200
+ attach_doc(entry)
201
+ entry
202
+ end
203
+
204
+ def screenshot(path, alt: nil, children: nil)
205
+ entry = DocEntry.screenshot(path, alt: alt, children: children)
206
+ attach_doc(entry)
207
+ entry
208
+ end
209
+
210
+ def custom(type, data, children: nil)
211
+ entry = DocEntry.custom(type, data, children: children)
212
+ attach_doc(entry)
213
+ entry
214
+ end
215
+
216
+ def attach(name, media_type, path: nil, body: nil, encoding: nil, charset: nil, file_name: nil)
217
+ att = RawAttachment.new(
218
+ name: name,
219
+ media_type: media_type,
220
+ path: path,
221
+ body: body,
222
+ encoding: encoding,
223
+ charset: charset,
224
+ file_name: file_name
225
+ )
226
+ if @current_step
227
+ idx = @steps.index(@current_step)
228
+ att.step_index = idx if idx
229
+ att.step_id = @current_step.id
230
+ end
231
+ @attachments << att
232
+ self
233
+ end
234
+
235
+ def attach_inline(name, media_type, body, encoding: "IDENTITY")
236
+ att = RawAttachment.new(
237
+ name: name,
238
+ media_type: media_type,
239
+ body: body,
240
+ encoding: encoding
241
+ )
242
+ if @current_step
243
+ idx = @steps.index(@current_step)
244
+ att.step_index = idx if idx
245
+ att.step_id = @current_step.id
246
+ end
247
+ @attachments << att
248
+ self
249
+ end
250
+
251
+ def attach_spans(spans)
252
+ @otel_spans = spans
253
+ self
254
+ end
255
+
256
+ def get_meta
257
+ StoryMeta.new(
258
+ scenario: @scenario,
259
+ steps: @steps,
260
+ tags: @tags,
261
+ tickets: @tickets,
262
+ meta: @meta,
263
+ docs: @docs.empty? ? nil : @docs,
264
+ source_order: @source_order,
265
+ otel_spans: @otel_spans
266
+ )
267
+ end
268
+
269
+ def record(status:, title: nil, suite_path: nil, source_file: nil, duration_ms: nil, error: nil)
270
+ @end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
271
+ duration = duration_ms || (@end_time - @start_time)
272
+
273
+ step_events = @steps.each_with_index.map do |s, i|
274
+ s.duration_ms ? RawStepEvent.new(index: i, title: s.text, duration_ms: s.duration_ms) : nil
275
+ end.compact
276
+
277
+ tc = RawTestCase.new(
278
+ status: status,
279
+ title: title || @scenario,
280
+ title_path: suite_path ? suite_path + [@scenario] : [@scenario],
281
+ story: get_meta,
282
+ source_file: source_file,
283
+ duration_ms: duration,
284
+ error: error,
285
+ retry: 0,
286
+ retries: 0,
287
+ attachments: @attachments.empty? ? nil : @attachments,
288
+ step_events: step_events.empty? ? nil : step_events,
289
+ start_time: @start_time,
290
+ end_time: @end_time
291
+ )
292
+ Collector.record(tc)
293
+ tc
294
+ end
295
+
296
+ private
297
+
298
+ def add_step(keyword, text)
299
+ effective = keyword
300
+ if %w[Given When Then].include?(keyword)
301
+ if @seen_primary[keyword]
302
+ effective = "And"
303
+ else
304
+ @seen_primary[keyword] = true
305
+ end
306
+ end
307
+
308
+ step = StoryStep.new(
309
+ id: "step-#{@step_counter}",
310
+ keyword: effective,
311
+ text: text,
312
+ mode: nil,
313
+ wrapped: nil,
314
+ duration_ms: nil,
315
+ docs: nil
316
+ )
317
+ @step_counter += 1
318
+ @steps << step
319
+ @current_step = step
320
+ nil
321
+ end
322
+
323
+ def add_explicit_step(keyword, text)
324
+ step = StoryStep.new(
325
+ id: "step-#{@step_counter}",
326
+ keyword: keyword,
327
+ text: text,
328
+ mode: nil,
329
+ wrapped: nil,
330
+ duration_ms: nil,
331
+ docs: nil
332
+ )
333
+ @step_counter += 1
334
+ @steps << step
335
+ @current_step = step
336
+ nil
337
+ end
338
+
339
+ def attach_doc(entry)
340
+ if entry["children"] && !entry["children"].empty?
341
+ child_set = entry["children"].to_set(&:object_id)
342
+ filter_docs = ->(docs) { docs.reject { |d| child_set.include?(d.object_id) } }
343
+ @docs = filter_docs.call(@docs)
344
+ @steps.each { |s| s.docs = filter_docs.call(s.docs) if s.docs }
345
+ end
346
+
347
+ if @current_step
348
+ @current_step.docs ||= []
349
+ @current_step.docs << entry
350
+ else
351
+ @docs << entry
352
+ end
353
+ end
354
+
355
+ def normalize_tickets(ticket)
356
+ return nil unless ticket
357
+
358
+ tickets = Array(ticket)
359
+ tickets.map do |t|
360
+ if t.is_a?(String)
361
+ Ticket.new(id: t)
362
+ else
363
+ Ticket.new(id: t[:id] || t["id"], url: t[:url] || t["url"])
364
+ end
365
+ end
366
+ end
367
+
368
+ def bridge_otel
369
+ require "opentelemetry-api"
370
+ context = OpenTelemetry::Trace.current_span_context
371
+ return unless context&.valid?
372
+
373
+ inject_otel_meta(context)
374
+ inject_otel_docs(context)
375
+ tag_otel_span
376
+ rescue LoadError
377
+ # opentelemetry-api not available
378
+ rescue StandardError
379
+ # OTel not configured, ignore
380
+ end
381
+
382
+ def inject_otel_meta(context)
383
+ @meta ||= {}
384
+ @meta["otel"] = { "traceId" => context.trace_id, "spanId" => context.span_id }
385
+ end
386
+
387
+ def inject_otel_docs(context)
388
+ @docs << DocEntry.kv("Trace ID", context.trace_id)
389
+
390
+ template = @trace_url_template || ENV["OTEL_TRACE_URL_TEMPLATE"]
391
+ return unless template && !template.empty?
392
+
393
+ url = template.gsub("{traceId}", context.trace_id)
394
+ @docs << DocEntry.link("View Trace", url)
395
+ end
396
+
397
+ def tag_otel_span
398
+ span = OpenTelemetry::Trace.current_span
399
+ return unless span && !span.recording?
400
+
401
+ span.set_attribute("story.scenario", @scenario)
402
+ span.set_attribute("story.tags", @tags.join(",")) if @tags && !@tags.empty?
403
+ return unless @tickets
404
+
405
+ span.set_attribute("story.tickets", @tickets.map(&:id).join(","))
406
+ end
407
+ end
408
+
409
+ module_function
410
+
411
+ def init(scenario, **opts)
412
+ Story.new(scenario, **opts)
413
+ end
414
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExecutableStories
4
+ Ticket = Struct.new(:id, :url, keyword_init: true)
5
+
6
+ RawError = Struct.new(:message, :stack, keyword_init: true)
7
+
8
+ RawCIInfo = Struct.new(:name, :url, :build_number, keyword_init: true)
9
+
10
+ RawAttachment = Struct.new(
11
+ :name, :media_type, :path, :body, :encoding,
12
+ :charset, :file_name, :byte_length, :step_index, :step_id,
13
+ keyword_init: true
14
+ )
15
+
16
+ RawStepEvent = Struct.new(:index, :title, :status, :duration_ms, keyword_init: true)
17
+
18
+ StoryStep = Struct.new(
19
+ :id, :keyword, :text, :mode, :wrapped, :duration_ms, :docs,
20
+ keyword_init: true
21
+ )
22
+
23
+ StoryMeta = Struct.new(
24
+ :scenario, :steps, :tags, :tickets, :meta, :suite_path, :docs,
25
+ :source_order, :otel_spans,
26
+ keyword_init: true
27
+ )
28
+
29
+ RawTestCase = Struct.new(
30
+ :status, :external_id, :title, :title_path, :story, :source_file,
31
+ :source_line, :duration_ms, :error, :meta, :retry, :retries,
32
+ :attachments, :step_events, :start_time, :end_time,
33
+ keyword_init: true
34
+ )
35
+
36
+ RawRun = Struct.new(
37
+ :schema_version, :test_cases, :project_root, :started_at_ms,
38
+ :finished_at_ms, :package_version, :git_sha, :ci, :meta,
39
+ keyword_init: true
40
+ )
41
+
42
+ module_function
43
+
44
+ def step_to_h(step)
45
+ h = {
46
+ "keyword" => step.keyword,
47
+ "text" => step.text
48
+ }
49
+ h["id"] = step.id if step.id
50
+ h["mode"] = step.mode if step.mode
51
+ h["wrapped"] = step.wrapped if step.wrapped
52
+ h["durationMs"] = step.duration_ms if step.duration_ms
53
+ h["docs"] = step.docs.map { |d| d.is_a?(Hash) ? d : d.to_h } if step.docs && !step.docs.empty?
54
+ h
55
+ end
56
+
57
+ def meta_to_h(meta)
58
+ h = {
59
+ "scenario" => meta.scenario,
60
+ "steps" => meta.steps.map { |s| s.is_a?(StoryStep) ? step_to_h(s) : s }
61
+ }
62
+ h["tags"] = meta.tags if meta.tags && !meta.tags.empty?
63
+ h["tickets"] = meta.tickets.map { |t| t.is_a?(Ticket) ? ticket_to_h(t) : t } if meta.tickets && !meta.tickets.empty?
64
+ h["meta"] = meta.meta if meta.meta
65
+ h["suitePath"] = meta.suite_path if meta.suite_path
66
+ h["docs"] = meta.docs if meta.docs && !meta.docs.empty?
67
+ h["sourceOrder"] = meta.source_order unless meta.source_order.nil?
68
+ h["otelSpans"] = meta.otel_spans if meta.otel_spans
69
+ h
70
+ end
71
+
72
+ def ticket_to_h(ticket)
73
+ h = { "id" => ticket.id }
74
+ h["url"] = ticket.url if ticket.url
75
+ h
76
+ end
77
+
78
+ def error_to_h(error)
79
+ h = {}
80
+ h["message"] = error.message if error.message
81
+ h["stack"] = error.stack if error.stack
82
+ h
83
+ end
84
+
85
+ def attachment_to_h(att)
86
+ h = {
87
+ "name" => att.name,
88
+ "mediaType" => att.media_type
89
+ }
90
+ h["path"] = att.path if att.path
91
+ h["body"] = att.body if att.body
92
+ h["encoding"] = att.encoding if att.encoding
93
+ h["charset"] = att.charset if att.charset
94
+ h["fileName"] = att.file_name if att.file_name
95
+ h["byteLength"] = att.byte_length if att.byte_length
96
+ h["stepIndex"] = att.step_index unless att.step_index.nil?
97
+ h["stepId"] = att.step_id if att.step_id
98
+ h
99
+ end
100
+
101
+ def step_event_to_h(event)
102
+ h = {}
103
+ h["index"] = event.index unless event.index.nil?
104
+ h["title"] = event.title if event.title
105
+ h["status"] = event.status if event.status
106
+ h["durationMs"] = event.duration_ms if event.duration_ms
107
+ h
108
+ end
109
+
110
+ def test_case_to_h(tc)
111
+ h = { "status" => tc.status }
112
+ h["externalId"] = tc.external_id if tc.external_id
113
+ h["title"] = tc.title if tc.title
114
+ h["titlePath"] = tc.title_path if tc.title_path
115
+ h["story"] = meta_to_h(tc.story) if tc.story
116
+ h["sourceFile"] = tc.source_file if tc.source_file
117
+ h["sourceLine"] = tc.source_line if tc.source_line
118
+ h["durationMs"] = tc.duration_ms if tc.duration_ms
119
+ h["error"] = error_to_h(tc.error) if tc.error
120
+ h["meta"] = tc.meta if tc.meta
121
+ h["retry"] = tc.retry unless tc.retry.nil?
122
+ h["retries"] = tc.retries unless tc.retries.nil?
123
+ h["attachments"] = tc.attachments.map { |a| attachment_to_h(a) } if tc.attachments && !tc.attachments.empty?
124
+ h["stepEvents"] = tc.step_events.map { |e| step_event_to_h(e) } if tc.step_events && !tc.step_events.empty?
125
+ h
126
+ end
127
+
128
+ def ci_info_to_h(ci)
129
+ h = { "name" => ci.name }
130
+ h["url"] = ci.url if ci.url
131
+ h["buildNumber"] = ci.build_number if ci.build_number
132
+ h
133
+ end
134
+
135
+ def run_to_h(run)
136
+ h = {
137
+ "schemaVersion" => run.schema_version,
138
+ "testCases" => run.test_cases.map { |tc| test_case_to_h(tc) },
139
+ "projectRoot" => run.project_root
140
+ }
141
+ h["startedAtMs"] = run.started_at_ms unless run.started_at_ms.nil?
142
+ h["finishedAtMs"] = run.finished_at_ms unless run.finished_at_ms.nil?
143
+ h["packageVersion"] = run.package_version if run.package_version
144
+ h["gitSha"] = run.git_sha if run.git_sha
145
+ h["ci"] = ci_info_to_h(run.ci) if run.ci
146
+ h["meta"] = run.meta if run.meta
147
+ h
148
+ end
149
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "executable_stories/story"
4
+ require_relative "executable_stories/doc_entry"
5
+ require_relative "executable_stories/types"
6
+ require_relative "executable_stories/collector"
7
+ require_relative "executable_stories/json_writer"
8
+
9
+ module ExecutableStories
10
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: executable-stories-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jag Reehal
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-04-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.50'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.50'
55
+ description: BDD story testing library for Ruby. Tests and documentation from the
56
+ same code.
57
+ email:
58
+ - jag@jagreehal.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - lib/executable_stories.rb
64
+ - lib/executable_stories/collector.rb
65
+ - lib/executable_stories/doc_entry.rb
66
+ - lib/executable_stories/json_writer.rb
67
+ - lib/executable_stories/minitest.rb
68
+ - lib/executable_stories/story.rb
69
+ - lib/executable_stories/types.rb
70
+ homepage: https://github.com/jagreehal/executable-stories
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/jagreehal/executable-stories
75
+ source_code_uri: https://github.com/jagreehal/executable-stories/tree/main/packages/executable-stories-ruby
76
+ changelog_uri: https://github.com/jagreehal/executable-stories/releases
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '3.1'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.5.3
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Ruby-first story/given/when/then helpers for Minitest with doc generation.
96
+ test_files: []