jet_black 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a21eedaa0beebe53a809bb73bd3f264f4993d0eb4d6040e6521b6ef7d044b4b7
4
- data.tar.gz: 1c805ee9c3c4921e3978caced024e3c9332dcc50ef215dd10b1e3cacc366e807
3
+ metadata.gz: c389c6caddb4fe0d3b8874f96a50f9c0e4217c3e1af903b6ca9f19bca24065c5
4
+ data.tar.gz: 5c1775b10ac55262d4291a9748858776a618400dfb4fa4561357147802822ce3
5
5
  SHA512:
6
- metadata.gz: ac2904ee5ebf833094905c22ef0e8064bbc6618fab3f7319ee80feecdaf78fdb05ee7ba5e29f657221fff2fc16e8a05ac88717eee3ab9380e6e90b847aa1db20
7
- data.tar.gz: e29ddc28dfc5cb2b450a4b61bfeb037b56d34c297fde88844558168191d15166d2e4405168da10768c93f6e51eac6737158c4874a27ae7d4d210d596b86893c3
6
+ metadata.gz: 9ddb6513193a3395baef52fab1bf86b3cf1de9f5d04091b82f52cd35e8bc97c2a3e21ca7549855e7d9db7947cb1af3b5f7263ce1b9929382dd7743ceb05d7d91
7
+ data.tar.gz: eac5b131ecce2f9b5aff51b1220d3276f320972bbf712f2e1efc7794265972c72f1e6fa5197f3c69548d574ac0f1bbae5193cf16961c9daa2b382c7a652d88b6
data/.circleci/config.yml CHANGED
@@ -61,9 +61,15 @@ jobs:
61
61
  - image: circleci/ruby:2.7
62
62
  environment:
63
63
  ENABLE_COVERAGE: 1
64
+ ruby-3.0:
65
+ <<: *base_job
66
+ docker:
67
+ - image: circleci/ruby:3.0
64
68
 
65
69
  #-------------------------------------------------------------------------------
66
70
 
71
+ # TODO: Migrate to CircleCI matrix
72
+
67
73
  workflows:
68
74
  version: 2
69
75
  multiple-rubies:
@@ -72,3 +78,4 @@ workflows:
72
78
  - ruby-2.5
73
79
  - ruby-2.6
74
80
  - ruby-2.7
81
+ - ruby-3.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.7.0
4
+
5
+ - Adds `run_interactive` to allow pseudo-terminal interaction
6
+
3
7
  ## v0.6.0
4
8
 
5
9
  - Freeze string literals
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jet_black (0.6.0)
4
+ jet_black (0.7.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -15,7 +15,7 @@ GEM
15
15
  tins (~> 1.6)
16
16
  diff-lcs (1.3)
17
17
  docile (1.3.1)
18
- json (2.1.0)
18
+ json (2.3.1)
19
19
  method_source (0.9.0)
20
20
  pry (0.11.3)
21
21
  coderay (~> 1.1.0)
@@ -60,4 +60,4 @@ DEPENDENCIES
60
60
  simplecov
61
61
 
62
62
  BUNDLED WITH
63
- 2.1.2
63
+ 2.1.4
data/README.md CHANGED
@@ -11,6 +11,7 @@ with [RSpec] in mind. Features:
11
11
  - Synchronously [run commands](#running-commands) then write assertions on:
12
12
  - The `stdout` / `stderr` content
13
13
  - The exit status of the process
14
+ - Exercise [interactive command line interfaces](#running-interactive-commands)
14
15
  - Manipulate files in the temporary directory:
15
16
  - [Create files](#file-manipulation)
16
17
  - [Create executable files](#file-manipulation)
@@ -85,6 +86,38 @@ session = JetBlack::Session.new
85
86
  session.run("./hello-world", stdin: "Alice")
86
87
  ```
87
88
 
89
+ ### Running interactive commands
90
+
91
+ ```ruby
92
+ session = JetBlack::Session.new
93
+
94
+ result = session.run_interactive("./hello-world") do |terminal|
95
+ terminal.expect("What's your name?", reply: "Alice")
96
+ terminal.expect("What's your location?", reply: "Wonderland")
97
+ end
98
+
99
+ expect(result.exit_status).to eq 0
100
+ expect(result.stdout).to eq <<~TXT
101
+ What's your name?
102
+ Alice
103
+ What's your location?
104
+ Wonderland
105
+ Hello Alice in Wonderland
106
+ TXT
107
+ ```
108
+
109
+ If you don't want to wait for a process to finish, you can end the interactive
110
+ session early:
111
+
112
+ ```ruby
113
+ session = JetBlack::Session.new
114
+
115
+ result = session.run_interactive("./long-cli-flow") do |terminal|
116
+ terminal.expect("Question 1", reply: "Y")
117
+ terminal.end_session(signal: "INT")
118
+ end
119
+ ```
120
+
88
121
  ### File manipulation
89
122
 
90
123
  ```ruby
@@ -33,4 +33,17 @@ module JetBlack
33
33
  MSG
34
34
  end
35
35
  end
36
+
37
+ class TerminalSessionTimeoutError < Error
38
+ attr_reader :terminal
39
+
40
+ def initialize(terminal, expected_value, timeout)
41
+ @terminal = terminal
42
+
43
+ super <<~MSG
44
+ Interactive terminal session timed out after #{timeout} second(s).
45
+ Waiting for: '#{expected_value}'
46
+ MSG
47
+ end
48
+ end
36
49
  end
@@ -0,0 +1,24 @@
1
+ require_relative "environment"
2
+ require_relative "terminal_session"
3
+
4
+ module JetBlack
5
+ class InteractiveCommand
6
+ def call(raw_command:, raw_env:, directory:, block:)
7
+ env = Environment.new(raw_env).to_h
8
+ terminal = TerminalSession.new(raw_command, env: env, directory: directory)
9
+
10
+ unless block.nil?
11
+ block.call(terminal)
12
+ end
13
+
14
+ terminal.wait_for_finish
15
+
16
+ ExecutedCommand.new(
17
+ raw_command: raw_command,
18
+ stdout: terminal.stdout,
19
+ stderr: terminal.stderr,
20
+ exit_status: terminal.exit_status,
21
+ )
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ require "open3"
2
+ require_relative "environment"
3
+
4
+ module JetBlack
5
+ class NonInteractiveCommand
6
+ def call(raw_command:, stdin:, raw_env:, directory:)
7
+ env = Environment.new(raw_env).to_h
8
+
9
+ stdout, stderr, exit_status = Open3.capture3(
10
+ env, raw_command, chdir: directory, stdin_data: stdin
11
+ )
12
+
13
+ ExecutedCommand.new(
14
+ raw_command: raw_command,
15
+ stdout: stdout,
16
+ stderr: stderr,
17
+ exit_status: exit_status,
18
+ )
19
+ end
20
+ end
21
+ end
@@ -3,12 +3,12 @@
3
3
  require "bundler"
4
4
  require "fileutils"
5
5
  require "forwardable"
6
- require "open3"
7
6
  require "tmpdir"
8
- require_relative "environment"
9
7
  require_relative "errors"
10
8
  require_relative "executed_command"
11
9
  require_relative "file_helper"
10
+ require_relative "non_interactive_command"
11
+ require_relative "interactive_command"
12
12
 
13
13
  module JetBlack
14
14
  class Session
@@ -27,33 +27,38 @@ module JetBlack
27
27
  end
28
28
 
29
29
  def run(command, stdin: nil, env: {}, options: {})
30
- combined_options = session_options.merge(options)
31
- executed_command = exec_command(command, stdin, env, combined_options)
32
- commands << executed_command
33
- executed_command
30
+ exec_non_interactive(raw_command: command, stdin: stdin, raw_env: env, options: options).tap do |executed_command|
31
+ commands << executed_command
32
+ end
33
+ end
34
+
35
+ def run_interactive(command, env: {}, options: {}, &block)
36
+ exec_interactive(raw_command: command, raw_env: env, options: options, block: block).tap do |executed_command|
37
+ commands << executed_command
38
+ end
34
39
  end
35
40
 
36
41
  private
37
42
 
38
43
  attr_reader :session_options, :file_helper
39
44
 
40
- def exec_command(raw_command, stdin, raw_env, options)
41
- env = Environment.new(raw_env).to_h
45
+ def exec_non_interactive(raw_command:, stdin:, raw_env:, options:)
46
+ combined_options = session_options.merge(options)
42
47
 
43
- command_context(options) do
44
- stdout, stderr, exit_status =
45
- Open3.capture3(env, raw_command, chdir: directory, stdin_data: stdin)
48
+ execution_context(combined_options) do
49
+ NonInteractiveCommand.new.call(raw_command: raw_command, stdin: stdin, raw_env: raw_env, directory: directory)
50
+ end
51
+ end
52
+
53
+ def exec_interactive(raw_command:, raw_env:, options:, block:)
54
+ combined_options = session_options.merge(options)
46
55
 
47
- ExecutedCommand.new(
48
- raw_command: raw_command,
49
- stdout: stdout,
50
- stderr: stderr,
51
- exit_status: exit_status,
52
- )
56
+ execution_context(combined_options) do
57
+ InteractiveCommand.new.call(raw_command: raw_command, raw_env: raw_env, directory: directory, block: block)
53
58
  end
54
59
  end
55
60
 
56
- def command_context(options)
61
+ def execution_context(options)
57
62
  if options[:clean_bundler_env]
58
63
  Bundler.public_send(bundler_clean_environment_method) { yield }
59
64
  else
@@ -0,0 +1,91 @@
1
+ require "pty"
2
+ require "expect"
3
+ require_relative "errors"
4
+
5
+ module JetBlack
6
+ class TerminalSession
7
+ DEFAULT_TIMEOUT = 10
8
+
9
+ attr_reader :exit_status
10
+
11
+ def initialize(raw_command, env:, directory:)
12
+ @stderr_reader, @stderr_writer = IO.pipe
13
+ @output, @input, @pid = PTY.spawn(env, raw_command, chdir: directory, err: stderr_writer.fileno)
14
+ self.raw_stdout = []
15
+ end
16
+
17
+ def expect(expected_value, reply: nil, timeout: DEFAULT_TIMEOUT, signal_on_timeout: "KILL")
18
+ output_matches = output.expect(expected_value, timeout)
19
+
20
+ if output_matches.nil?
21
+ end_session(signal: signal_on_timeout)
22
+ raise TerminalSessionTimeoutError.new(self, expected_value, timeout)
23
+ end
24
+
25
+ raw_stdout.concat(output_matches)
26
+
27
+ if reply != nil
28
+ input.puts(reply)
29
+ end
30
+ end
31
+
32
+ def stdout
33
+ raw_stdout.join.gsub("\r", "")
34
+ end
35
+
36
+ def stderr
37
+ raw_std_err
38
+ end
39
+
40
+ def wait_for_finish
41
+ return if finished?
42
+
43
+ finalize_io
44
+
45
+ self.exit_status = wait_for_exit_status
46
+ end
47
+
48
+ def end_session(signal: "INT")
49
+ Process.kill(signal, pid)
50
+ finalize_io
51
+
52
+ self.exit_status = wait_for_exit_status
53
+ end
54
+
55
+ def finished?
56
+ !exit_status.nil?
57
+ end
58
+
59
+ private
60
+
61
+ attr_accessor :raw_stdout, :raw_std_err
62
+ attr_reader :input, :output, :pid, :stderr_reader, :stderr_writer
63
+ attr_writer :exit_status
64
+
65
+ def finalize_io
66
+ drain_stdout
67
+ drain_stderr
68
+ end
69
+
70
+ def wait_for_exit_status
71
+ _, pty_status = Process.waitpid2(pid)
72
+ pty_status.exitstatus || pty_status.termsig
73
+ end
74
+
75
+ def drain_stdout
76
+ until output.eof? do
77
+ raw_stdout << output.readline
78
+ end
79
+
80
+ input.close
81
+ output.close
82
+ rescue Errno::EIO => e # https://github.com/ruby/ruby/blob/57fb2199059cb55b632d093c2e64c8a3c60acfbb/ext/pty/pty.c#L521
83
+ warn("Rescued #{e.message}") if ENV.key?("DEBUG")
84
+ end
85
+
86
+ def drain_stderr
87
+ stderr_writer.close
88
+ self.raw_std_err = stderr_reader.read
89
+ end
90
+ end
91
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JetBlack
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jet_black
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oli Peate
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-02-13 00:00:00.000000000 Z
11
+ date: 2021-02-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -114,8 +114,8 @@ dependencies:
114
114
  - - ">="
115
115
  - !ruby/object:Gem::Version
116
116
  version: '0'
117
- description:
118
- email:
117
+ description:
118
+ email:
119
119
  executables: []
120
120
  extensions: []
121
121
  extra_rdoc_files: []
@@ -141,15 +141,18 @@ files:
141
141
  - lib/jet_black/errors.rb
142
142
  - lib/jet_black/executed_command.rb
143
143
  - lib/jet_black/file_helper.rb
144
+ - lib/jet_black/interactive_command.rb
145
+ - lib/jet_black/non_interactive_command.rb
144
146
  - lib/jet_black/rspec.rb
145
147
  - lib/jet_black/rspec/matchers.rb
146
148
  - lib/jet_black/session.rb
149
+ - lib/jet_black/terminal_session.rb
147
150
  - lib/jet_black/version.rb
148
151
  homepage: https://github.com/odlp/jet_black
149
152
  licenses:
150
153
  - MIT
151
154
  metadata: {}
152
- post_install_message:
155
+ post_install_message:
153
156
  rdoc_options: []
154
157
  require_paths:
155
158
  - lib
@@ -164,8 +167,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
164
167
  - !ruby/object:Gem::Version
165
168
  version: '0'
166
169
  requirements: []
167
- rubygems_version: 3.1.2
168
- signing_key:
170
+ rubygems_version: 3.1.4
171
+ signing_key:
169
172
  specification_version: 4
170
173
  summary: Black box testing
171
174
  test_files: []