nukadoko 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.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "fileutils"
5
+ require "time"
6
+
7
+ # One run: for each environment, start its world, play every scenario's steps
8
+ # in order, and write a record per step. A scenario stops at its first step
9
+ # that does not pass and skips the rest. A control that fails at a Then runs
10
+ # again for proof. The table and the summary come last.
11
+ module Nukadoko
12
+ module Engine
13
+ class Run
14
+ def initialize(project, steps, scenarios, program, out: $stdout)
15
+ @project = project
16
+ @steps = steps
17
+ @scenarios = scenarios
18
+ @program = Program.new(program)
19
+ @out = out
20
+ @id = new_id("run")
21
+ @dir = File.join(project, ".nukadoko", "runs", @id)
22
+ end
23
+
24
+ def call
25
+ FileUtils.mkdir_p(File.join(@dir, "steps"))
26
+ executions = []
27
+ cells = Hash.new { |h, k| h[k] = [] }
28
+ @program.environments.each do |environment|
29
+ @out.puts "environment #{environment.name}"
30
+ world_dir = File.join(@dir, "worlds", environment.name)
31
+ FileUtils.mkdir_p(world_dir)
32
+ world = @program.start(environment, world_dir, @project)
33
+ @process_fixtures = FixtureCache.new
34
+ @browser = Browser.new(world.base_url, Nukadoko.config_options.fetch(:browser, {}))
35
+ @scenarios.each do |scenario|
36
+ role = Table.role(Table.condition_tags(scenario.tags), environment.conditions)
37
+ runs = [play(scenario, environment, role, world)]
38
+ runs << play(scenario, environment, role, world) while proving?(role, runs)
39
+ executions.concat(runs)
40
+ cells[scenario] << Table.cell(runs.map { _1[:execution] }).merge(environment: environment.name, role: role)
41
+ end
42
+ ensure
43
+ @browser&.close
44
+ @process_fixtures&.teardown
45
+ world&.stop
46
+ end
47
+ finish(executions, cells)
48
+ end
49
+
50
+ private
51
+
52
+ def proving?(role, runs)
53
+ role == "control" && runs.size < Table::PROOF_RUNS && runs.all? { _1[:execution].first == :fail } && runs.map { _1[:execution] }.uniq.size == 1
54
+ end
55
+
56
+ def finish(executions, cells)
57
+ rows = @scenarios.map do |scenario|
58
+ row_cells = cells[scenario]
59
+ { feature: scenario.uri, line: scenario.line, name: scenario.name, conditions: Table.condition_tags(scenario.tags),
60
+ cells: row_cells, verdict: Table.verdict(row_cells) }
61
+ end
62
+ write_summary(rows)
63
+ @out.puts "summary #{File.join(".nukadoko/runs", @id, "summary.md")}"
64
+ { run_id: @id, run_dir: File.join(".nukadoko/runs", @id), scenarios: executions.map { _1[:json] },
65
+ table: { rows: rows }, passed: rows.none? { _1[:verdict] == "failed" } }
66
+ end
67
+
68
+ def play(scenario, environment, role, world)
69
+ @out.puts "scenario #{scenario.uri}:#{scenario.line} #{scenario.name}#{role == "control" ? " (control)" : ""}"
70
+ results = []
71
+ stopped = nil
72
+ @scratch = Scratch.new
73
+ @scenario_fixtures = FixtureCache.new
74
+ records = scenario.steps.map do |line|
75
+ record = stopped ? skipped(line) : play_step(line, results, world)
76
+ stopped ||= [record[:outcome], line.line, record[:claim]] if record[:status] == "failed"
77
+ @out.puts " #{record[:status]} #{line.text}"
78
+ @out.puts " #{record[:error]}" if record[:error]
79
+ record
80
+ end
81
+ @browser.end_scenario
82
+ @scenario_fixtures.teardown
83
+ @scratch.remove_all
84
+ execution = stopped ? stopped.compact : [:pass]
85
+ { execution: execution,
86
+ json: { feature: scenario.uri, line: scenario.line, scenario: scenario.name, environment: environment.name, role: role,
87
+ status: stopped ? "failed" : "passed",
88
+ steps: scenario.steps.zip(records).map do |line, record|
89
+ { line: line.line, text: line.text, step: record[:step], status: record[:status], record: record[:step_record_id] }.compact
90
+ end } }
91
+ end
92
+
93
+ def skipped(line) = { step: Engine.matches(@steps, line.text).first&.first&.step_name, status: "skipped" }
94
+
95
+ def play_step(line, results, world)
96
+ step, captures = Engine.matches(@steps, line.text).first
97
+ return { status: "failed", outcome: :broke, error: %(no step matches "#{line.text}") } unless step
98
+
99
+ id = new_id("step")
100
+ dir = File.join(@dir, "steps", id)
101
+ FileUtils.mkdir_p(dir)
102
+ log = []
103
+ record = { step_record_id: id, step: step.step_name, text: line.text, started_at: Time.now.utc.iso8601(3) }
104
+ fixtures = {}
105
+ finish = nil
106
+ processes = []
107
+ begin
108
+ args = bind_args(step, captures, line, results)
109
+ record[:args] = args
110
+ problems = step.args.problems(args)
111
+ raise TypeError, "args: #{problems.join("; ")}" if problems.any?
112
+
113
+ fixtures, finish = build_fixtures(step, dir, log, world)
114
+ Processes.collect(processes) { body(step, fixtures, args, line, record, results) }
115
+ record[:status] ||= "passed"
116
+ rescue StandardError => e
117
+ record.merge!(status: "failed", outcome: :broke, error: "#{e.class}: #{e.message}")
118
+ ensure
119
+ finish&.call
120
+ log.concat(fixtures[:request].log) if fixtures[:request]
121
+ record[:finished_at] = Time.now.utc.iso8601(3)
122
+ record[:processes] = processes if processes&.any?
123
+ record[:calls] = fixtures[:call].calls if fixtures[:call]&.calls&.any?
124
+ record[:observed] = { http: log.size, processes: processes&.size || 0 }
125
+ File.write(File.join(dir, "http.jsonl"), log.map { JSON.generate(_1) }.join("\n"))
126
+ File.write(File.join(dir, "record.json"), JSON.pretty_generate(record))
127
+ end
128
+ record
129
+ end
130
+
131
+ # A Then step's claims are judged on what it observed, or on its args
132
+ # when it reads nothing itself; any other step runs and returns.
133
+ def body(step, fixtures, args, line, record, results)
134
+ if step.claims
135
+ own = fixtures.slice(*step.fixtures)
136
+ read = -> { step.method_defined?(:observe) ? step.new.observe(**own, args: step.inputs.load(args)) : args }
137
+ observed = step.poll ? Poll.new(step.poll, step.claims, args).call(record, &read) : read.()
138
+ judged = Claims.judge(step.claims, observed, args)
139
+ record[:claims] = judged
140
+ broken = judged.index { !_1[:holds] }
141
+ record.merge!(status: "failed", outcome: line_kind(line) == :then ? :fail : :broke, claim: broken, error: "claim #{broken} broke") if broken
142
+ else
143
+ value = step.new.run(**fixtures.slice(*step.fixtures), args: step.inputs.load(args))
144
+ problems = step.returns ? step.returns.problems(value) : []
145
+ raise TypeError, "returns: #{problems.join("; ")}" if problems.any?
146
+
147
+ if step.returns
148
+ dropped = step.returns.dropped(value)
149
+ record[:returns_dropped] = dropped if dropped.any?
150
+ value = step.returns.strip(value)
151
+ end
152
+ results << [step, value.transform_keys(&:to_s)]
153
+ record[:result] = value
154
+ end
155
+ end
156
+
157
+ # Captures first, then each `from` key from the nearest earlier step that
158
+ # gave it, then a table or docstring for the one required key still open.
159
+ def bind_args(step, captures, line, results)
160
+ args = captures.dup
161
+ step.from.each do |key, sources|
162
+ cls, value = results.reverse_each.find { |c, _| sources.any? { _1.first == c } }
163
+ raise ArgumentError, %(no earlier step in this scenario gave "#{key}") unless cls
164
+
165
+ args[key] = value.fetch(sources.find { _1.first == cls }[1].to_s)
166
+ end
167
+ open = step.args.fields.map { _1.name.to_s } - args.keys
168
+ attachment = line.table || line.doc_string
169
+ args[open.first] = attachment if attachment && open.any?
170
+ args
171
+ end
172
+
173
+ def line_kind(line) = line.kind == "Outcome" ? :then : :other
174
+
175
+ # A project fixture is built the first time a step of its scope names
176
+ # it, directly or through another fixture, and kept until the scope ends.
177
+ def project_fixture(name, world, chain)
178
+ fixture = Nukadoko.project_fixtures[name] or raise ArgumentError, "the Ruby engine has no fixture #{name} yet"
179
+ raise ArgumentError, "fixtures depend on each other in a cycle: #{[*chain, name].join(" -> ")}" if chain.include?(name)
180
+
181
+ cache = fixture.scope == :process ? @process_fixtures : @scenario_fixtures
182
+ return cache[name] if cache.key?(name)
183
+
184
+ deps = fixture.dependencies.to_h do |dep|
185
+ case dep
186
+ when :baseURL then [dep, world.base_url]
187
+ when :scratch then [dep, @scratch]
188
+ else [dep, project_fixture(dep, world, [*chain, name])]
189
+ end
190
+ end
191
+ cache.build(fixture, deps)
192
+ end
193
+
194
+ def build_fixtures(step, dir, log, world)
195
+ finish = nil
196
+ fixtures = (step.bag - [:call]).to_h do |name|
197
+ case name
198
+ when :request then [name, Request.new(world.base_url)]
199
+ when :baseURL then [name, world.base_url]
200
+ when :scratch then [name, @scratch]
201
+ when :page
202
+ page, finish = @browser.page_for(dir, log)
203
+ [name, page]
204
+ else [name, project_fixture(name, world, [])]
205
+ end
206
+ end
207
+ fixtures[:call] = Caller.new(step, fixtures) if step.fixtures.include?(:call)
208
+ [fixtures, finish]
209
+ end
210
+
211
+ def write_summary(rows)
212
+ verdicts = rows.map { _1[:verdict] }.tally.map { |v, n| "#{n} #{v}" }.join(", ")
213
+ lines = ["# nukadoko run #{rows.none? { _1[:verdict] == "failed" } ? "passed" : "failed"}: #{rows.size} criteria, #{verdicts}", ""]
214
+ rows.each do |r|
215
+ cells = r[:cells].map { |c| "#{c[:environment]} #{c[:outcome]}#{c[:line] ? "@#{c[:line]}" : ""}#{c[:role] == "control" ? " (control)" : ""}" }
216
+ lines << "- #{r[:verdict]} #{r[:feature]}:#{r[:line]} #{r[:name]} #{cells.join(", ")}"
217
+ end
218
+ File.write(File.join(@dir, "summary.md"), "#{lines.join("\n")}\n")
219
+ end
220
+
221
+ def new_id(kind) = "#{kind}-#{Time.now.utc.strftime("%Y%m%d-%H%M%S")}-#{SecureRandom.alphanumeric(4).downcase}"
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One declaration of a step's args or returns gives three things: the check
4
+ # a value must pass at run time, the RBS a Steep user checks the step body
5
+ # against, and the Data class the body receives. A type is a symbol for a
6
+ # scalar, `[:array, type]` for a list, or a nested Schema for an object.
7
+ module Nukadoko
8
+ class Schema
9
+ Field = Data.define(:name, :type, :description)
10
+
11
+ def self.build(&block)
12
+ schema = new
13
+ schema.instance_eval(&block) if block
14
+ schema
15
+ end
16
+
17
+ attr_reader :fields
18
+
19
+ def initialize(fields = [])
20
+ @fields = fields
21
+ end
22
+
23
+ # `null: true` lets the value be nil as well, as zod's `.nullable()` does.
24
+ def string(name, description = nil, null: false) = add(name, :string, description, null)
25
+ def boolean(name, description = nil, null: false) = add(name, :boolean, description, null)
26
+ def integer(name, description = nil, null: false) = add(name, :integer, description, null)
27
+ def number(name, description = nil, null: false) = add(name, :number, description, null)
28
+ def unknown(name, description = nil) = add(name, :unknown, description)
29
+ def enum(name, values, description = nil, null: false) = add(name, [:enum, values.map(&:to_s)], description, null)
30
+ def strings(name, description = nil, null: false) = add(name, [:array, :string], description, null)
31
+ def table(name, description = nil, null: false) = add(name, [:array, [:array, :string]], description, null)
32
+ def object(name, description = nil, null: false, &block) = add(name, Schema.build(&block), description, null)
33
+
34
+ # Reuses a field another step declared, so two contracts that must agree
35
+ # on one shape are written once: `field :created, AnItemExists.returns[:body]`.
36
+ def field(name, other, description = nil) = add(name, other.type, description || other.description)
37
+
38
+ def [](name) = fields.find { _1.name == name.to_sym }
39
+
40
+ def without(names) = Schema.new(fields.reject { names.include?(_1.name.to_s) })
41
+
42
+ def data_class
43
+ @data_class ||= fields.empty? ? Data.define : Data.define(*fields.map(&:name))
44
+ end
45
+
46
+ def load(hash)
47
+ data_class.new(**fields.to_h { [_1.name, hash.fetch(_1.name.to_s)] })
48
+ end
49
+
50
+ # Every way `value` departs from the schema, as `path: problem` lines.
51
+ # A key the schema does not declare is no problem: as a zod object does,
52
+ # the schema drops it (`strip`), and `dropped` names where.
53
+ def problems(value, path = nil)
54
+ return ["#{path || "value"} must be an object, got #{value.inspect}"] unless value.is_a?(Hash)
55
+
56
+ hash = value.transform_keys(&:to_s)
57
+ fields.flat_map do |field|
58
+ key = join(path, field.name)
59
+ hash.key?(field.name.to_s) ? Schema.type_problems(field.type, hash[field.name.to_s], key) : ["#{key} is missing"]
60
+ end
61
+ end
62
+
63
+ # The value with every key the schema does not declare left out, at any
64
+ # depth, with string keys. Assumes `problems` found none.
65
+ def strip(value)
66
+ hash = value.transform_keys(&:to_s)
67
+ fields.to_h { [_1.name.to_s, Schema.strip_type(_1.type, hash[_1.name.to_s])] }
68
+ end
69
+
70
+ def self.strip_type(type, value)
71
+ case type
72
+ in [:nullable, inner] then value.nil? ? nil : strip_type(inner, value)
73
+ in [:array, item] then value.map { strip_type(item, _1) }
74
+ in Schema then type.strip(value)
75
+ else value
76
+ end
77
+ end
78
+
79
+ # The paths of the keys `strip` leaves out, so a record can say what the
80
+ # step returned and the contract did not keep.
81
+ def dropped(value, path = nil)
82
+ hash = value.transform_keys(&:to_s)
83
+ names = fields.map { _1.name.to_s }
84
+ (hash.keys - names).map { join(path, _1) } + fields.flat_map { Schema.dropped_type(_1.type, hash[_1.name.to_s], join(path, _1.name)) }
85
+ end
86
+
87
+ def self.dropped_type(type, value, path)
88
+ case type
89
+ in [:nullable, inner] then value.nil? ? [] : dropped_type(inner, value, path)
90
+ in [:array, item] then value.each_with_index.flat_map { dropped_type(item, _1, "#{path}[#{_2}]") }
91
+ in Schema then type.dropped(value, path)
92
+ else []
93
+ end
94
+ end
95
+
96
+ def self.type_problems(type, value, path)
97
+ ok =
98
+ case type
99
+ in :string then value.is_a?(String)
100
+ in :boolean then [true, false].include?(value)
101
+ in :integer then value.is_a?(Integer)
102
+ in :number then value.is_a?(Numeric)
103
+ in :unknown then true
104
+ in [:nullable, inner] then return value.nil? ? [] : type_problems(inner, value, path)
105
+ in [:enum, values] then values.include?(value.is_a?(Symbol) ? value.to_s : value)
106
+ in [:array, item]
107
+ return ["#{path} must be an array, got #{value.inspect}"] unless value.is_a?(Array)
108
+
109
+ return value.each_with_index.flat_map { |v, i| type_problems(item, v, "#{path}[#{i}]") }
110
+ in Schema then return type.problems(value, path)
111
+ end
112
+ ok ? [] : ["#{path} must be #{type}, got #{value.inspect}"]
113
+ end
114
+
115
+ # The JSON Schema `nuka describe` prints, as zod's own conversion gives it:
116
+ # the root is closed, and an inner object is closed only on the output
117
+ # side (`returns`), where zod drops the keys it does not declare.
118
+ def json_schema(root: true, output: false)
119
+ schema = { type: "object", properties: fields.to_h { [_1.name, Schema.json_type(_1.type, output).merge(_1.description ? { description: _1.description } : {})] } }
120
+ schema[:required] = fields.map(&:name) if fields.any?
121
+ return { "$schema": "https://json-schema.org/draft/2020-12/schema", **schema, additionalProperties: false } if root
122
+
123
+ output ? schema.merge(additionalProperties: false) : schema
124
+ end
125
+
126
+ def self.json_type(type, output = false)
127
+ case type
128
+ in :string then { type: "string" }
129
+ in :boolean then { type: "boolean" }
130
+ in :integer then { type: "integer" }
131
+ in :number then { type: "number" }
132
+ in :unknown then {}
133
+ in [:nullable, inner] then { anyOf: [json_type(inner, output), { type: "null" }] }
134
+ in [:enum, values] then { type: "string", enum: values }
135
+ in [:array, item] then { type: "array", items: json_type(item, output) }
136
+ in Schema then type.json_schema(root: false, output: output)
137
+ end
138
+ end
139
+
140
+ def rbs_fields = fields.map { "attr_reader #{_1.name}: #{Schema.rbs_type(_1.type)}" }
141
+ def rbs_record = "{ #{fields.map { "#{_1.name}: #{Schema.rbs_type(_1.type)}" }.join(", ")} }"
142
+
143
+ def self.rbs_type(type)
144
+ case type
145
+ in :string then "String"
146
+ in :boolean then "bool"
147
+ in :integer then "Integer"
148
+ in :number then "Numeric"
149
+ in :unknown then "untyped"
150
+ in [:nullable, inner] then "(#{rbs_type(inner)})?"
151
+ in [:enum, values] then values.map(&:inspect).join(" | ")
152
+ in [:array, item] then "Array[#{rbs_type(item)}]"
153
+ in Schema then type.fields.empty? ? "Hash[Symbol, untyped]" : type.rbs_record
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def add(name, type, description, null = false) = fields << Field.new(name.to_sym, null ? [:nullable, type] : type, description)
160
+ def join(path, name) = path ? "#{path}.#{name}" : name.to_s
161
+ end
162
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The RBS of each step, written from the same declarations check reads, so a
4
+ # project that runs Steep gets the body checked against its own contract:
5
+ # args it does not declare, a return value of the wrong shape, and, in a
6
+ # Then step, an args key the claim keeps from observe.
7
+ module Nukadoko
8
+ module Signature
9
+ FIXTURE_TYPES = { request: "Nukadoko::Engine::Request", scratch: "Nukadoko::Engine::Scratch", call: "Nukadoko::Engine::Caller", baseURL: "String", page: "untyped", context: "untyped" }.freeze
10
+
11
+ def self.of(steps)
12
+ steps.map { of_step(_1) }.join("\n")
13
+ end
14
+
15
+ def self.of_step(step)
16
+ params = step.fixtures.map { "#{_1}: #{FIXTURE_TYPES.fetch(_1, "untyped")}" } + ["args: Args"]
17
+ returns = step.returns ? step.returns.rbs_record : "Hash[Symbol, untyped]"
18
+ [
19
+ "class #{step.name} < Nukadoko::Step",
20
+ " class Args",
21
+ *step.inputs.rbs_fields.map { " #{_1}" },
22
+ " end",
23
+ "",
24
+ " def #{step.body}: (#{params.join(", ")}) -> #{returns}",
25
+ "end",
26
+ ""
27
+ ].join("\n")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A step is a class under features/steps/, one per file, named by its file.
4
+ # Everything except the body is a class-level declaration, so check reads the
5
+ # contract without calling the body, and `Method#parameters` names the
6
+ # fixtures the body asks for.
7
+ module Nukadoko
8
+ class Step
9
+ class << self
10
+ attr_reader :file
11
+
12
+ def inherited(subclass)
13
+ super
14
+ subclass.instance_variable_set(:@file, caller_locations(1, 1).first.absolute_path)
15
+ Step.registry << subclass
16
+ end
17
+
18
+ def registry = (@registry ||= [])
19
+
20
+ def pattern(text = nil) = text ? (@pattern = text) : @pattern
21
+ def description(text = nil) = text ? (@description = text) : @description
22
+ def mutates(value = nil) = value.nil? ? @mutates : (@mutates = value)
23
+ def claims(list = nil) = list ? (@claims = list) : @claims
24
+ def poll(value = nil) = value.nil? ? @poll : (@poll = value)
25
+
26
+ # The steps this one may run through the `call` fixture. A part with no
27
+ # pattern exists only for calls.
28
+ def parts(list = nil) = list ? (@parts = list.freeze) : (@parts || [])
29
+
30
+ def args(&block) = block ? (@args = Schema.build(&block)) : (@args ||= Schema.new)
31
+ def returns(&block) = block ? (@returns = Schema.build(&block)) : @returns
32
+
33
+ # Args an earlier step of the scenario returned, by key: one source as
34
+ # `[Step, :field]`, or several as a list of them, where the nearest
35
+ # earlier one that ran gives the value.
36
+ def from(map = nil)
37
+ return @from || {} unless map
38
+
39
+ @from = map.to_h { |key, source| [key.to_s, source.first.is_a?(Array) ? source : [source]] }
40
+ end
41
+
42
+ # observe never sees the args a claim compares with, so it cannot pick
43
+ # what to read by the answer it will be judged against.
44
+ def inputs = claims ? args.without(expected_keys(claims)) : args
45
+
46
+ def step_name = File.basename(file, ".rb").tr("_", "-")
47
+
48
+ # A step that runs code may change the world unless it says it does
49
+ # not; a claim step has no action, so it never does.
50
+ def mutating? = claims ? false : mutates != false
51
+ def body = claims ? :observe : :run
52
+ def line = method_defined?(body) ? instance_method(body).source_location&.last : nil
53
+
54
+ def fixtures
55
+ return [] unless method_defined?(body)
56
+
57
+ instance_method(body).parameters.filter_map { |kind, name| name if kind == :keyreq && name != :args }
58
+ end
59
+
60
+ # The fixtures the engine builds before the body runs: the step's own,
61
+ # and those of every part it can reach, since the bag is closed before
62
+ # any call happens.
63
+ def bag(seen = [])
64
+ return [] if seen.include?(self)
65
+
66
+ reachable = parts.select { _1.is_a?(Class) && _1 < Step }.flat_map { _1.bag([*seen, self]) }
67
+ (fixtures + reachable - (seen.empty? ? [] : [:call])).uniq
68
+ end
69
+
70
+ private
71
+
72
+ def expected_keys(node)
73
+ case node
74
+ in Array then node.flat_map { expected_keys(_1) }
75
+ in Hash then node.flat_map { |k, v| k.to_s == "expected" ? expected_key(v) : expected_keys(v) }
76
+ else []
77
+ end
78
+ end
79
+
80
+ def expected_key(value)
81
+ case value
82
+ in String then [value]
83
+ in { from: String => key } then [key]
84
+ else []
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Each step file is registered for autoload under its class name first, so a
91
+ # step can name another in `from` or `field` whatever order the files load in.
92
+ def self.load_steps(dir)
93
+ files = Dir.glob(File.join(dir, "**/*.rb")).sort.map { File.expand_path(_1) }
94
+ files.each { Object.autoload(File.basename(_1, ".rb").split("_").map(&:capitalize).join.to_sym, _1) }
95
+ files.each { require _1 }
96
+ Step.registry
97
+ end
98
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The run's verdict table: a row per criterion (a scenario, or an Examples
4
+ # row), a cell per environment. A cell is `required` where its environment
5
+ # meets the row's condition tags and a `control` elsewhere. Only a control
6
+ # that fails at a Then, the same way, in every proof run proves a row.
7
+ module Nukadoko
8
+ module Engine
9
+ module Table
10
+ PROOF_RUNS = 2
11
+
12
+ def self.role(tags, conditions)
13
+ meets = tags.all? do |tag|
14
+ case tag
15
+ when /\A@with:([\w.-]+)=(\S+)\z/ then conditions[Regexp.last_match(1)].to_s == Regexp.last_match(2)
16
+ when /\A@with:([\w.-]+)\z/ then ![nil, false].include?(conditions[Regexp.last_match(1)])
17
+ when /\A@without:([\w.-]+)\z/ then [nil, false].include?(conditions[Regexp.last_match(1)])
18
+ else true
19
+ end
20
+ end
21
+ meets ? "required" : "control"
22
+ end
23
+
24
+ # What a cell's executions came to. An execution is `[:pass]`,
25
+ # `[:fail, line, claim]`, or `[:broke, line]`.
26
+ def self.cell(executions)
27
+ first = executions.first
28
+ if executions.uniq.size > 1
29
+ line = executions.find { _1.first != :pass }&.at(1)
30
+ { outcome: "flaky", line: line }
31
+ elsif first.first == :pass then { outcome: "pass" }
32
+ else { outcome: first.first.to_s, line: first[1] }
33
+ end.merge(runs: executions.size)
34
+ end
35
+
36
+ def self.verdict(cells)
37
+ required = cells.select { _1[:role] == "required" }
38
+ return "failed" if required.empty? || required.any? { _1[:outcome] != "pass" }
39
+
40
+ proven = cells.any? { _1[:role] == "control" && _1[:outcome] == "fail" && _1[:runs] >= PROOF_RUNS }
41
+ proven ? "proven" : "held"
42
+ end
43
+
44
+ def self.condition_tags(tags) = tags.grep(/\A@with(out)?:/)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nukadoko
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What `nuka steps` and `nuka describe` print: each step as data, in the
4
+ # fields the TypeScript engine prints, so an agent picks a step from either
5
+ # engine's vocabulary without opening a file.
6
+ module Nukadoko
7
+ module Vocabulary
8
+ BROWSER = %i[page context pageAs].freeze
9
+
10
+ def self.entry(step, project)
11
+ {
12
+ name: step.step_name, kind: "typed", file: step.file.delete_prefix("#{project}/"),
13
+ patterns: [step.pattern].compact, description: step.description, mutates: step.mutating?,
14
+ parts: step.parts.empty? ? nil : step.parts.map(&:step_name),
15
+ from: from(step), claims: step.claims, poll: step.poll,
16
+ needs: step.bag.map(&:to_s).sort, needs_browser: step.bag.intersect?(BROWSER)
17
+ }.compact
18
+ end
19
+
20
+ def self.describe(step, project)
21
+ # A step with no returns returns an empty object, as zod's default says.
22
+ returns = step.returns&.json_schema(output: true) || { "$schema": "https://json-schema.org/draft/2020-12/schema", default: {}, type: "object", properties: {}, additionalProperties: false }
23
+ entry(step, project).merge(args: step.args.json_schema, returns: returns)
24
+ end
25
+
26
+
27
+ def self.from(step)
28
+ return nil if step.from.empty?
29
+
30
+ step.from.transform_values do |sources|
31
+ named = sources.map { |cls, key| { step: cls.step_name, key: key.to_s } }
32
+ named.size == 1 ? named.first : named
33
+ end
34
+ end
35
+
36
+ def self.text(step)
37
+ lines = ["#{step.step_name} typed #{step.claims ? "claims" : step.mutating? ? "mutates" : "read-only"}",
38
+ " #{step.pattern || "(no pattern)"}", " #{step.description}"]
39
+ (step.claims || []).each { lines << " claim: #{claim_text(_1)}" }
40
+ ((step.poll.is_a?(Hash) && step.poll[:settled]) || []).each { lines << " settled: #{claim_text(_1)}" }
41
+ lines.join("\n")
42
+ end
43
+
44
+ def self.claim_text(claim)
45
+ return "each #{claim[:actual]}" if claim[:each]
46
+
47
+ actual = [claim[:actual], claim[:view] && "as #{claim[:view]}", claim[:at] && "at #{claim[:at]}"].compact.join(" ")
48
+ expected = case claim[:expected]
49
+ in String => key then key
50
+ in { literal: value } then value.to_json
51
+ in { from: key } then "from #{key}"
52
+ end
53
+ "#{actual} #{claim[:compare]} #{expected}"
54
+ end
55
+ end
56
+ end
data/lib/nukadoko.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "nukadoko/version"
5
+ require_relative "nukadoko/schema"
6
+ require_relative "nukadoko/step"
7
+ require_relative "nukadoko/pattern"
8
+ require_relative "nukadoko/features"
9
+ require_relative "nukadoko/claims"
10
+ require_relative "nukadoko/check"
11
+ require_relative "nukadoko/fixtures"
12
+ require_relative "nukadoko/command"
13
+ require_relative "nukadoko/project_fixtures"
14
+ require_relative "nukadoko/parts"
15
+ require_relative "nukadoko/poll"
16
+ require_relative "nukadoko/definition"
17
+ require_relative "nukadoko/vocabulary"
18
+ require_relative "nukadoko/program"
19
+ require_relative "nukadoko/table"
20
+ require_relative "nukadoko/run"
21
+ require_relative "nukadoko/signature"
22
+
23
+ module Nukadoko
24
+ def self.program(**options) = (@program = options)
25
+ def self.program_options = @program || {}
26
+ def self.config(**options) = (@config = options)
27
+ def self.config_options = @config || {}
28
+ end