shellfie 1.0.0 → 1.1.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 +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pty"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "timeout"
|
|
9
|
+
require "fileutils"
|
|
10
|
+
require "tmpdir"
|
|
11
|
+
require "shellwords"
|
|
12
|
+
require_relative "errors"
|
|
13
|
+
require_relative "session"
|
|
14
|
+
require_relative "text_metrics"
|
|
15
|
+
|
|
16
|
+
module Shellfie
|
|
17
|
+
class SessionRunner
|
|
18
|
+
MAX_OUTPUT_BYTES = 10 * 1_048_576
|
|
19
|
+
REGEXP_TIMEOUT = 0.1
|
|
20
|
+
MAX_GOLDEN_BYTES = 1_048_576
|
|
21
|
+
KEYS = {
|
|
22
|
+
"enter" => "\r", "tab" => "\t", "esc" => "\e", "escape" => "\e",
|
|
23
|
+
"up" => "\e[A", "down" => "\e[B", "right" => "\e[C", "left" => "\e[D",
|
|
24
|
+
"home" => "\e[H", "end" => "\e[F", "pageup" => "\e[5~", "pagedown" => "\e[6~",
|
|
25
|
+
"delete" => "\e[3~", "insert" => "\e[2~", "backspace" => "\x7f", "shift-tab" => "\e[Z"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def initialize(config)
|
|
29
|
+
@config = config
|
|
30
|
+
@visible = true
|
|
31
|
+
@redactions = config.redactions.map { |pattern| Regexp.new(pattern.to_s) }
|
|
32
|
+
@utf8_pending = +"".b
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
total_timeout = terminal[:total_timeout] && parse_duration(terminal[:total_timeout])
|
|
37
|
+
return Timeout.timeout(total_timeout, ExecutionError, "Session exceeded total timeout of #{total_timeout}s") { execute_session } if total_timeout
|
|
38
|
+
|
|
39
|
+
execute_session
|
|
40
|
+
ensure
|
|
41
|
+
stop_pty
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def execute_session
|
|
45
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
|
+
check_requirements!
|
|
47
|
+
validate_working_directories!
|
|
48
|
+
@session = Session.new(columns: terminal[:columns], rows: terminal[:rows], title: @config.title)
|
|
49
|
+
@live_screen = TerminalScreen.new(
|
|
50
|
+
columns: terminal[:columns], rows: terminal[:rows],
|
|
51
|
+
graphics_policy: @config.render.dig(:window, :graphics_policy) || "ignore"
|
|
52
|
+
)
|
|
53
|
+
start_pty
|
|
54
|
+
@config.steps.each do |step|
|
|
55
|
+
execute(step)
|
|
56
|
+
check_reader_error!
|
|
57
|
+
end
|
|
58
|
+
@session
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def terminal
|
|
64
|
+
@config.terminal
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def start_pty
|
|
68
|
+
@prompt_marker = SecureRandom.hex(4)
|
|
69
|
+
env = {
|
|
70
|
+
"TERM" => "xterm-256color", "LANG" => "C.UTF-8", "TZ" => "UTC", "HISTFILE" => "/dev/null",
|
|
71
|
+
"PATH" => ENV.fetch("PATH", ""), "HOME" => (@session_home = Dir.mktmpdir("shellfie-home")), "TMPDIR" => Dir.tmpdir
|
|
72
|
+
}.merge(terminal[:env].transform_keys(&:to_s))
|
|
73
|
+
env["PS1"] = "#{terminal[:prompt]}\e]9;#{@prompt_marker}\a"
|
|
74
|
+
shell = resolve_command(terminal[:shell])
|
|
75
|
+
raise DependencyError, "Shell not found: #{terminal[:shell]}" unless shell
|
|
76
|
+
args = case File.basename(shell)
|
|
77
|
+
when "bash" then %w[--noprofile --norc -i]
|
|
78
|
+
when "zsh" then %w[-f -i]
|
|
79
|
+
when "fish" then %w[--no-config -i]
|
|
80
|
+
when "pwsh", "powershell" then %w[-NoLogo -NoProfile -NoExit]
|
|
81
|
+
when "cmd", "cmd.exe" then %w[/Q /K]
|
|
82
|
+
when "nu" then %w[--no-config-file]
|
|
83
|
+
else %w[-i]
|
|
84
|
+
end
|
|
85
|
+
cwd = File.expand_path(terminal[:cwd], @config.base_dir)
|
|
86
|
+
@buffer = +""
|
|
87
|
+
@mutex = Mutex.new
|
|
88
|
+
@condition = ConditionVariable.new
|
|
89
|
+
@master, @slave, @pid = PTY.spawn(env, shell, *args, chdir: cwd, unsetenv_others: true)
|
|
90
|
+
@master.winsize = [terminal[:rows], terminal[:columns]]
|
|
91
|
+
@reader_thread = Thread.new do
|
|
92
|
+
loop do
|
|
93
|
+
append_terminal_chunk(decode_terminal_bytes(@master.readpartial(4096)))
|
|
94
|
+
break if @reader_error
|
|
95
|
+
end
|
|
96
|
+
rescue EOFError, Errno::EIO, IOError
|
|
97
|
+
nil
|
|
98
|
+
ensure
|
|
99
|
+
append_terminal_chunk(decode_terminal_bytes("", final: true))
|
|
100
|
+
end
|
|
101
|
+
wait_for_quiet(0.1)
|
|
102
|
+
clear_buffer
|
|
103
|
+
@recorded_offset = 0
|
|
104
|
+
@prompt_wait_offset = 0
|
|
105
|
+
rescue SystemCallError => e
|
|
106
|
+
raise DependencyError, "Unable to start shell #{terminal[:shell]}: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stop_pty
|
|
110
|
+
return unless @pid
|
|
111
|
+
|
|
112
|
+
terminate_job_groups(background_job_pids)
|
|
113
|
+
terminate_descendants
|
|
114
|
+
@slave&.write("exit\n") unless @slave&.closed?
|
|
115
|
+
unless reap_child(1)
|
|
116
|
+
signal_process_group("TERM")
|
|
117
|
+
signal_process(@pid, "TERM")
|
|
118
|
+
unless reap_child(1)
|
|
119
|
+
signal_process_group("KILL")
|
|
120
|
+
signal_process(@pid, "KILL")
|
|
121
|
+
reap_child(1)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
rescue Errno::ESRCH, Errno::ECHILD, Errno::EIO, Errno::EPIPE, IOError
|
|
125
|
+
nil
|
|
126
|
+
ensure
|
|
127
|
+
signal_process_group("TERM")
|
|
128
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.2
|
|
129
|
+
sleep(0.01) while process_group_alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
130
|
+
signal_process_group("KILL") if process_group_alive?
|
|
131
|
+
@master&.close unless @master&.closed?
|
|
132
|
+
@slave&.close unless @slave&.closed?
|
|
133
|
+
@reader_thread&.join(0.2)
|
|
134
|
+
FileUtils.rm_rf(@session_home) if @session_home
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def reap_child(timeout)
|
|
138
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
139
|
+
loop do
|
|
140
|
+
return true if Process.waitpid(@pid, Process::WNOHANG)
|
|
141
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
142
|
+
|
|
143
|
+
sleep(0.01)
|
|
144
|
+
end
|
|
145
|
+
rescue Errno::ECHILD
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def background_job_pids
|
|
150
|
+
start = buffer_size
|
|
151
|
+
opening = "__SHELLFIE_JOBS_#{SecureRandom.hex(6)}__"
|
|
152
|
+
closing = "__SHELLFIE_JOBS_END_#{SecureRandom.hex(6)}__"
|
|
153
|
+
@slave.write("printf '\n#{opening}\n'; jobs -p; printf '#{closing}\n'\n")
|
|
154
|
+
wait_for(/\r?\n#{Regexp.escape(closing)}\r?\n/, timeout: 0.5, offset: start)
|
|
155
|
+
output = buffer_from(start)
|
|
156
|
+
body = output[/\r?\n#{Regexp.escape(opening)}\r?\n(.*?)\r?\n#{Regexp.escape(closing)}\r?\n/m, 1].to_s
|
|
157
|
+
body.lines.filter_map { |line| Integer(line.strip, exception: false) }
|
|
158
|
+
rescue Shellfie::Error, SystemCallError, IOError
|
|
159
|
+
[]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def terminate_job_groups(pids)
|
|
163
|
+
pids.each { |pid| signal_group(pid, "TERM") }
|
|
164
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.2
|
|
165
|
+
sleep(0.01) while pids.any? { |pid| process_group_exists?(pid) } && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
166
|
+
pids.each { |pid| signal_group(pid, "KILL") if process_group_exists?(pid) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def terminate_descendants
|
|
170
|
+
pids = descendant_pids
|
|
171
|
+
pids.reverse_each { |pid| signal_process(pid, "TERM") }
|
|
172
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.2
|
|
173
|
+
sleep(0.01) while pids.any? { |pid| process_exists?(pid) } && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
174
|
+
pids.reverse_each { |pid| signal_process(pid, "KILL") if process_exists?(pid) }
|
|
175
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
|
176
|
+
sleep(0.01) while pids.any? { |pid| process_exists?(pid) } && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def descendant_pids
|
|
180
|
+
output, status = Open3.capture2("ps", "-eo", "pid=,ppid=")
|
|
181
|
+
return [] unless status.success?
|
|
182
|
+
|
|
183
|
+
children = Hash.new { |hash, key| hash[key] = [] }
|
|
184
|
+
output.each_line do |line|
|
|
185
|
+
pid, parent = line.split.map!(&:to_i)
|
|
186
|
+
children[parent] << pid if pid&.positive? && parent&.positive?
|
|
187
|
+
end
|
|
188
|
+
descendants = []
|
|
189
|
+
pending = [@pid]
|
|
190
|
+
until pending.empty?
|
|
191
|
+
found = children[pending.shift]
|
|
192
|
+
descendants.concat(found)
|
|
193
|
+
pending.concat(found)
|
|
194
|
+
end
|
|
195
|
+
descendants
|
|
196
|
+
rescue SystemCallError
|
|
197
|
+
[]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def signal_process(pid, signal)
|
|
201
|
+
Process.kill(signal, pid)
|
|
202
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def signal_group(pid, signal)
|
|
207
|
+
Process.kill(signal, -pid)
|
|
208
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def process_group_exists?(pid)
|
|
213
|
+
Process.kill(0, -pid)
|
|
214
|
+
true
|
|
215
|
+
rescue Errno::ESRCH
|
|
216
|
+
false
|
|
217
|
+
rescue Errno::EPERM
|
|
218
|
+
true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def process_exists?(pid)
|
|
222
|
+
Process.kill(0, pid)
|
|
223
|
+
true
|
|
224
|
+
rescue Errno::ESRCH
|
|
225
|
+
false
|
|
226
|
+
rescue Errno::EPERM
|
|
227
|
+
true
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def execute(step)
|
|
231
|
+
action = (step.keys & SessionConfig::ACTIONS).first
|
|
232
|
+
case action
|
|
233
|
+
when :hide then set_visibility(false)
|
|
234
|
+
when :show then set_visibility(true)
|
|
235
|
+
when :run then execute_command(step[:run], step, visible: step.fetch(:visibility, step[:async] ? "visible" : "hidden") != "hidden")
|
|
236
|
+
when :type then type(step[:type], step)
|
|
237
|
+
when :key then key(step[:key], step)
|
|
238
|
+
when :sleep
|
|
239
|
+
delay = parse_duration(step[:sleep])
|
|
240
|
+
@session.record("", delay: delay, visible: @visible)
|
|
241
|
+
sleep(delay)
|
|
242
|
+
when :wait
|
|
243
|
+
wait_condition(step[:wait], timeout: step_timeout(step))
|
|
244
|
+
flush_pending
|
|
245
|
+
when :expect then expect_condition(step[:expect])
|
|
246
|
+
when :capture
|
|
247
|
+
flush_pending
|
|
248
|
+
@session.capture(step[:capture])
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def execute_command(command, step, visible:)
|
|
253
|
+
flush_pending
|
|
254
|
+
command = "(cd #{Shellwords.escape(step_directory(step[:cwd]))} && #{command})" if step[:cwd]
|
|
255
|
+
start = buffer_size
|
|
256
|
+
@prompt_wait_offset = start
|
|
257
|
+
@slave.write("#{command}\n")
|
|
258
|
+
if step[:async]
|
|
259
|
+
wait_for_quiet(0.03)
|
|
260
|
+
text, @recorded_offset = buffer_snapshot_from(start)
|
|
261
|
+
event = @session.record(redact(text), delay: 0.03, visible: @visible && visible)
|
|
262
|
+
return event
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
text, status = finish_command(start, timeout: step_timeout(step))
|
|
266
|
+
@recorded_offset = buffer_size
|
|
267
|
+
@session.record(redact(text), delay: 0.1, visible: @visible && visible, status: status)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def type(text, step)
|
|
271
|
+
flush_pending
|
|
272
|
+
delay = typing_delay(step[:speed])
|
|
273
|
+
start = buffer_size
|
|
274
|
+
TextMetrics.graphemes(text).each do |grapheme|
|
|
275
|
+
@slave.write(grapheme)
|
|
276
|
+
sleep(delay) if delay.positive?
|
|
277
|
+
end
|
|
278
|
+
wait_for_quiet(0.03)
|
|
279
|
+
typed, @recorded_offset = buffer_snapshot_from(start)
|
|
280
|
+
@session.record(redact(typed), delay: delay * TextMetrics.graphemes(text).size, visible: @visible)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def key(name, step)
|
|
284
|
+
sequence = key_sequence(name)
|
|
285
|
+
count = Integer(step[:count] || 1)
|
|
286
|
+
delay = step[:delay] ? parse_duration(step[:delay]) : 0
|
|
287
|
+
flush_pending
|
|
288
|
+
if delay.zero?
|
|
289
|
+
start = buffer_size
|
|
290
|
+
@prompt_wait_offset = start if name.downcase == "enter"
|
|
291
|
+
@slave.write(sequence * count)
|
|
292
|
+
if name.downcase == "enter" && !step[:async]
|
|
293
|
+
text, status = finish_command(start, timeout: step_timeout(step))
|
|
294
|
+
@recorded_offset = buffer_size
|
|
295
|
+
@session.record(redact(text), delay: 0.1, visible: @visible, status: status)
|
|
296
|
+
else
|
|
297
|
+
wait_for_quiet(0.03)
|
|
298
|
+
text, @recorded_offset = buffer_snapshot_from(start)
|
|
299
|
+
@session.record(redact(text), delay: 0.03, visible: @visible)
|
|
300
|
+
end
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
count.times do |index|
|
|
305
|
+
start = buffer_size
|
|
306
|
+
@prompt_wait_offset = start if name.downcase == "enter"
|
|
307
|
+
@slave.write(sequence)
|
|
308
|
+
if name.downcase == "enter" && !step[:async] && index == count - 1
|
|
309
|
+
text, status = finish_command(start, timeout: step_timeout(step))
|
|
310
|
+
@recorded_offset = buffer_size
|
|
311
|
+
@session.record(redact(text), delay: 0.1, visible: @visible, status: status)
|
|
312
|
+
else
|
|
313
|
+
index < count - 1 ? sleep(delay) : wait_for_quiet(0.03)
|
|
314
|
+
text, @recorded_offset = buffer_snapshot_from(start)
|
|
315
|
+
@session.record(redact(text), delay: index < count - 1 ? delay : 0.03, visible: @visible)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def finish_command(start, timeout:, marker_prefix: "")
|
|
321
|
+
marker = "__SF_#{SecureRandom.hex(6)}__"
|
|
322
|
+
marker_command = "#{marker_prefix}printf '\\n#{marker}:%s\\n' \"$?\""
|
|
323
|
+
@slave.write("#{marker_command}\n")
|
|
324
|
+
match = wait_for(/#{Regexp.escape(marker)}:(\d+)/, timeout: timeout, offset: start)
|
|
325
|
+
text = buffer_from(start)[0...match.begin(0)]
|
|
326
|
+
text = text.gsub(marker_command, "")
|
|
327
|
+
[text, match[1].to_i]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def wait_condition(value, timeout:)
|
|
331
|
+
condition = value.is_a?(Hash) ? value.transform_keys(&:to_sym) : { screen: value }
|
|
332
|
+
return wait_for_exit(timeout: parse_duration(condition[:timeout] || timeout)) if condition[:exit]
|
|
333
|
+
return wait_for_stable(parse_duration(condition[:stable]), timeout: parse_duration(condition[:timeout] || timeout)) if condition[:stable]
|
|
334
|
+
if condition[:prompt]
|
|
335
|
+
marker = Regexp.escape("\e]9;#{@prompt_marker}\a")
|
|
336
|
+
pattern = /#{marker}(?:\e\[[0-9;?]*[ -\/]*[@-~])*\z/
|
|
337
|
+
return wait_for(pattern, timeout: parse_duration(condition[:timeout] || timeout), offset: @prompt_wait_offset, prompt: true)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
pattern = condition[:screen] || condition[:line]
|
|
341
|
+
raise ValidationError, "wait requires screen or line" unless pattern
|
|
342
|
+
|
|
343
|
+
wait_for(
|
|
344
|
+
Regexp.new(pattern.to_s), timeout: parse_duration(condition[:timeout] || timeout),
|
|
345
|
+
screen: condition.key?(:screen), line: condition.key?(:line)
|
|
346
|
+
)
|
|
347
|
+
rescue RegexpError => e
|
|
348
|
+
raise ValidationError, "Invalid wait pattern: #{e.message}"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def wait_for_exit(timeout:)
|
|
352
|
+
start = @recorded_offset
|
|
353
|
+
text, status = finish_command(start, timeout: timeout)
|
|
354
|
+
@recorded_offset = buffer_size
|
|
355
|
+
@session.record(redact(text), delay: 0.1, visible: @visible, status: status)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def wait_for_stable(seconds, timeout:)
|
|
359
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
360
|
+
previous = live_screen_text
|
|
361
|
+
stable_since = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
362
|
+
loop do
|
|
363
|
+
check_reader_error!
|
|
364
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
365
|
+
current = live_screen_text
|
|
366
|
+
return if current == previous && now - stable_since >= seconds
|
|
367
|
+
raise ExecutionError, "Timed out after #{timeout}s waiting for a stable screen" if now >= deadline
|
|
368
|
+
|
|
369
|
+
if current != previous
|
|
370
|
+
previous = current
|
|
371
|
+
stable_since = now
|
|
372
|
+
end
|
|
373
|
+
@mutex.synchronize { @condition.wait(@mutex, [[deadline - now, seconds].min, 0.05].min) }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def expect_condition(value)
|
|
378
|
+
condition = value.is_a?(Hash) ? value.transform_keys(&:to_sym) : { screen_contains: value }
|
|
379
|
+
current = @session.screen.to_s
|
|
380
|
+
if condition[:screen_contains] && !current.include?(condition[:screen_contains].to_s)
|
|
381
|
+
raise ExecutionError, "Expected screen to contain #{condition[:screen_contains].inspect}"
|
|
382
|
+
end
|
|
383
|
+
if condition[:screen] && !regex_match(Regexp.new(condition[:screen].to_s), current)
|
|
384
|
+
raise ExecutionError, "Expected screen to match #{condition[:screen].inspect}"
|
|
385
|
+
end
|
|
386
|
+
if condition[:line] && !line_match(Regexp.new(condition[:line]), current)
|
|
387
|
+
raise ExecutionError, "Expected a line to match #{condition[:line].inspect}"
|
|
388
|
+
end
|
|
389
|
+
if condition.key?(:exit_status) && @session.exit_status != condition[:exit_status]
|
|
390
|
+
raise ExecutionError, "Expected exit status #{condition[:exit_status]}, got #{@session.exit_status.inspect}"
|
|
391
|
+
end
|
|
392
|
+
if condition[:golden]
|
|
393
|
+
path = File.expand_path(condition[:golden], @config.base_dir)
|
|
394
|
+
expected = File.open(path, "rb") { |file| file.read(MAX_GOLDEN_BYTES + 1) }
|
|
395
|
+
raise ResourceLimitError, "Text golden is too large (max #{MAX_GOLDEN_BYTES} bytes)" if expected.bytesize > MAX_GOLDEN_BYTES
|
|
396
|
+
raise ExecutionError, "Text golden mismatch: #{condition[:golden]}" unless expected == "#{current}\n"
|
|
397
|
+
end
|
|
398
|
+
%i[row column].each do |coordinate|
|
|
399
|
+
expected = condition[:"cursor_#{coordinate}"]
|
|
400
|
+
actual = @session.screen.public_send(coordinate)
|
|
401
|
+
raise ExecutionError, "Expected cursor #{coordinate} #{expected}, got #{actual}" if expected && actual != expected
|
|
402
|
+
end
|
|
403
|
+
if condition[:elapsed_under] || condition[:elapsed_over]
|
|
404
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
|
|
405
|
+
if condition[:elapsed_under] && elapsed > parse_duration(condition[:elapsed_under])
|
|
406
|
+
raise ExecutionError, "Expected elapsed time under #{condition[:elapsed_under]}, got #{elapsed.round(3)}s"
|
|
407
|
+
end
|
|
408
|
+
if condition[:elapsed_over] && elapsed < parse_duration(condition[:elapsed_over])
|
|
409
|
+
raise ExecutionError, "Expected elapsed time over #{condition[:elapsed_over]}, got #{elapsed.round(3)}s"
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
rescue RegexpError => e
|
|
413
|
+
raise ValidationError, "Invalid expect pattern: #{e.message}"
|
|
414
|
+
rescue Errno::ENOENT
|
|
415
|
+
raise FileSystemError, "Text golden not found: #{condition[:golden]}"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def wait_for(pattern, timeout:, screen: false, line: false, offset: 0, prompt: false)
|
|
419
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
420
|
+
loop do
|
|
421
|
+
check_reader_error!
|
|
422
|
+
text = (screen || line) ? live_screen_text : buffer_from(offset)
|
|
423
|
+
match = if line
|
|
424
|
+
line_match(pattern, text, deadline: deadline)
|
|
425
|
+
else
|
|
426
|
+
regex_match(pattern, text)
|
|
427
|
+
end
|
|
428
|
+
return match if match && (!prompt || foreground_shell?)
|
|
429
|
+
|
|
430
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
431
|
+
raise ExecutionError, "Timed out after #{timeout}s waiting for #{pattern.inspect}" unless remaining.positive?
|
|
432
|
+
|
|
433
|
+
@mutex.synchronize { @condition.wait(@mutex, [remaining, 0.05].min) }
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def wait_for_quiet(seconds)
|
|
438
|
+
previous = -1
|
|
439
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + parse_duration(terminal[:timeout])
|
|
440
|
+
loop do
|
|
441
|
+
check_reader_error!
|
|
442
|
+
current = buffer_size
|
|
443
|
+
return if current == previous
|
|
444
|
+
raise ExecutionError, "Terminal did not become idle" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
445
|
+
|
|
446
|
+
previous = current
|
|
447
|
+
sleep(seconds)
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def check_requirements!
|
|
452
|
+
missing = @config.requires.reject { |command| resolve_command(command) }
|
|
453
|
+
raise DependencyError, "Required command(s) not found: #{missing.join(", ")}" unless missing.empty?
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def validate_working_directories!
|
|
457
|
+
paths = [File.expand_path(terminal[:cwd], @config.base_dir)]
|
|
458
|
+
paths.concat(@config.steps.filter_map { |step| step_directory(step[:cwd]) if step[:cwd] })
|
|
459
|
+
missing = paths.find { |path| !File.directory?(path) }
|
|
460
|
+
raise FileSystemError, "Working directory not found: #{missing}" if missing
|
|
461
|
+
return unless terminal[:cwd_policy] == "root"
|
|
462
|
+
|
|
463
|
+
root = File.realpath(@config.base_dir)
|
|
464
|
+
escaped = paths.map { |path| File.realpath(path) }
|
|
465
|
+
.find { |path| path != root && !path.start_with?("#{root}#{File::SEPARATOR}") }
|
|
466
|
+
raise FileSystemError, "Working directory escapes the session root: #{escaped}" if escaped
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def step_directory(path)
|
|
470
|
+
File.expand_path(path, File.expand_path(terminal[:cwd], @config.base_dir))
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def decode_terminal_bytes(chunk, final: false)
|
|
474
|
+
bytes = @utf8_pending << chunk.b
|
|
475
|
+
@utf8_pending = final ? +"".b : incomplete_utf8_suffix(bytes)
|
|
476
|
+
complete_size = bytes.bytesize - @utf8_pending.bytesize
|
|
477
|
+
bytes.byteslice(0, complete_size).to_s.force_encoding(Encoding::UTF_8).scrub
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def incomplete_utf8_suffix(bytes)
|
|
481
|
+
(bytes.bytesize - 1).downto([bytes.bytesize - 3, 0].max) do |index|
|
|
482
|
+
byte = bytes.getbyte(index)
|
|
483
|
+
next if byte.between?(0x80, 0xbf)
|
|
484
|
+
|
|
485
|
+
expected = if byte.between?(0xc2, 0xdf)
|
|
486
|
+
2
|
|
487
|
+
elsif byte.between?(0xe0, 0xef)
|
|
488
|
+
3
|
|
489
|
+
elsif byte.between?(0xf0, 0xf4)
|
|
490
|
+
4
|
|
491
|
+
end
|
|
492
|
+
suffix = bytes.byteslice(index..)
|
|
493
|
+
return suffix if expected && suffix.bytesize < expected && suffix.bytes.drop(1).all? { |part| part.between?(0x80, 0xbf) }
|
|
494
|
+
|
|
495
|
+
break
|
|
496
|
+
end
|
|
497
|
+
+"".b
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def append_terminal_chunk(chunk)
|
|
501
|
+
return if chunk.empty?
|
|
502
|
+
|
|
503
|
+
@mutex.synchronize do
|
|
504
|
+
if @buffer.bytesize + chunk.bytesize > MAX_OUTPUT_BYTES
|
|
505
|
+
@reader_error = ResourceLimitError.new("Terminal output exceeded #{MAX_OUTPUT_BYTES} bytes")
|
|
506
|
+
else
|
|
507
|
+
@buffer << chunk
|
|
508
|
+
begin
|
|
509
|
+
@live_screen.feed(chunk)
|
|
510
|
+
rescue Shellfie::Error => e
|
|
511
|
+
@reader_error = e
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
@condition.broadcast
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def resolve_command(command)
|
|
519
|
+
if command.include?(File::SEPARATOR)
|
|
520
|
+
path = File.expand_path(command, @config.base_dir)
|
|
521
|
+
return path if File.executable?(path)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
env = terminal[:env]
|
|
525
|
+
path = if env.key?("PATH")
|
|
526
|
+
env["PATH"]
|
|
527
|
+
elsif env.key?(:PATH)
|
|
528
|
+
env[:PATH]
|
|
529
|
+
else
|
|
530
|
+
ENV.fetch("PATH", "")
|
|
531
|
+
end
|
|
532
|
+
path.to_s.split(File::PATH_SEPARATOR)
|
|
533
|
+
.map { |path| File.join(path, command) }
|
|
534
|
+
.find { |candidate| File.file?(candidate) && File.executable?(candidate) }
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def key_sequence(name)
|
|
538
|
+
normalized = name.downcase
|
|
539
|
+
return KEYS.fetch(normalized) if KEYS.key?(normalized)
|
|
540
|
+
|
|
541
|
+
parts = normalized.split("-")
|
|
542
|
+
base = parts.pop
|
|
543
|
+
modifiers = parts
|
|
544
|
+
raise ValidationError, "Unsupported key: #{name}" unless (modifiers - %w[shift alt ctrl meta]).empty?
|
|
545
|
+
|
|
546
|
+
if base.length == 1
|
|
547
|
+
key = modifiers.include?("shift") ? base.upcase : base
|
|
548
|
+
key = (key.ord & 0x1f).chr if modifiers.include?("ctrl")
|
|
549
|
+
key = "\e#{key}" if (modifiers & %w[alt meta]).any?
|
|
550
|
+
return key
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
bits = 0
|
|
554
|
+
bits |= 1 if modifiers.include?("shift")
|
|
555
|
+
bits |= 2 if modifiers.include?("alt")
|
|
556
|
+
bits |= 4 if modifiers.include?("ctrl")
|
|
557
|
+
bits |= 8 if modifiers.include?("meta")
|
|
558
|
+
parameter = bits + 1
|
|
559
|
+
suffix = { "up" => "A", "down" => "B", "right" => "C", "left" => "D", "home" => "H", "end" => "F" }[base]
|
|
560
|
+
return "\e[1;#{parameter}#{suffix}" if suffix && bits.positive?
|
|
561
|
+
|
|
562
|
+
tilde = { "insert" => 2, "delete" => 3, "pageup" => 5, "pagedown" => 6 }[base]
|
|
563
|
+
return "\e[#{tilde};#{parameter}~" if tilde && bits.positive?
|
|
564
|
+
|
|
565
|
+
raise ValidationError, "Unsupported key: #{name}"
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def typing_delay(value)
|
|
569
|
+
rate = value.to_s[/\d+(?:\.\d+)?/]&.to_f || 30.0
|
|
570
|
+
rate = 30.0 unless rate.positive?
|
|
571
|
+
1.0 / rate
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def step_timeout(step)
|
|
575
|
+
parse_duration(step[:timeout] || terminal[:timeout])
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def parse_duration(value)
|
|
579
|
+
match = /\A(\d+(?:\.\d+)?)(ms|s)?\z/.match(value.to_s)
|
|
580
|
+
raise ValidationError, "Invalid duration: #{value}" unless match
|
|
581
|
+
raise ValidationError, "Duration is too large" if match[1].bytesize > 32
|
|
582
|
+
|
|
583
|
+
match[2] == "ms" ? match[1].to_f / 1_000 : match[1].to_f
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def redact(text)
|
|
587
|
+
clean = @prompt_marker ? text.to_s.gsub("\e]9;#{@prompt_marker}\a", "") : text.to_s
|
|
588
|
+
clean = clean.gsub(@session_home, "~") if @session_home
|
|
589
|
+
@redactions.reduce(clean) do |result, pattern|
|
|
590
|
+
with_regexp_timeout { result.gsub(pattern, "[REDACTED]") }
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def regex_match(pattern, text, timeout: REGEXP_TIMEOUT)
|
|
595
|
+
with_regexp_timeout(timeout) { pattern.match(text) }
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def line_match(pattern, text, deadline: nil)
|
|
599
|
+
text.each_line do |line|
|
|
600
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
601
|
+
return nil if remaining && !remaining.positive?
|
|
602
|
+
|
|
603
|
+
match = regex_match(pattern, line.chomp, timeout: remaining ? [remaining, REGEXP_TIMEOUT].min : REGEXP_TIMEOUT)
|
|
604
|
+
return match if match
|
|
605
|
+
end
|
|
606
|
+
nil
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def with_regexp_timeout(seconds = REGEXP_TIMEOUT, &block)
|
|
610
|
+
Timeout.timeout(seconds, &block)
|
|
611
|
+
rescue Timeout::Error
|
|
612
|
+
raise ExecutionError, "Regular expression exceeded #{seconds.round(3)}s"
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def live_screen_text
|
|
616
|
+
@mutex.synchronize { redact(@live_screen.to_s) }
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def flush_pending
|
|
620
|
+
text, finish = buffer_snapshot_from(@recorded_offset)
|
|
621
|
+
return if finish <= @recorded_offset
|
|
622
|
+
|
|
623
|
+
@session.record(redact(text), delay: 0.03, visible: @visible)
|
|
624
|
+
@recorded_offset = finish
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def set_visibility(visible)
|
|
628
|
+
flush_pending
|
|
629
|
+
@visible = visible
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def clear_buffer
|
|
633
|
+
@mutex.synchronize do
|
|
634
|
+
@buffer.clear
|
|
635
|
+
@live_screen = TerminalScreen.new(
|
|
636
|
+
columns: terminal[:columns], rows: terminal[:rows],
|
|
637
|
+
graphics_policy: @config.render.dig(:window, :graphics_policy) || "ignore"
|
|
638
|
+
)
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def buffer_size
|
|
643
|
+
@mutex.synchronize { @buffer.bytesize }
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def buffer_snapshot
|
|
647
|
+
@mutex.synchronize { @buffer.dup }
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def buffer_from(index)
|
|
651
|
+
@mutex.synchronize { @buffer.byteslice(index..) || "" }
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def buffer_snapshot_from(index)
|
|
655
|
+
@mutex.synchronize { [@buffer.byteslice(index..) || "", @buffer.bytesize] }
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def check_reader_error!
|
|
659
|
+
error = @mutex&.synchronize { @reader_error }
|
|
660
|
+
raise error if error
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def signal_process_group(signal)
|
|
664
|
+
Process.kill(signal, -@pid) if @pid
|
|
665
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
666
|
+
nil
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def process_group_alive?
|
|
670
|
+
return false unless @pid
|
|
671
|
+
|
|
672
|
+
Process.kill(0, -@pid)
|
|
673
|
+
true
|
|
674
|
+
rescue Errno::ESRCH
|
|
675
|
+
false
|
|
676
|
+
rescue Errno::EPERM
|
|
677
|
+
true
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def foreground_shell?
|
|
681
|
+
request = RbConfig::CONFIG["host_os"].include?("darwin") ? 0x40047477 : 0x540f
|
|
682
|
+
process_group = [0].pack("i")
|
|
683
|
+
@master.ioctl(request, process_group)
|
|
684
|
+
process_group.unpack1("i") == @pid
|
|
685
|
+
rescue SystemCallError, IOError, NotImplementedError
|
|
686
|
+
false
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|