rundoc 4.1.3 → 5.0.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -1
  3. data/.standard.yml +1 -1
  4. data/CHANGELOG.md +12 -0
  5. data/README.md +36 -9
  6. data/lib/rundoc/cli.rb +6 -3
  7. data/lib/rundoc/code_command/background/log/clear.rb +12 -2
  8. data/lib/rundoc/code_command/background/log/read.rb +12 -2
  9. data/lib/rundoc/code_command/background/process_spawn.rb +9 -5
  10. data/lib/rundoc/code_command/background/start.rb +25 -6
  11. data/lib/rundoc/code_command/background/stdin_write.rb +21 -8
  12. data/lib/rundoc/code_command/background/stop.rb +12 -2
  13. data/lib/rundoc/code_command/background/wait.rb +15 -3
  14. data/lib/rundoc/code_command/background.rb +2 -0
  15. data/lib/rundoc/code_command/bash/cd.rb +7 -7
  16. data/lib/rundoc/code_command/bash.rb +43 -19
  17. data/lib/rundoc/code_command/comment.rb +33 -0
  18. data/lib/rundoc/code_command/deferred.rb +66 -0
  19. data/lib/rundoc/code_command/file_command/append.rb +29 -8
  20. data/lib/rundoc/code_command/file_command/remove.rb +27 -5
  21. data/lib/rundoc/code_command/no_such_command.rb +8 -3
  22. data/lib/rundoc/code_command/pipe.rb +36 -16
  23. data/lib/rundoc/code_command/pre/erb.rb +28 -18
  24. data/lib/rundoc/code_command/print/erb.rb +28 -4
  25. data/lib/rundoc/code_command/print/text.rb +27 -8
  26. data/lib/rundoc/code_command/raw.rb +17 -5
  27. data/lib/rundoc/code_command/rundoc/require.rb +25 -17
  28. data/lib/rundoc/code_command/rundoc_command.rb +21 -8
  29. data/lib/rundoc/code_command/website/driver.rb +25 -7
  30. data/lib/rundoc/code_command/website/navigate.rb +18 -12
  31. data/lib/rundoc/code_command/website/screenshot.rb +17 -11
  32. data/lib/rundoc/code_command/website/visit.rb +26 -14
  33. data/lib/rundoc/code_command/website.rb +2 -0
  34. data/lib/rundoc/code_command/write.rb +37 -9
  35. data/lib/rundoc/code_command.rb +5 -48
  36. data/lib/rundoc/context/after_build.rb +2 -0
  37. data/lib/rundoc/context/execution.rb +2 -0
  38. data/lib/rundoc/document.rb +6 -2
  39. data/lib/rundoc/fenced_code_block.rb +10 -7
  40. data/lib/rundoc/peg_parser.rb +17 -9
  41. data/lib/rundoc/version.rb +3 -1
  42. data/lib/rundoc.rb +52 -17
  43. data/rundoc.gemspec +2 -0
  44. data/test/integration/background_stdin_test.rb +65 -15
  45. data/test/integration/website_test.rb +19 -0
  46. data/test/rundoc/code_commands/append_file_test.rb +35 -10
  47. data/test/rundoc/code_commands/background_test.rb +26 -22
  48. data/test/rundoc/code_commands/bash_test.rb +10 -5
  49. data/test/rundoc/code_commands/comment_test.rb +116 -0
  50. data/test/rundoc/code_commands/pipe_test.rb +2 -2
  51. data/test/rundoc/code_commands/print_test.rb +13 -25
  52. data/test/rundoc/code_commands/remove_contents_test.rb +8 -3
  53. data/test/rundoc/code_section_test.rb +28 -21
  54. data/test/rundoc/peg_parser_test.rb +17 -1
  55. data/test/test_helper.rb +32 -2
  56. metadata +7 -10
  57. data/lib/rundoc/code_command/rundoc/depend_on.rb +0 -13
  58. data/test/fixtures/depend_on/dependency/rundoc.md +0 -5
  59. data/test/fixtures/depend_on/main/rundoc.md +0 -10
@@ -1,9 +1,23 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Rundoc::CodeCommand::Website
2
- class Screenshot < Rundoc::CodeCommand
4
+ class ScreenshotArgs
5
+ attr_reader :name, :upload
6
+
3
7
  def initialize(name:, upload: false)
4
8
  @name = name
5
9
  @upload = upload
10
+ end
11
+ end
12
+
13
+ class ScreenshotRunner
14
+ attr_reader :io
15
+
16
+ def initialize(user_args:, render_command:, render_result:, io:, contents: nil)
17
+ @name = user_args.name
18
+ @upload = user_args.upload
6
19
  @driver = nil
20
+ @io = io
7
21
  end
8
22
 
9
23
  def driver
@@ -15,7 +29,7 @@ class Rundoc::CodeCommand::Website
15
29
  end
16
30
 
17
31
  def call(env = {})
18
- puts "Taking screenshot: #{driver.current_url}"
32
+ io.puts "Taking screenshot: #{driver.current_url}"
19
33
  filename = driver.screenshot(
20
34
  upload: @upload,
21
35
  screenshots_dir: env[:context].screenshots_dir
@@ -25,14 +39,6 @@ class Rundoc::CodeCommand::Website
25
39
  env[:before] << "![Screenshot of #{driver.current_url}](#{relative_filename})"
26
40
  ""
27
41
  end
28
-
29
- # def hidden?
30
- # true
31
- # end
32
-
33
- # def not_hidden?
34
- # !hidden?
35
- # end
36
42
  end
37
43
  end
38
- Rundoc.register_code_command(:"website.screenshot", Rundoc::CodeCommand::Website::Screenshot)
44
+ Rundoc.register_code_command(keyword: :"website.screenshot", args_klass: Rundoc::CodeCommand::Website::ScreenshotArgs, runner_klass: Rundoc::CodeCommand::Website::ScreenshotRunner)
@@ -1,14 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rundoc::CodeCommand::Website
4
- class Visit < Rundoc::CodeCommand
5
- def initialize(name:, url: nil, scroll: nil, height: 720, width: 1024, visible: false)
4
+ class VisitArgs
5
+ attr_reader :name, :url, :scroll, :height, :width, :visible, :max_attempts
6
+
7
+ def initialize(name:, url: nil, scroll: nil, height: 720, width: 1024, visible: false, max_attempts: 3)
6
8
  @name = name
7
9
  @url = url
8
10
  @scroll = scroll
9
11
  @height = height
10
12
  @width = width
11
13
  @visible = visible
14
+ @max_attempts = max_attempts
15
+ end
16
+ end
17
+
18
+ class VisitRunner
19
+ attr_reader :io, :contents
20
+
21
+ def initialize(user_args:, render_command:, render_result:, io:, contents: nil)
22
+ @name = user_args.name
23
+ @url = user_args.url
24
+ @scroll = user_args.scroll
25
+ @height = user_args.height
26
+ @width = user_args.width
27
+ @visible = user_args.visible
28
+ @max_attempts = user_args.max_attempts
29
+ @io = io
30
+ @contents = contents.dup if contents && !contents.empty?
12
31
  end
13
32
 
14
33
  def driver
@@ -17,7 +36,8 @@ class Rundoc::CodeCommand::Website
17
36
  url: @url,
18
37
  height: @height,
19
38
  width: @width,
20
- visible: @visible
39
+ visible: @visible,
40
+ io: io
21
41
  ).tap do |driver|
22
42
  Driver.add(@name, driver)
23
43
  end
@@ -31,9 +51,9 @@ class Rundoc::CodeCommand::Website
31
51
  message = "Visting: #{@url}"
32
52
  message << "and executing:\n#{contents}" unless contents.nil? || contents.empty?
33
53
 
34
- puts message
54
+ io.puts message
35
55
 
36
- driver.visit(@url) if @url
56
+ driver.visit(@url, max_attempts: @max_attempts) if @url
37
57
  driver.scroll(@scroll) if @scroll
38
58
 
39
59
  return "" if contents.nil? || contents.empty?
@@ -41,15 +61,7 @@ class Rundoc::CodeCommand::Website
41
61
 
42
62
  ""
43
63
  end
44
-
45
- def hidden?
46
- true
47
- end
48
-
49
- def not_hidden?
50
- !hidden?
51
- end
52
64
  end
53
65
  end
54
66
 
55
- Rundoc.register_code_command(:"website.visit", Rundoc::CodeCommand::Website::Visit)
67
+ Rundoc.register_code_command(keyword: :"website.visit", args_klass: Rundoc::CodeCommand::Website::VisitArgs, runner_klass: Rundoc::CodeCommand::Website::VisitRunner)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Rundoc::CodeCommand::Website
2
4
  end
3
5
 
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rundoc
2
- class CodeCommand
4
+ module CodeCommand
3
5
  module FileUtil
4
6
  def filename
5
7
  files = Dir.glob(@filename)
@@ -15,16 +17,42 @@ module Rundoc
15
17
  end
16
18
  end
17
19
 
18
- class Write < Rundoc::CodeCommand
19
- include FileUtil
20
+ class WriteArgs
21
+ attr_reader :path
22
+
23
+ def initialize(path)
24
+ @path = Pathname(path)
25
+ end
26
+ end
27
+
28
+ class WriteRunner
29
+ NEWLINE = Object.new
30
+ def NEWLINE.to_s
31
+ ""
32
+ end
33
+
34
+ def NEWLINE.empty?
35
+ false
36
+ end
37
+
38
+ include Rundoc::CodeCommand::FileUtil
39
+
40
+ attr_reader :io, :contents
41
+
42
+ def initialize(user_args:, render_command:, render_result:, io:, contents: nil)
43
+ @filename = user_args.path.to_s
44
+ @io = io
45
+ @render_command = render_command
46
+ @contents = contents.dup if contents && !contents.empty?
47
+ end
20
48
 
21
- def initialize(filename)
22
- @filename = filename
49
+ def render_command?
50
+ @render_command
23
51
  end
24
52
 
25
53
  def to_md(env)
26
54
  if render_command?
27
- if env[:commands].any? { |c| c[:object].not_hidden? }
55
+ if env[:commands].any? { |c| c[:visibility].not_hidden? }
28
56
  raise "must call write in its own code section"
29
57
  end
30
58
  env[:before] << "In file `#{filename}` write:"
@@ -34,7 +62,7 @@ module Rundoc
34
62
  end
35
63
 
36
64
  def call(env = {})
37
- puts "Writing to: '#{filename}'"
65
+ io.puts "Writing to: '#{filename}'"
38
66
  mkdir_p
39
67
  File.write(filename, contents)
40
68
  contents
@@ -43,8 +71,8 @@ module Rundoc
43
71
  end
44
72
  end
45
73
 
46
- Rundoc.register_code_command(:write, Rundoc::CodeCommand::Write)
47
- Rundoc.register_code_command(:"file.write", Rundoc::CodeCommand::Write)
74
+ Rundoc.register_code_command(keyword: :write, args_klass: Rundoc::CodeCommand::WriteArgs, runner_klass: Rundoc::CodeCommand::WriteRunner)
75
+ Rundoc.register_code_command(keyword: :"file.write", args_klass: Rundoc::CodeCommand::WriteArgs, runner_klass: Rundoc::CodeCommand::WriteRunner)
48
76
 
49
77
  require "rundoc/code_command/file_command/append"
50
78
  require "rundoc/code_command/file_command/remove"
@@ -1,55 +1,11 @@
1
- module Rundoc
2
- # Generic CodeCommand class to be inherited
3
- #
4
- class CodeCommand
5
- # Newlines are stripped and re-added, this tells the project that
6
- # we're intentionally wanting an extra newline
7
- NEWLINE = Object.new
8
- def NEWLINE.to_s
9
- ""
10
- end
11
-
12
- def NEWLINE.empty?
13
- false
14
- end
15
-
16
- attr_accessor :render_result, :render_command,
17
- :command, :contents, :keyword,
18
- :original_args
19
-
20
- alias_method :render_result?, :render_result
21
- alias_method :render_command?, :render_command
22
-
23
- def initialize(*args)
24
- end
1
+ # frozen_string_literal: true
25
2
 
26
- def hidden?
27
- !render_command? && !render_result?
28
- end
29
-
30
- def not_hidden?
31
- !hidden?
32
- end
33
-
34
- def push(contents)
35
- @contents ||= ""
36
- @contents << contents
37
- end
38
- alias_method :<<, :push
39
-
40
- # Executes command to build project
41
- # Is expected to return the result of the command
42
- def call(env = {})
43
- raise "not implemented on #{inspect}"
44
- end
45
-
46
- # the output of the command, i.e. `$ cat foo.txt`
47
- def to_md(env = {})
48
- raise "not implemented on #{inspect}"
49
- end
3
+ module Rundoc
4
+ module CodeCommand
50
5
  end
51
6
  end
52
7
 
8
+ require "rundoc/code_command/deferred"
53
9
  require "rundoc/code_command/bash"
54
10
  require "rundoc/code_command/pipe"
55
11
  require "rundoc/code_command/write"
@@ -61,3 +17,4 @@ require "rundoc/code_command/website"
61
17
  require "rundoc/code_command/print/text"
62
18
  require "rundoc/code_command/print/erb"
63
19
  require "rundoc/code_command/pre/erb"
20
+ require "rundoc/code_command/comment"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rundoc
2
4
  module Context
3
5
  # Public interface for the `Rundoc.after_build` proc
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rundoc
2
4
  module Context
3
5
  # Holds configuration for the currently executing script
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rundoc
2
4
  # Represents a single rundoc file on disk,
3
5
  #
@@ -11,7 +13,8 @@ module Rundoc
11
13
 
12
14
  attr_reader :contents, :stack, :context
13
15
 
14
- def initialize(contents, context:)
16
+ def initialize(contents, context:, io: $stdout)
17
+ @io = io
15
18
  @context = context
16
19
  @contents = contents
17
20
  @original = contents.dup
@@ -59,7 +62,8 @@ module Rundoc
59
62
  fence: match[:fence],
60
63
  lang: match[:lang],
61
64
  code: match[:contents],
62
- context: context
65
+ context: context,
66
+ io: @io
63
67
  )
64
68
  end
65
69
  @contents = tail
@@ -15,7 +15,7 @@ module Rundoc
15
15
  def executed_commands
16
16
  raise "Nothing executed" unless @env[:commands].any?
17
17
 
18
- @env[:commands].map { |c| c[:object] }
18
+ @env[:commands].map { |c| c[:visibility] }
19
19
  end
20
20
 
21
21
  # @param fence [String] the fence used to start the code block like "```".
@@ -24,7 +24,8 @@ module Rundoc
24
24
  # @param code [String] the code block contents inside the fence.
25
25
  # @param context [Context::Execution] The details about where
26
26
  # the code block came from.
27
- def initialize(fence:, lang:, code:, context:)
27
+ def initialize(fence:, lang:, code:, context:, io: $stdout)
28
+ @io = io
28
29
  @fence = fence
29
30
  @lang = lang
30
31
  @code = code
@@ -54,12 +55,13 @@ module Rundoc
54
55
  env[:after] = []
55
56
  env[:context] = @context
56
57
  env[:stack] = @stack
58
+ while (item = @stack.pop)
59
+ code_command = item.build(io: @io)
57
60
 
58
- while (code_command = @stack.pop)
59
61
  code_output = code_command.call(env) || ""
60
62
  code_line = code_command.to_md(env) || ""
61
- result << code_line if code_command.render_command?
62
- result << code_output if code_command.render_result?
63
+ result << code_line if item.render_command?
64
+ result << code_output if item.render_result?
63
65
 
64
66
  PARTIAL_RESULT.replace(result)
65
67
  PARTIAL_ENV.replace(env)
@@ -67,11 +69,12 @@ module Rundoc
67
69
  env[:commands] << {
68
70
  object: code_command,
69
71
  output: code_output,
70
- command: code_line
72
+ command: code_line,
73
+ visibility: item
71
74
  }
72
75
  end
73
76
 
74
- if env[:commands].any? { |c| c[:object].not_hidden? }
77
+ if env[:commands].any? { |c| c[:visibility].not_hidden? }
75
78
  @rendered = self.class.to_doc(result: result, env: env)
76
79
  end
77
80
  self
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "parslet"
2
4
 
3
5
  module Rundoc
4
6
  class PegParser < Parslet::Parser
5
7
  rule(:spaces) { match('\s').repeat(1) }
6
8
  rule(:spaces?) { spaces.maybe }
9
+ rule(:horizontal_spaces) { match('[ \t]').repeat(1) }
7
10
  rule(:comma) { spaces? >> str(",") >> spaces? }
8
11
  rule(:digit) { match("[0-9]") }
9
12
  rule(:lparen) { str("(") >> spaces? }
@@ -87,7 +90,7 @@ module Rundoc
87
90
  }
88
91
 
89
92
  rule(:seattle_method) {
90
- funcall >> spaces >>
93
+ funcall >> horizontal_spaces >>
91
94
  args.as(:args)
92
95
  }
93
96
 
@@ -242,8 +245,8 @@ module Rundoc
242
245
  raise TransformError.new(message: message, line_and_column: line_and_column)
243
246
  end
244
247
  Visability.new(
245
- command: command.to_s == ">".freeze,
246
- result: result.to_s == ">".freeze
248
+ command: command.to_s == ">",
249
+ result: result.to_s == ">"
247
250
  )
248
251
  }
249
252
 
@@ -265,16 +268,21 @@ module Rundoc
265
268
  code_command
266
269
  }
267
270
 
268
- # The lines before a CodeCommand are rendered
269
- # without running any code
270
271
  rule(raw_code: simple(:raw_code)) {
271
- CodeCommand::Raw.new(raw_code)
272
+ deferred = CodeCommand::Deferred.new(args_instance: nil, runner_klass: CodeCommand::Raw)
273
+ deferred.render_command = false
274
+ deferred.render_result = true
275
+ deferred.push(raw_code.to_s)
276
+ deferred
272
277
  }
273
278
 
274
- # Sometimes
275
279
  rule(raw_code: sequence(:raw_code)) {
276
- hidden = raw_code.nil? || raw_code.empty?
277
- CodeCommand::Raw.new(raw_code, visible: !hidden)
280
+ visible = !raw_code.nil? && !raw_code.empty?
281
+ deferred = CodeCommand::Deferred.new(args_instance: nil, runner_klass: CodeCommand::Raw)
282
+ deferred.render_command = false
283
+ deferred.render_result = visible
284
+ deferred.push(raw_code.map(&:to_s).join)
285
+ deferred
278
286
  }
279
287
  end
280
288
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rundoc
2
- VERSION = "4.1.3"
4
+ VERSION = "5.0.0"
3
5
  end
data/lib/rundoc.rb CHANGED
@@ -5,43 +5,78 @@ require "rundoc/version"
5
5
  module Rundoc
6
6
  extend self
7
7
 
8
+ class UnknownCommand < StandardError; end
9
+
8
10
  def code_command_from_keyword(keyword, args)
9
- klass = code_command(keyword.to_sym) || Rundoc::CodeCommand::NoSuchCommand
10
- original_args = args.dup
11
- if args.is_a?(Array) && args.last.is_a?(Hash)
12
- kwargs = args.pop
13
- cc = klass.new(*args, **kwargs)
14
- elsif args.is_a?(Hash)
15
- cc = klass.new(**args)
11
+ args_klass = code_command(keyword.to_sym)
12
+ original_args = args&.dup
13
+
14
+ if args_klass
15
+ runner_klass = user_args_runner[keyword]
16
+
17
+ if args.is_a?(Array) && args.last.is_a?(Hash)
18
+ kwargs = args.pop
19
+ user_args = args_klass.new(*args, **kwargs)
20
+ elsif args.is_a?(Hash)
21
+ user_args = args_klass.new(**args)
22
+ else
23
+ user_args = args_klass.new(*args)
24
+ end
25
+ elsif keyword.start_with?("#")
26
+ args_klass = Rundoc::CodeCommand::CommentArgs
27
+ runner_klass = Rundoc::CodeCommand::CommentRunner
28
+ remainder = keyword.to_s.delete_prefix("#")
29
+ comment_text = [remainder, args].compact.join(" ").strip
30
+ user_args = args_klass.new(comment_text.empty? ? nil : comment_text)
16
31
  else
17
- cc = klass.new(*args)
32
+ runner_klass = Rundoc::CodeCommand::NoSuchCommand
33
+ user_args = nil
18
34
  end
19
35
 
20
- cc.original_args = original_args
21
- cc.keyword = keyword
22
- cc
36
+ deferred = CodeCommand::Deferred.new(
37
+ args_instance: user_args,
38
+ runner_klass: runner_klass,
39
+ always_hidden: always_hidden_commands[keyword] || keyword.start_with?("#")
40
+ )
41
+ deferred.original_args = original_args
42
+ deferred.keyword = keyword
43
+ deferred
23
44
  rescue ArgumentError => e
24
45
  raise ArgumentError, "Wrong method signature for #{keyword} with arguments: #{original_args.inspect}, error:\n #{e.message}"
25
46
  end
26
47
 
48
+ def user_code_runner_klass
49
+ @user_code_runner_klass ||= {}
50
+ end
51
+
27
52
  def parser_options
28
53
  @parser_options ||= {}
29
54
  end
30
55
 
31
- def code_lookup
32
- @code_lookup ||= {}
56
+ def user_args_runner
57
+ @user_args_runner ||= {}
58
+ end
59
+
60
+ def user_args
61
+ @user_args ||= {}
33
62
  end
34
63
 
35
64
  def code_command(keyword)
36
- code_lookup[:"#{keyword}"]
65
+ user_args[:"#{keyword}"]
37
66
  end
38
67
 
39
68
  def known_commands
40
- code_lookup.keys
69
+ user_args.keys
70
+ end
71
+
72
+ def register_code_command(keyword:, args_klass:, runner_klass:, always_hidden: false)
73
+ user_args[keyword] = args_klass
74
+ user_args_runner[keyword] = runner_klass
75
+ always_hidden_commands[keyword] = always_hidden
41
76
  end
42
77
 
43
- def register_code_command(keyword, klass)
44
- code_lookup[keyword] = klass
78
+ def always_hidden_commands
79
+ @always_hidden_commands ||= {}
45
80
  end
46
81
 
47
82
  def configure(&block)
data/rundoc.gemspec CHANGED
@@ -16,6 +16,8 @@ Gem::Specification.new do |gem|
16
16
  gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
17
17
  gem.require_paths = ["lib"]
18
18
 
19
+ gem.required_ruby_version = ">= 3.2"
20
+
19
21
  gem.add_dependency "thor"
20
22
  gem.add_dependency "parslet", "~> 2"
21
23
  gem.add_dependency "capybara", "~> 3"
@@ -6,21 +6,7 @@ class BackgroundStdinTest < Minitest::Test
6
6
  Dir.chdir(dir) do
7
7
  dir = Pathname(dir)
8
8
  script = dir.join("script.rb")
9
- script.write <<~'EOF'
10
- $stdout.sync = true
11
-
12
- print "> "
13
- while line = gets
14
- puts line
15
- if line.strip == "exit"
16
- puts "Bye"
17
- return
18
- else
19
- puts "You said: #{line}"
20
- end
21
- print "> "
22
- end
23
- EOF
9
+ script.write loop_script
24
10
 
25
11
  source_path = dir.join("RUNDOC.md")
26
12
  source_path.write <<~EOF
@@ -54,4 +40,68 @@ class BackgroundStdinTest < Minitest::Test
54
40
  end
55
41
  end
56
42
  end
43
+
44
+ def test_print_output_on_exit
45
+ Dir.mktmpdir do |dir|
46
+ Dir.chdir(dir) do
47
+ dir = Pathname(dir)
48
+ script = dir.join("script.rb")
49
+ script.write loop_script
50
+
51
+ source_path = dir.join("RUNDOC.md")
52
+ source_path.write <<~EOF
53
+ ```
54
+ :::-- background.start("ruby #{script}",
55
+ name: "background_ungraceful_exit",
56
+ wait: ">",
57
+ timeout: 15
58
+ )
59
+ :::-- background.stdin_write("hello", name: "background_ungraceful_exit", wait: "hello")
60
+ ```
61
+ EOF
62
+
63
+ io = StringIO.new
64
+ Rundoc::CLI.new(
65
+ io: io,
66
+ source_path: source_path,
67
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
68
+ ).call
69
+
70
+ logs = io.string
71
+
72
+ match_after = partition_match_after(actual: logs, include_str: "Warning background task is still running, cleaning up: `background_ungraceful_exit`")
73
+ match_after = partition_match_after(actual: match_after, include_str: "Log contents for `/usr/bin/env bash -c")
74
+ partition_match_after(actual: match_after, include_str: "> hello")
75
+ end
76
+ end
77
+ end
78
+
79
+ # Finds the include_str if it exists or raises an error
80
+ # Returns the contents of that string and everything after it
81
+ #
82
+ # Used to handle the case where output might be in the logs twice and we want to verify the order
83
+ def partition_match_after(actual:, include_str:)
84
+ _before, match, after = actual.partition(include_str)
85
+ found = match && !match.empty?
86
+ assert found, "Expected to find `#{include_str}` in output, but did not. Output:\n#{actual}"
87
+ [match, after].join
88
+ end
89
+
90
+ def loop_script
91
+ <<~'EOF'
92
+ $stdout.sync = true
93
+
94
+ print "> "
95
+ while line = gets
96
+ puts line
97
+ if line.strip == "exit"
98
+ puts "Bye"
99
+ return
100
+ else
101
+ puts "You said: #{line}"
102
+ end
103
+ print "> "
104
+ end
105
+ EOF
106
+ end
57
107
  end
@@ -34,4 +34,23 @@ class IntegrationWebsiteTest < Minitest::Test
34
34
  end
35
35
  end
36
36
  end
37
+
38
+ def test_retry_client_closed_early
39
+ tcp_unexpected_exit do |port|
40
+ io = StringIO.new
41
+ driver = Rundoc::CodeCommand::Website::Driver.new(name: SecureRandom.hex, url: nil, read_timeout: 0.1, io: io)
42
+ assert_raises(Net::ReadTimeout) do
43
+ driver.visit("http://localhost:#{port}", max_attempts: 3, delay: 0)
44
+ end
45
+
46
+ logs = io.string
47
+ assert_logs_include(logs: logs, include_str: "Error visiting url (1/3)")
48
+ assert_logs_include(logs: logs, include_str: "Error visiting url (2/3)")
49
+ assert_logs_include(logs: logs, include_str: "Error visiting url (3/3)")
50
+ end
51
+ end
52
+
53
+ def assert_logs_include(logs:, include_str:)
54
+ assert logs.include?(include_str), "Expected logs to include #{include_str} but they didnt. Logs:\n#{logs}"
55
+ end
37
56
  end