Module: Bundler::Spinel::TestRunner
- Defined in:
- lib/bundler/spinel/test_runner.rb
Overview
Turn a gem’s own minitest/test-unit suite into a Spinel-compilable smoke (the ‘exercised-by-own-tests` signal, spinelgems#6). Two Spinel realities force a build-time translation rather than running the framework as-is:
1. No reflection. Frameworks discover tests via `Module#instance_methods`,
which Spinel returns 0 for. So we parse the test file with Ripper (in
CRuby, at build) and emit an *explicit* runner — `T.new.test_a; ...`.
2. No polymorphic methods. A shared `assert_equal(exp, act)` is called with
heterogeneous types across tests; Spinel monomorphizes one C function and
fails to type it. So each assertion is rewritten to `__t(<bool>)` — the
comparison happens at the call site (locally typed), and `__t` only ever
takes a bool.
The result is run by the normal differential Verifier (CRuby vs Spinel, diff the ‘TESTS pass=N fail=M` line) — a divergence is a caught miscompile.
Class Method Summary collapse
- .balanced?(s) ⇒ Boolean
-
.chunk(src) ⇒ Object
one test file -> [rewritten_body, klass, methods] or nil.
-
.const_name(node) ⇒ Object
const_ref / const_path_ref / var_ref -> “A::B” string.
-
.generate(test_file) ⇒ Object
single-file convenience (kept for tests).
-
.generate_suite(dir) ⇒ Object
Generate one combined runner smoke from all of a gem’s test files, or nil if none parse into a recognizable test class.
-
.rewrite_assertions(body) ⇒ Object
Rewrite single-line assertions to a monomorphic ‘__t(<bool>)`.
- .rewrite_line(line) ⇒ Object
-
.split_top(s) ⇒ Object
Split a top-level comma list, respecting (), [], {}, strings.
-
.strip_requires(src) ⇒ Object
— textual rewriting ———————————————-.
-
.test_class(src) ⇒ Object
— Ripper-driven extraction —————————————.
-
.test_files(dir) ⇒ Object
Locate a gem’s test files (unit suites only — skip spec/ which is ~always RSpec, out of reach).
- .test_methods(src) ⇒ Object
Class Method Details
.balanced?(s) ⇒ Boolean
170 171 172 173 174 |
# File 'lib/bundler/spinel/test_runner.rb', line 170 def balanced?(s) depth = 0 s.each_char { |c| depth += 1 if "([{".include?(c); depth -= 1 if ")]}".include?(c); return false if depth < 0 } depth == 0 end |
.chunk(src) ⇒ Object
one test file -> [rewritten_body, klass, methods] or nil
53 54 55 56 57 58 |
# File 'lib/bundler/spinel/test_runner.rb', line 53 def chunk(src) klass = test_class(src) or return nil methods = test_methods(src) return nil if methods.empty? [rewrite_assertions(strip_requires(src)), klass, methods] end |
.const_name(node) ⇒ Object
const_ref / const_path_ref / var_ref -> “A::B” string
103 104 105 106 107 108 109 110 111 112 |
# File 'lib/bundler/spinel/test_runner.rb', line 103 def const_name(node) return nil unless node.is_a?(Array) case node[0] when :const_ref, :var_ref, :var_field then const_name(node[1]) when :@const then node[1] when :const_path_ref, :const_path_field [const_name(node[1]), const_name(node[2])].compact.join("::") when :top_const_ref then const_name(node[1]) end end |
.generate(test_file) ⇒ Object
single-file convenience (kept for tests)
60 61 62 63 64 65 66 67 68 |
# File 'lib/bundler/spinel/test_runner.rb', line 60 def generate(test_file) # single-file convenience (kept for tests) c = chunk(File.read(test_file)) or return nil body, klass, methods = c out = +"$P = 0; $F = 0\nclass Test; class Unit; class TestCase; end; end; end\nmodule Minitest; class Test; end; end\n" out << body << "\n" methods.each { |m| out << "begin; #{klass}.new.#{m}; rescue => e; $F += 1; end\n" } out << %{puts("TESTS pass=" + $P.to_s + " fail=" + $F.to_s)\n} out end |
.generate_suite(dir) ⇒ Object
Generate one combined runner smoke from all of a gem’s test files, or nil if none parse into a recognizable test class.
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/bundler/spinel/test_runner.rb', line 35 def generate_suite(dir) files = test_files(dir) return nil if files.empty? chunks = files.map { |f| chunk(File.read(f)) }.compact return nil if chunks.empty? out = +"$P = 0; $F = 0\n" out << "class Test; class Unit; class TestCase; end; end; end\n" # tolerate `< Test::Unit::TestCase` out << "module Minitest; class Test; end; end\n" chunks.each do |(body, klass, methods)| out << body << "\n" methods.each { |m| out << "begin; #{klass}.new.#{m}; rescue => e; $F += 1; end\n" } end out << %{puts("TESTS pass=" + $P.to_s + " fail=" + $F.to_s)\n} out end |
.rewrite_assertions(body) ⇒ Object
Rewrite single-line assertions to a monomorphic ‘__t(<bool>)`. Unhandled forms are left as-is (they’ll resolve to nothing or surface honestly).
122 123 124 |
# File 'lib/bundler/spinel/test_runner.rb', line 122 def rewrite_assertions(body) body.lines.map { |line| rewrite_line(line) }.join end |
.rewrite_line(line) ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/bundler/spinel/test_runner.rb', line 126 def rewrite_line(line) m = line.match(/^(\s*)(assert_equal|assert_nil|assert|refute|refute_equal)\b[ (](.*)$/) return line unless m indent, kind, rest = m[1], m[2], m[3].rstrip rest = rest[0...-1] if rest.end_with?(")") && balanced?(rest[0...-1]) # drop a wrapping paren args = split_top(rest) expr = case kind when "assert_equal" then args.size >= 2 ? "(#{args[0]}) == (#{args[1]})" : nil when "refute_equal" then args.size >= 2 ? "(#{args[0]}) != (#{args[1]})" : nil when "assert_nil" then args[0] ? "(#{args[0]}).nil?" : nil when "assert" then args[0] ? "(#{args[0]})" : nil when "refute" then args[0] ? "!(#{args[0]})" : nil end return line unless expr "#{indent}if #{expr} then $P += 1 else $F += 1 end\n" end |
.split_top(s) ⇒ Object
Split a top-level comma list, respecting (), [], {}, strings.
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/bundler/spinel/test_runner.rb', line 145 def split_top(s) parts = [] depth = 0 buf = +"" q = nil s.each_char do |c| if q buf << c q = nil if c == q next end case c when '"', "'" then q = c; buf << c when "(", "[", "{" then depth += 1; buf << c when ")", "]", "}" then depth -= 1; buf << c when "," if depth <= 0 then parts << buf.strip; buf = +"" else buf << c end else buf << c end end parts << buf.strip unless buf.strip.empty? parts end |
.strip_requires(src) ⇒ Object
— textual rewriting ———————————————-
116 117 118 |
# File 'lib/bundler/spinel/test_runner.rb', line 116 def strip_requires(src) src.lines.reject { |l| l =~ /^\s*require(_relative)?\s/ }.join end |
.test_class(src) ⇒ Object
— Ripper-driven extraction —————————————
72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/bundler/spinel/test_runner.rb', line 72 def test_class(src) sexp = Ripper.sexp(src) or return nil found = nil walk = lambda do |n| return unless n.is_a?(Array) if n[0] == :class && (cp = const_name(n[1])) sup = const_name(n[2]) found ||= cp if sup && (sup =~ /TestCase\z/ || sup =~ /Minitest::Test\z/ || sup == "Test") end n.each { |c| walk.call(c) if c.is_a?(Array) } end walk.call(sexp) found end |
.test_files(dir) ⇒ Object
Locate a gem’s test files (unit suites only — skip spec/ which is ~always RSpec, out of reach). Returns [] if none.
26 27 28 29 30 31 |
# File 'lib/bundler/spinel/test_runner.rb', line 26 def test_files(dir) Dir[File.join(dir, "test", "**", "*.rb")].select do |f| b = File.basename(f) b.start_with?("test_") || b.end_with?("_test.rb") end.sort end |
.test_methods(src) ⇒ Object
87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/bundler/spinel/test_runner.rb', line 87 def test_methods(src) sexp = Ripper.sexp(src) or return [] names = [] walk = lambda do |n| return unless n.is_a?(Array) if n[0] == :def && n[1].is_a?(Array) && n[1][0] == :@ident nm = n[1][1] names << nm if nm.start_with?("test_") end n.each { |c| walk.call(c) if c.is_a?(Array) } end walk.call(sexp) names.uniq end |