diff --git a/CHANGELOG.md b/CHANGELOG.md index 385f310..038a15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ ## HEAD +## 4.0.0 + +- Add: Rundoc command `background.stdin_write` to send a string to a backtround process' STDIN. This allows driving REPL interfaces (https://github.com/zombocom/rundoc/pull/79) + +``` +:::>- background.start("ruby #{script}", + name: "script", + wait: ">", + timeout: 15 +) +:::-- background.stdin_write("hello", name: "script", wait: "hello") +:::-- background.stdin_write("exit", name: "script", wait: "exit") +:::>> background.stop(name: "script") +``` + +- Changed: Rundoc command `background.stop` now outputs the log contents on `:::>>`. Previously it output nothing (https://github.com/zombocom/rundoc/pull/79) +- Changed: Strings passed into `background.wait` and similar `wait:` arguments of other background rundoc commands were accidentally being converted into regex, now they are matched as string literals. If you need regexes, please open an issue, they could be supporte but require peg_parser support (https://github.com/zombocom/rundoc/pull/79) + ## 3.1.2 - Fix: Using `rundoc.require` inside of a document that was `rundoc.require`-d now sources files from the correct relative document path (https://github.com/zombocom/rundoc/pull/84) @@ -11,7 +29,7 @@ ## 3.1.0 -- Add: `--with-contents` flag that accepts a directory. The **contents** of the directory (and not the directory itself) will be copied into the working dir before execution. This is useful for debugging a single rundoc step. () +- Add: `--with-contents` flag that accepts a directory. The **contents** of the directory (and not the directory itself) will be copied into the working dir before execution. This is useful for debugging a single rundoc step. (https://github.com/zombocom/rundoc/pull/83) For example if `RUNDOC.md` features many smaller docs: diff --git a/README.md b/README.md index ceef388..55eea4a 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,10 @@ This will generate a project folder with your project in it, and a markdown `REA - Boot background processes such as a local server - [background.start](#background) - [background.stop](#background) + - [background.stdin_write](#background) + - [background.wait](#background) - [background.log.read](#background) - [background.log.clear](#background) - - [background.wait](#background) - Take screenshots - [website.visit](#screenshots) - [website.nav](#screenshots) @@ -424,6 +425,21 @@ You can make the background process wait until it receives a certain string in t :::>> background.start("rails server", name: "server", wait: "Listening on") ``` +You can send strings to the STDIN of the process: + +``` +:::>> background.start("heroku run bash", name: "heroku_run", wait: "$") +:::-- background.stdin_write("ls", name: "heroku_run") +``` + +- Arguments + - contents: Positional. The string to write to stdin + - ending: A string to append to the end of the contents, defaults to a newline + you could set it to something like ";" or an empty newline "" + - name + - wait + - timeout + You can stop the process by referencing the name: ``` @@ -433,6 +449,7 @@ You can stop the process by referencing the name: - Arguments - name + You can also get the log contents: ``` diff --git a/lib/rundoc/code_command/background.rb b/lib/rundoc/code_command/background.rb index f7a9122..3727831 100644 --- a/lib/rundoc/code_command/background.rb +++ b/lib/rundoc/code_command/background.rb @@ -7,3 +7,4 @@ class Rundoc::CodeCommand::Background require "rundoc/code_command/background/wait" require "rundoc/code_command/background/log/clear" require "rundoc/code_command/background/log/read" +require "rundoc/code_command/background/stdin_write" diff --git a/lib/rundoc/code_command/background/process_spawn.rb b/lib/rundoc/code_command/background/process_spawn.rb index 8f8eeec..e85a356 100644 --- a/lib/rundoc/code_command/background/process_spawn.rb +++ b/lib/rundoc/code_command/background/process_spawn.rb @@ -19,7 +19,6 @@ class Rundoc::CodeCommand::Background # server.stop # server.alive? # => false # - # # There are class level methods that can be used to "name" and record # background processes. They can be used like this: # @@ -53,17 +52,31 @@ def initialize(command, timeout: 5, log: Tempfile.new("log"), out: "2>&1") @log = Pathname.new(log) @log.dirname.mkpath FileUtils.touch(@log) + @pipe_output, @pipe_input = IO.pipe @command = "/usr/bin/env bash -c #{@command.shellescape} >> #{@log} #{out}" @pid = nil end - def wait(wait_value = nil, timeout_value = @timeout_value) + # Wait until a given string is found in the logs + # + # If the string is not found within the timeout, a Timeout::Error is raised + # + # Caution: The logs will not be cleared before waiting, so if the string is + # already present from a prior operation, then it will not wait at all. + # + # To ensure you're waiting for a brand new string, call `log.truncate(0)` first. + # which is accessible via `:::-- background.log.clear` in rundoc syntax. + # + # @param wait_value [String] the string to wait for + # @param timeout_value [Integer] the number of seconds to wait before raising a Timeout::Error + # @param file [Pathname] the file to read from, default is the log file + def wait(wait_value = nil, timeout_value = @timeout_value, file: @log) call return unless wait_value Timeout.timeout(Integer(timeout_value)) do - until @log.read.match(wait_value) + until file.read.include?(wait_value) sleep 0.01 end end @@ -78,10 +91,42 @@ def alive? false end + # Writes the contents along with an optional ending character to the STDIN of the backtround process + # + # @param contents [String] the contents to write to the STDIN of the background process + # @param ending [String] an optional string to append to the contents before writing default is a newline + # if you don't want an ending, pass `""` + # @param timeout [Integer] the number of seconds to wait before raising a Timeout::Error when writing to STDIN + # or waiting for a string to appear in the logs. That means that the process can wait for a maximum + # of `timeout * 2` seconds before raising a Timeout::Error. + # @param wait [String] the string to wait for in the logs before continuing. There's a race condition + # if the process is in the middle of printing out something to the logs, then the output + # you're waiting form might not come as a result of the stdin_write. + def stdin_write(contents, ending: $/, timeout: timeout_value, wait: nil) + log_file = File.new(@log) + before_write_bytes = log_file.size + begin + Timeout.timeout(Integer(timeout)) do + @pipe_input.print(contents + ending) + @pipe_input.flush + end + rescue Timeout::Error + raise "Timeout (#{timeout}s) waiting to write #{contents} to stdin. Log contents:\n'#{log.read}'" + end + + # Ignore bytes written before we sent the STDIN message + log_file.seek(before_write_bytes) + wait(wait, timeout, file: log_file) + contents + end + def stop return unless alive? + @pipe_input.close Process.kill("TERM", -Process.getpgid(@pid)) Process.wait(@pid) + rescue Errno::ESRCH => e + puts "Error stopping process (command: #{command}): #{e}" end def check_alive! @@ -89,7 +134,7 @@ def check_alive! end private def call - @pid ||= Process.spawn(@command, pgroup: true) + @pid ||= Process.spawn(@command, pgroup: true, in: @pipe_output) end end end diff --git a/lib/rundoc/code_command/background/stdin_write.rb b/lib/rundoc/code_command/background/stdin_write.rb new file mode 100644 index 0000000..6164471 --- /dev/null +++ b/lib/rundoc/code_command/background/stdin_write.rb @@ -0,0 +1,36 @@ +class Rundoc::CodeCommand::Background + # Will send contents to the background process via STDIN along with a newline + # + # + class StdinWrite < Rundoc::CodeCommand + def initialize(contents, name:, wait:, timeout: 5, ending: $/) + @contents = contents + @ending = ending + @spawn = Rundoc::CodeCommand::Background::ProcessSpawn.find(name) + @wait = wait + @timeout_value = Integer(timeout) + @contents_written = nil + end + + # The command is rendered (`:::>-`) by the output of the `def call` method. + def to_md(env = {}) + writecontents + end + + # The contents produced by the command (`:::->`) are rendered by the `def to_md` method. + def call(env = {}) + writecontents + @spawn.log.read + end + + def writecontents + @contents_written ||= @spawn.stdin_write( + contents, + wait: @wait, + ending: @ending, + timeout: @timeout_value + ) + end + end +end +Rundoc.register_code_command(:"background.stdin_write", Rundoc::CodeCommand::Background::StdinWrite) diff --git a/lib/rundoc/code_command/background/stop.rb b/lib/rundoc/code_command/background/stop.rb index c5c3f14..9349df4 100644 --- a/lib/rundoc/code_command/background/stop.rb +++ b/lib/rundoc/code_command/background/stop.rb @@ -10,7 +10,7 @@ def to_md(env = {}) def call(env = {}) @spawn.stop - "" + @spawn.log.read end end end diff --git a/lib/rundoc/version.rb b/lib/rundoc/version.rb index 1ec2fdb..d9ab207 100644 --- a/lib/rundoc/version.rb +++ b/lib/rundoc/version.rb @@ -1,3 +1,3 @@ module Rundoc - VERSION = "3.1.2" + VERSION = "4.0.0" end diff --git a/test/integration/background_stdin_test.rb b/test/integration/background_stdin_test.rb new file mode 100644 index 0000000..f2dc231 --- /dev/null +++ b/test/integration/background_stdin_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class BackgroundStdinTest < Minitest::Test + def test_background_stdin_write + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + dir = Pathname(dir) + script = dir.join("script.rb") + script.write <<~'EOF' + $stdout.sync = true + + print "> " + while line = gets + puts line + if line.strip == "exit" + puts "Bye" + return + else + puts "You said: #{line}" + end + print "> " + end + EOF + + source_path = dir.join("RUNDOC.md") + source_path.write <<~EOF + ``` + :::>- background.start("ruby #{script}", + name: "script", + wait: ">", + timeout: 15 + ) + :::-- background.stdin_write("hello", name: "script", wait: "hello") + :::-- background.stdin_write("exit", name: "script", wait: "exit") + :::>> background.stop(name: "script") + ``` + EOF + + io = StringIO.new + Rundoc::CLI.new( + io: io, + source_path: source_path, + on_success_dir: dir.join(SUCCESS_DIRNAME) + ).call + + readme = dir.join(SUCCESS_DIRNAME).join("README.md").read + expected = <<~EOF + > hello + You said: hello + > exit + Bye + EOF + assert readme.include?(expected) + end + end + end +end diff --git a/test/rundoc/code_commands/background_test.rb b/test/rundoc/code_commands/background_test.rb index 6317876..ecb62cf 100644 --- a/test/rundoc/code_commands/background_test.rb +++ b/test/rundoc/code_commands/background_test.rb @@ -1,11 +1,39 @@ require "test_helper" class BackgroundTest < Minitest::Test + def test_stdin_with_cat_echo + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + background_start = Rundoc::CodeCommand::Background::Start.new("cat", + name: "cat") + background_start.call + + output = Rundoc::CodeCommand::Background::StdinWrite.new( + "hello there", + name: "cat", + wait: "hello" + ).call + assert_equal("hello there" + $/, output) + + Rundoc::CodeCommand::Background::Log::Clear.new( + name: "cat" + ).call + + output = Rundoc::CodeCommand::Background::StdinWrite.new( + "general kenobi", + name: "cat", + wait: "general" + ).call + assert_equal("general kenobi" + $/, output) + end + end + end + def test_process_spawn_gc Dir.mktmpdir do |dir| Dir.chdir(dir) do file = "foo.txt" - `echo 'foo' >> #{file}` + run!("echo 'foo' >> #{file}") background_start = Rundoc::CodeCommand::Background::Start.new("tail -f #{file}", name: "tail2", @@ -30,7 +58,7 @@ def test_background_start Dir.mktmpdir do |dir| Dir.chdir(dir) do file = "foo.txt" - `echo 'foo' >> #{file}` + run!("echo 'foo' >> #{file}") background_start = Rundoc::CodeCommand::Background::Start.new("tail -f #{file}", name: "tail", @@ -49,7 +77,7 @@ def test_background_start output = log_clear.call assert_equal("", output) - `echo 'bar' >> #{file}` + run!("echo 'bar' >> #{file}") log_read = Rundoc::CodeCommand::Background::Log::Read.new(name: "tail") output = log_read.call