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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.ja.md +71 -0
- data/README.md +66 -0
- data/exe/nuka +7 -0
- data/lib/nukadoko/check.rb +173 -0
- data/lib/nukadoko/claims.rb +110 -0
- data/lib/nukadoko/cli.rb +76 -0
- data/lib/nukadoko/command.rb +63 -0
- data/lib/nukadoko/definition.rb +68 -0
- data/lib/nukadoko/features.rb +50 -0
- data/lib/nukadoko/fixtures.rb +80 -0
- data/lib/nukadoko/parts.rb +68 -0
- data/lib/nukadoko/pattern.rb +29 -0
- data/lib/nukadoko/poll.rb +75 -0
- data/lib/nukadoko/program.rb +70 -0
- data/lib/nukadoko/project_fixtures.rb +60 -0
- data/lib/nukadoko/run.rb +224 -0
- data/lib/nukadoko/schema.rb +162 -0
- data/lib/nukadoko/signature.rb +30 -0
- data/lib/nukadoko/step.rb +98 -0
- data/lib/nukadoko/table.rb +47 -0
- data/lib/nukadoko/version.rb +5 -0
- data/lib/nukadoko/vocabulary.rb +56 -0
- data/lib/nukadoko.rb +28 -0
- data/sig/nukadoko.rbs +60 -0
- metadata +107 -0
data/lib/nukadoko/run.rb
ADDED
|
@@ -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,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
|