Closed14

rspec-daemonできるか?

ピン留めされたアイテム
inomotoinomoto

結論

ここまででサンドボックス環境では問題なく動くようにできた。
しかし現実的にrails環境で外部設定ファイルとかちゃんと読むのか?などの不安はある。
bundle exec rspecだと無指定で全ファイル実行するがこれはそうならないし、何か考慮すべき前処理が抜けてる可能性は高い。初期化とか。

そのあたりのdeep diveはまた今度。初期実験としてはいい感じ。

inomotoinomoto

最終版スクリプト

追記: 2023/4/11
簡易的に下記コードをgist化 & MITライセンスを明記しました。LICENSEファイルがある以外は同じコードになっています。
https://gist.github.com/cumet04/71d7d76310f7cb436c68b57a7c99aae3

rspec_daemon.rb
# frozen_string_literal: true

require 'socket'
require 'stringio'

SCRIPT_NAME = File.basename(__FILE__).freeze

def entry_point
  RSpec::Core::Runner.disable_autorun!
  server = TCPServer.open('0.0.0.0', 3002)

  loop do
    handle_request(server.accept)
  rescue Interrupt
    puts 'quit'
    server.close
    break
  end
end

def handle_request(socket)
  status, out = run(socket.read)

  socket.puts(status)
  socket.puts(out)
  socket.puts(__FILE__)
rescue StandardError => e
  socket.puts e.full_message
ensure
  socket.close
end

def run(msg, options = [])
  options += ['--force-color']
  argv = msg.split(' ')

  RSpec.reset
  out = StringIO.new
  status = RSpec::Core::Runner.run(options + argv, out, out)

  [status, out.string]
end

RSpec::Core::BacktraceFormatter.class_eval do
  def format_backtrace(backtrace, options = {})
    return [] unless backtrace
    return backtrace if options[:full_backtrace] || backtrace.empty?

    backtrace.map { |l| backtrace_line(l) }.compact.inject([]) do |result, line|
      break result if line.include?(SCRIPT_NAME)

      result << line
    end
  end
end

entry_point
rspec
#!/bin/bash

set -eu
cd $(dirname $0)/..

NETCAT="nc -N" # 環境に応じて調整

raw_output=$(echo "$@" | $NETCAT 127.0.0.1 3002)
status=$(echo "$raw_output" | head -n 1)
echo "$raw_output" | tail -n +2

exit $status
inomotoinomoto

https://github.com/fohte/rubocop-daemon

rubocop-daemonのアイデアが素晴らしいので、同じノリでrspecもできないか?という試み。
rspecが速いとTDDが捗りそう。

想定

  • rspec
  • rails
  • daemonを起動しておき、コマンドをTCPで受けて実行する
  • クライアントからはnc使う
inomotoinomoto

rubocop-daemonと同じようにするならrails前提にする必要は無いのだが、railsだとrails runnerでカジュアルにスクリプトを突っ込めて導入しやすいのではないかというところ

inomotoinomoto

実行すべき関数を探る。rspecの場合はだいたいbundle exec rspec的な感じで実行するので、rspecコマンドから辿る。

rspec-coreのそれっぽいファイルを見ると

#!/usr/bin/env ruby

require 'rspec/core'
RSpec::Core::Runner.invoke

引数とかそういうのが見当たらないのでRSpec::Core::Runner.invokeを見にいく。

def self.invoke
  disable_autorun!
  status = run(ARGV, $stderr, $stdout).to_i
  exit(status) if status != 0
end

disable_autorun!はフラグをセットしてるだけなので、runを実行すればいいっぽい。めちゃくちゃCLIの入出力をそのまま指定してるし。

inomotoinomoto

rspec rspec-railsの入ったrailsプロジェクトにてbundle exec rails runner 'RSpec::Core::Runner.run([], $stderr, $stdout)'としてみると、いつもの結果出力が見える。ok。

であれば、あとはTCPサーバ立てて入出力をあれこれしてこれに渡せばよいっぽい。

inomotoinomoto

とりあえずecho serverをこしらえる。rubocop-daemonを参考にしつつ。

# frozen_string_literal: true

require 'socket'

def read_socket(socket)
  msg = socket.read
  puts msg
  socket.write(msg)
rescue StandardError => e
  socket.puts e.full_message
ensure
  socket.close
end

server = TCPServer.open('0.0.0.0', 3002)
loop do
  read_socket(server.accept)
rescue Interrupt
  puts 'quit'
  server.close
  break
end
shellA
$ ruby lib/rspec_daemon.rb
aaa
shellB
$ echo aaa | nc -N 127.0.0.1 3002
aaa

※筆者はWSLなのでnc -Nです

inomotoinomoto

なんか動くやつ

# frozen_string_literal: true

require 'socket'
require 'stringio'

def run(msg)
  argv = msg.split(' ')

  err = StringIO.new
  out = StringIO.new
  status = RSpec::Core::Runner.run(argv, err, out)

  [status, err, out]
end

def read_socket(socket)
  status, err, out = run(socket.read)

  socket.puts(status)
  socket.puts(out.string)
  socket.puts(err.string)
...

ただし起動後の最初の一回しかテストが実行されない。なにか実行済フラグ的なのがあるのかもしれない。

inomotoinomoto

コマンドステータスなどはともかく、とにかく動くα版

# frozen_string_literal: true

require 'socket'
require 'stringio'

def run(msg)
  argv = msg.split(' ')

  RSpec.reset
  out = StringIO.new
  status = RSpec::Core::Runner.run(argv, out, out)

  [status, out.string]
end

def read_socket(socket)
  status, out = run(socket.read)

  socket.puts(status)
  socket.puts(out)
rescue StandardError => e
  socket.puts e.full_message
ensure
  socket.close
end

RSpec::Core::Runner.disable_autorun!
server = TCPServer.open('0.0.0.0', 3002)

loop do
  read_socket(server.accept)
rescue Interrupt
  puts 'quit'
  server.close
  break
end

errとoutを分けると順番がおかしくなることに気付いたのでマージ。
あとテスト失敗時のエラー出力が出過ぎなので調整

inomotoinomoto
  • 失敗時のスタックトレースを抑制する(daemonスクリプトの行まで全部出ちゃってる)
  • 出力の色が死んでるので取り出す
inomotoinomoto

スタックトレース、rspec自体の機能としてフィルタリングする機能があるのだが、この場合は「rspec_daemon以降(以前?)のトレースを全部消す」なので、これではrunnerとかspringとかbootsnapとかそのへんがいい感じに消せない。

そこで秘奥義class_eval先生にご登場願う。

RSpec::Core::BacktraceFormatter.class_eval do
  def format_backtrace(backtrace, options = {})
    return [] unless backtrace
    return backtrace if options[:full_backtrace] || backtrace.empty?

    backtrace.map { |l| backtrace_line(l) }.compact.inject([]) do |result, line|
      break result if line =~ /rspec_daemon\.rb/

      result << line
    end
  end
end

※トレースが空の場合のヘルプメッセージは長いので割愛

これでいい感じになる。

inomotoinomoto

出力の色は--force-colorオプションつければいいだけだった。

def run(msg, options = [])
  options += ['--force-color']
  argv = msg.split(' ')

  RSpec.reset
  out = StringIO.new
  status = RSpec::Core::Runner.run(options + argv, out, out)

  [status, out.string]
end
inomotoinomoto

クライアントスクリプト

#!/bin/bash

set -eu
cd $(dirname $0)/..

NETCAT="nc -N" # 環境に応じて調整

raw_output=$(echo "$@" | $NETCAT 127.0.0.1 3002)
status=$(echo "$raw_output" | head -n 1)
echo "$raw_output" | tail -n +2

exit $status
このスクラップは2021/01/03にクローズされました