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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What the TypeScript engine's defineStep refuses when a step file loads:
|
|
4
|
+
# a claim or a poll that could only be judged one way, or that would let a
|
|
5
|
+
# line fill the value a claim measures. The Ruby engine reads the same
|
|
6
|
+
# declarations after loading, and check reports each as the TypeScript
|
|
7
|
+
# engine does, as step-file-import-failed, so the step leaves the vocabulary.
|
|
8
|
+
module Nukadoko
|
|
9
|
+
module Engine
|
|
10
|
+
class Definition
|
|
11
|
+
def initialize(step)
|
|
12
|
+
@step = step
|
|
13
|
+
@from = step.from.keys
|
|
14
|
+
@captures = step.pattern ? Pattern.new(step.pattern).keys : []
|
|
15
|
+
@args = step.args.fields.map { _1.name.to_s }
|
|
16
|
+
@observe = step.method_defined?(:observe)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def problems
|
|
20
|
+
return [] unless @step.claims
|
|
21
|
+
|
|
22
|
+
claim_problems + poll_problems
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def claim_problems
|
|
28
|
+
@step.claims.each_with_index.flat_map do |claim, i|
|
|
29
|
+
label = "claims[#{i}]"
|
|
30
|
+
actual_problems(label, claim) + expected_problems(label, claim)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def actual_problems(label, claim)
|
|
35
|
+
head = claim[:actual].to_s.split(".").first
|
|
36
|
+
from = @from.include?(head)
|
|
37
|
+
if !from && !@observe
|
|
38
|
+
[%(#{label}.actual starts with "#{head}", which is not a key of this step's from, and the step has no observe to read it)]
|
|
39
|
+
elsif from && @captures.include?(head)
|
|
40
|
+
[%(#{label}.actual starts with "#{head}", which a pattern captures, so a line would fill the value the claim measures. Take it out of the pattern, so an earlier step fills it)]
|
|
41
|
+
elsif !from && @args.include?(head)
|
|
42
|
+
[%(#{label}.actual starts with "#{head}", an args key the sentence fills. A claim's actual is a value an earlier step returned (a from key) or one observe read)]
|
|
43
|
+
else []
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def expected_problems(label, claim)
|
|
48
|
+
key = claim[:expected].is_a?(Hash) && claim[:expected][:from]&.to_s&.split(".")&.first
|
|
49
|
+
return [] unless key
|
|
50
|
+
return [%(#{label}.expected.from starts with "#{key}", which is not a key of this step's from)] unless @from.include?(key)
|
|
51
|
+
return [%(#{label}.expected.from starts with "#{key}", which a pattern captures, so a line fills it and no earlier step does)] if @captures.include?(key)
|
|
52
|
+
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def poll_problems
|
|
57
|
+
poll = @step.poll
|
|
58
|
+
return [] if poll.nil? || poll == false
|
|
59
|
+
unless poll.is_a?(Hash) && (poll.keys - %i[timeout_ms settled]).empty? && poll[:timeout_ms].is_a?(Numeric) && poll[:timeout_ms].positive?
|
|
60
|
+
return ["poll must be false or { timeout_ms, settled? } with a number of milliseconds greater than 0; got #{poll.inspect}"]
|
|
61
|
+
end
|
|
62
|
+
return ["poll re-runs observe, and the step has no observe. Values from earlier steps do not change between readings"] unless @observe
|
|
63
|
+
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gherkin"
|
|
4
|
+
|
|
5
|
+
# Feature files become pickles through the official gherkin gem. A pickle
|
|
6
|
+
# from an outline is named by its Examples row's line, and its steps by the
|
|
7
|
+
# outline's step lines, as the TypeScript engine names them.
|
|
8
|
+
module Nukadoko
|
|
9
|
+
module Engine
|
|
10
|
+
# `keyword_line` is the Scenario keyword's own line, the same for every
|
|
11
|
+
# row of an outline. A step from a Background has `background` set.
|
|
12
|
+
Scenario = Data.define(:uri, :line, :keyword_line, :name, :tags, :steps)
|
|
13
|
+
PickleStep = Data.define(:text, :line, :kind, :table, :doc_string, :background)
|
|
14
|
+
|
|
15
|
+
def self.load_features(dir)
|
|
16
|
+
paths = Dir.glob(File.join(dir, "**/*.feature")).sort
|
|
17
|
+
lines = {}
|
|
18
|
+
background = {}
|
|
19
|
+
scenarios = []
|
|
20
|
+
Gherkin.from_paths(paths, include_source: false, include_gherkin_document: true, include_pickles: true).each do |envelope|
|
|
21
|
+
if (doc = envelope.gherkin_document)
|
|
22
|
+
collect_lines(doc.feature, lines, background) if doc.feature
|
|
23
|
+
elsif (pickle = envelope.pickle)
|
|
24
|
+
steps = pickle.steps.map do |step|
|
|
25
|
+
table = step.argument&.data_table&.rows&.map { |row| row.cells.map(&:value) }
|
|
26
|
+
PickleStep.new(step.text, lines.fetch(step.ast_node_ids.first), step.type, table, step.argument&.doc_string&.content,
|
|
27
|
+
background.key?(step.ast_node_ids.first))
|
|
28
|
+
end
|
|
29
|
+
scenarios << Scenario.new(pickle.uri, lines.fetch(pickle.ast_node_ids.last), lines.fetch(pickle.ast_node_ids.first),
|
|
30
|
+
pickle.name, pickle.tags.map(&:name), steps)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
scenarios
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.collect_lines(node, lines, background)
|
|
37
|
+
node.children.each do |child|
|
|
38
|
+
next collect_lines(child.rule, lines, background) if child.rule
|
|
39
|
+
|
|
40
|
+
scenario = child.scenario || child.background
|
|
41
|
+
next unless scenario
|
|
42
|
+
|
|
43
|
+
lines[scenario.id] = scenario.location.line if child.scenario
|
|
44
|
+
scenario.steps.each { |step| lines[step.id] = step.location.line }
|
|
45
|
+
child.background&.steps&.each { |step| background[step.id] = true }
|
|
46
|
+
child.scenario&.examples&.each { |examples| examples.table_body.each { |row| lines[row.id] = row.location.line } }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# Fixtures are built by the engine, not the step, so what they record is
|
|
7
|
+
# measured: every request a step sends goes through Request and lands in the
|
|
8
|
+
# step's http.jsonl as it was sent and answered.
|
|
9
|
+
module Nukadoko
|
|
10
|
+
module Engine
|
|
11
|
+
Response = Data.define(:status, :headers, :body) do
|
|
12
|
+
def json = JSON.parse(body)
|
|
13
|
+
def ok? = status.between?(200, 299)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Request
|
|
17
|
+
attr_reader :log
|
|
18
|
+
|
|
19
|
+
def initialize(base_url)
|
|
20
|
+
@base = URI(base_url)
|
|
21
|
+
@log = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
%w[get post put patch delete].each do |verb|
|
|
25
|
+
# `data:` is sent as JSON; `body:` is sent as it is, for a request
|
|
26
|
+
# whose body is meant to be wrong.
|
|
27
|
+
define_method(verb) do |path, data: nil, body: nil, headers: {}|
|
|
28
|
+
uri = URI.join(@base, path)
|
|
29
|
+
klass = Net::HTTP.const_get(verb.capitalize)
|
|
30
|
+
req = klass.new(uri, headers)
|
|
31
|
+
if data
|
|
32
|
+
req["content-type"] = "application/json"
|
|
33
|
+
req.body = JSON.generate(data)
|
|
34
|
+
end
|
|
35
|
+
req.body = body if body
|
|
36
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
res = Net::HTTP.start(uri.host, uri.port) { _1.request(req) }
|
|
38
|
+
@log << { method: verb.upcase, url: uri.to_s, request_body: req.body, status: res.code.to_i,
|
|
39
|
+
response_body: res.body, ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1) }
|
|
40
|
+
Response.new(res.code.to_i, res.to_hash, res.body.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Opened only for a step whose body names page or context, as in the
|
|
46
|
+
# TypeScript engine. Requests the page sends are recorded by the engine's
|
|
47
|
+
# own listener, and each step gets a trace chunk.
|
|
48
|
+
class Browser
|
|
49
|
+
def initialize(base_url, launch_options = {})
|
|
50
|
+
@base_url = base_url
|
|
51
|
+
@launch_options = launch_options
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def page_for(step_dir, log)
|
|
55
|
+
require "playwright"
|
|
56
|
+
@execution ||= Playwright.create(playwright_cli_executable_path: ENV.fetch("PLAYWRIGHT_CLI", "npx playwright"))
|
|
57
|
+
@browser ||= @execution.playwright.chromium.launch(**@launch_options)
|
|
58
|
+
@context ||= @browser.new_context(baseURL: @base_url).tap { _1.tracing.start(screenshots: true, snapshots: true) }
|
|
59
|
+
@page ||= @context.new_page
|
|
60
|
+
@context.tracing.start_chunk
|
|
61
|
+
listener = ->(req) { log << { method: req.method, url: req.url, from: "page" } }
|
|
62
|
+
@page.on("request", listener)
|
|
63
|
+
[@page, -> { @page.off("request", listener); @context.tracing.stop_chunk(path: File.join(step_dir, "trace.zip")) }]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Each scenario gets a browser context of its own, so cookies and storage
|
|
67
|
+
# never carry from one scenario to the next.
|
|
68
|
+
def end_scenario
|
|
69
|
+
@context&.tracing&.stop
|
|
70
|
+
@context&.close
|
|
71
|
+
@context = @page = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def close
|
|
75
|
+
@browser&.close
|
|
76
|
+
@execution&.stop
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
# The `call` fixture: a step runs one of the parts it declares, with args it
|
|
6
|
+
# supplies itself (a call never reads `from`). The part's args and returns
|
|
7
|
+
# are checked as a scenario line's are, and each call lands under `calls` in
|
|
8
|
+
# the calling step's record, nested when a part calls a part of its own.
|
|
9
|
+
module Nukadoko
|
|
10
|
+
module Engine
|
|
11
|
+
class Caller
|
|
12
|
+
# Raised for a call the declaration does not allow, so the step fails
|
|
13
|
+
# as broken rather than as a claim that did not hold.
|
|
14
|
+
class Refused < StandardError; end
|
|
15
|
+
|
|
16
|
+
attr_reader :calls
|
|
17
|
+
|
|
18
|
+
def initialize(step, fixtures)
|
|
19
|
+
@fixtures = fixtures
|
|
20
|
+
@stack = [step]
|
|
21
|
+
@calls = []
|
|
22
|
+
@into = [@calls]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_proc = method(:call).to_proc
|
|
26
|
+
|
|
27
|
+
def call(part, args = {}, **keywords)
|
|
28
|
+
caller = @stack.last
|
|
29
|
+
unless part.is_a?(Class) && part < Step && caller.parts.include?(part)
|
|
30
|
+
raise Refused, "#{caller.step_name} calls #{part.respond_to?(:step_name) ? part.step_name : part.inspect}, which its parts do not declare"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
args = args.merge(keywords).transform_keys(&:to_s)
|
|
34
|
+
entry = { part: part.step_name, args: args, started_at: Time.now.utc.iso8601(3) }
|
|
35
|
+
@into.last << entry
|
|
36
|
+
run(part, args, entry)
|
|
37
|
+
ensure
|
|
38
|
+
entry[:finished_at] = Time.now.utc.iso8601(3) if entry
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def run(part, args, entry)
|
|
44
|
+
problems = part.args.problems(args)
|
|
45
|
+
raise TypeError, "args of #{part.step_name}: #{problems.join("; ")}" if problems.any?
|
|
46
|
+
|
|
47
|
+
@stack << part
|
|
48
|
+
@into << (entry[:calls] = [])
|
|
49
|
+
value = part.new.run(**@fixtures.slice(*part.fixtures), args: part.inputs.load(args))
|
|
50
|
+
problems = part.returns ? part.returns.problems(value) : []
|
|
51
|
+
raise TypeError, "returns of #{part.step_name}: #{problems.join("; ")}" if problems.any?
|
|
52
|
+
|
|
53
|
+
value = part.returns.strip(value) if part.returns
|
|
54
|
+
entry[:result] = value
|
|
55
|
+
part.returns ? part.returns.load(value) : value
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
entry[:error] ||= "#{e.class}: #{e.message}"
|
|
58
|
+
raise
|
|
59
|
+
ensure
|
|
60
|
+
if @stack.last == part
|
|
61
|
+
@stack.pop
|
|
62
|
+
@into.pop
|
|
63
|
+
entry.delete(:calls) if entry[:calls]&.empty?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cucumber/cucumber_expressions/cucumber_expression"
|
|
4
|
+
require "cucumber/cucumber_expressions/parameter_type_registry"
|
|
5
|
+
|
|
6
|
+
# A pattern names every capture (`{title:string}`). The names are stripped and
|
|
7
|
+
# the plain expression goes to the official cucumber-expressions gem, so the
|
|
8
|
+
# syntax owner is the same one the TypeScript engine uses.
|
|
9
|
+
module Nukadoko
|
|
10
|
+
module Engine
|
|
11
|
+
class Pattern
|
|
12
|
+
NAMED = /\{([A-Za-z_][\w-]*):([\w-]+)\}/
|
|
13
|
+
REGISTRY = Cucumber::CucumberExpressions::ParameterTypeRegistry.new
|
|
14
|
+
|
|
15
|
+
attr_reader :source, :keys
|
|
16
|
+
|
|
17
|
+
def initialize(source)
|
|
18
|
+
@source = source
|
|
19
|
+
@keys = source.scan(NAMED).map(&:first)
|
|
20
|
+
@expression = Cucumber::CucumberExpressions::CucumberExpression.new(source.gsub(NAMED, '{\2}'), REGISTRY)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def match(text)
|
|
24
|
+
found = @expression.match(text)
|
|
25
|
+
found && keys.zip(found.map { _1.value(nil) }).to_h
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
# `poll timeout_ms: 3000` reads `observe` again, 100 ms apart, until every
|
|
6
|
+
# claim holds on two consecutive readings or the timeout passes. One holding
|
|
7
|
+
# reading is never enough: a page between two renders can show the expected
|
|
8
|
+
# state for one frame. `settled:` names a state that cannot change once
|
|
9
|
+
# reached, so two consecutive settled readings stop the wait for a claim
|
|
10
|
+
# that broke on it. The last reading is what the step judges, and every
|
|
11
|
+
# reading lands in the step record, because an observe that counts its
|
|
12
|
+
# calls could walk toward the expected value.
|
|
13
|
+
module Nukadoko
|
|
14
|
+
module Engine
|
|
15
|
+
class Poll
|
|
16
|
+
INTERVAL = 0.1
|
|
17
|
+
|
|
18
|
+
def initialize(options, claims, args)
|
|
19
|
+
@timeout = options.fetch(:timeout_ms) / 1000.0
|
|
20
|
+
@settled = options[:settled] || []
|
|
21
|
+
@claims = claims
|
|
22
|
+
@args = args
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A reading whose observe raised is kept with the first line of the
|
|
26
|
+
# error, and the poll reads again; only a last reading that raised
|
|
27
|
+
# fails the step with that error.
|
|
28
|
+
def call(record)
|
|
29
|
+
deadline = monotonic + @timeout
|
|
30
|
+
readings = []
|
|
31
|
+
stop = nil
|
|
32
|
+
begin
|
|
33
|
+
loop do
|
|
34
|
+
readings << read(readings.size + 1) { yield }
|
|
35
|
+
stop = stop_for(readings)
|
|
36
|
+
break if stop || monotonic + INTERVAL > deadline
|
|
37
|
+
|
|
38
|
+
sleep INTERVAL
|
|
39
|
+
end
|
|
40
|
+
ensure
|
|
41
|
+
record[:readings] = readings.map { _1.except(:value, :raised) }
|
|
42
|
+
record[:poll] = { readings: readings.size, stop: stop || "timeout" }
|
|
43
|
+
end
|
|
44
|
+
raise readings.last[:raised] if readings.last[:raised]
|
|
45
|
+
|
|
46
|
+
readings.last[:value]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def read(attempt)
|
|
52
|
+
judge(yield, attempt)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
{ attempt: attempt, at: Time.now.utc.iso8601(3), holds: false, error: "#{e.class}: #{e.message.lines.first&.chomp}", raised: e }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def judge(value, attempt)
|
|
58
|
+
judged = Claims.judge(@claims, value, @args)
|
|
59
|
+
reading = { attempt: attempt, at: Time.now.utc.iso8601(3), holds: judged.all? { _1[:holds] }, actuals: judged.map { _1[:actual] }, value: value }
|
|
60
|
+
reading[:settled] = @settled.all? { Claims.judge_one(_1, value, {})[:holds] } if @settled.any?
|
|
61
|
+
reading
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stop_for(readings)
|
|
65
|
+
last, before = readings.last(2).reverse
|
|
66
|
+
return nil unless before
|
|
67
|
+
return "held" if last[:holds] && before[:holds]
|
|
68
|
+
|
|
69
|
+
"settled" if last[:settled] && before[:settled]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
# How the program under test starts, once per environment: an environment
|
|
7
|
+
# is a world of its own, with its own port, directory, and process, so one
|
|
8
|
+
# environment's state never reaches another's scenarios.
|
|
9
|
+
module Nukadoko
|
|
10
|
+
module Engine
|
|
11
|
+
Environment = Data.define(:name, :conditions, :env)
|
|
12
|
+
|
|
13
|
+
class Program
|
|
14
|
+
def initialize(options)
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def environments
|
|
19
|
+
declared = @options[:environments]
|
|
20
|
+
return [Environment.new("default", {}, {})] unless declared
|
|
21
|
+
|
|
22
|
+
declared.map { |name, e| Environment.new(name.to_s, (e[:conditions] || {}).transform_keys(&:to_s), e[:env] || {}) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start(environment, dir, project)
|
|
26
|
+
return World.new(@options[:base_url], nil, nil) unless @options[:serve]
|
|
27
|
+
|
|
28
|
+
port = TCPServer.open("127.0.0.1", 0) { _1.addr[1] }
|
|
29
|
+
fill = ->(text) { placeholders(text.to_s, port, dir, environment) }
|
|
30
|
+
env = (@options[:env] || {}).merge(environment.env).to_h { |k, v| [k.to_s, fill.(v)] }
|
|
31
|
+
log = File.open(File.join(dir, "program.log"), "w")
|
|
32
|
+
pid = Process.spawn(env, fill.(@options[:serve][:command]), out: log, err: log, pgroup: true, chdir: project)
|
|
33
|
+
world = World.new("http://127.0.0.1:#{port}", pid, log)
|
|
34
|
+
world.wait_ready(@options[:serve][:ready])
|
|
35
|
+
world
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def placeholders(text, port, dir, environment)
|
|
41
|
+
text.gsub("{port}", port.to_s).gsub("{dir}", dir).gsub(/\{condition\.([\w.-]+)\}/) do
|
|
42
|
+
environment.conditions.fetch(Regexp.last_match(1), "").to_s
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
World = Data.define(:base_url, :pid, :log) do
|
|
48
|
+
def wait_ready(path)
|
|
49
|
+
uri = URI.join(base_url, path)
|
|
50
|
+
deadline = Time.now + 30
|
|
51
|
+
begin
|
|
52
|
+
Net::HTTP.get_response(uri)
|
|
53
|
+
rescue SystemCallError
|
|
54
|
+
raise "the program did not answer #{uri} within 30s" if Time.now > deadline
|
|
55
|
+
|
|
56
|
+
sleep 0.1
|
|
57
|
+
retry
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stop
|
|
62
|
+
Process.kill("TERM", -pid) if pid
|
|
63
|
+
Process.wait(pid) if pid
|
|
64
|
+
log&.close
|
|
65
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
66
|
+
log&.close
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fixtures a project declares in nukadoko.config.rb. A fixture block gets
|
|
4
|
+
# `use` and calls it with the value; whatever follows that call is the
|
|
5
|
+
# teardown, as with Playwright's `use()`. The block runs in a Fiber, so the
|
|
6
|
+
# teardown waits until the scope ends, and runs whether the steps passed.
|
|
7
|
+
#
|
|
8
|
+
# Nukadoko.fixture(:store) { |use| use.(Signup::Store.new) }
|
|
9
|
+
# Nukadoko.fixture(:db, scope: :process) { |use, env:| db = connect(env); use.(db); db.close }
|
|
10
|
+
module Nukadoko
|
|
11
|
+
Fixture = Data.define(:name, :scope, :block) do
|
|
12
|
+
def dependencies = block.parameters.filter_map { |kind, n| n if kind == :keyreq }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.fixture(name, scope: :scenario, &block)
|
|
16
|
+
raise ArgumentError, "scope is :scenario or :process, not #{scope.inspect}" unless %i[scenario process].include?(scope)
|
|
17
|
+
|
|
18
|
+
project_fixtures[name.to_sym] = Fixture.new(name.to_sym, scope, block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.project_fixtures = (@project_fixtures ||= {})
|
|
22
|
+
|
|
23
|
+
module Engine
|
|
24
|
+
# One scope's built fixtures, torn down in reverse build order.
|
|
25
|
+
class FixtureCache
|
|
26
|
+
def initialize
|
|
27
|
+
@values = {}
|
|
28
|
+
@fibers = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def key?(name) = @values.key?(name)
|
|
32
|
+
def [](name) = @values.fetch(name)
|
|
33
|
+
|
|
34
|
+
def build(fixture, deps)
|
|
35
|
+
fiber = Fiber.new do
|
|
36
|
+
fixture.block.call(->(value) { Fiber.yield(value) }, **deps)
|
|
37
|
+
:done
|
|
38
|
+
end
|
|
39
|
+
value = fiber.resume
|
|
40
|
+
raise "fixture #{fixture.name} returned without calling use" if value == :done && !fiber.alive?
|
|
41
|
+
|
|
42
|
+
@fibers << fiber
|
|
43
|
+
@values[fixture.name] = value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Every teardown runs; the first failure is raised after the rest.
|
|
47
|
+
def teardown
|
|
48
|
+
failure = nil
|
|
49
|
+
@fibers.reverse_each do |fiber|
|
|
50
|
+
fiber.resume if fiber.alive?
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
failure ||= e
|
|
53
|
+
end
|
|
54
|
+
@fibers.clear
|
|
55
|
+
@values.clear
|
|
56
|
+
raise failure if failure
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|