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

Class Method Details

.balanced?(s) ⇒ Boolean

Returns:

  • (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