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,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