expect-pty 0.5.3 → 0.7.1
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 +55 -1
- data/README.md +122 -130
- data/docs/API.md +113 -0
- data/docs/MIGRATION.md +68 -0
- data/lib/expect/cleanup.rb +34 -0
- data/lib/expect/interaction.rb +228 -210
- data/lib/expect/logging.rb +117 -162
- data/lib/expect/matcher.rb +42 -38
- data/lib/expect/pattern.rb +10 -3
- data/lib/expect/pattern_list.rb +39 -9
- data/lib/expect/redactor.rb +12 -5
- data/lib/expect/relay.rb +106 -81
- data/lib/expect/relay_writer.rb +7 -5
- data/lib/expect/result.rb +33 -9
- data/lib/expect/session.rb +554 -0
- data/lib/expect/session_resources.rb +19 -18
- data/lib/expect/version.rb +2 -2
- data/lib/expect.rb +68 -578
- data/sig/expect.rbs +279 -0
- metadata +14 -123
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/lib/expect/configuration.rb +0 -122
- data/lib/expect/terminal.rb +0 -102
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- data/test/write_contract_test.rb +0 -105
data/test/edge_case_test.rb
DELETED
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "test_helper"
|
|
4
|
-
require "open3"
|
|
5
|
-
|
|
6
|
-
class EdgeCaseTest < ExpectTest
|
|
7
|
-
def test_zero_width_continuation_observes_original_deadline
|
|
8
|
-
session, = pipe_session
|
|
9
|
-
session.buffer = "ready"
|
|
10
|
-
result = bounded(1) do
|
|
11
|
-
session.expect_result(timeout: 0.01) { on(/(?=ready)/) { Expect.continue(reset_timeout: false) } }
|
|
12
|
-
end
|
|
13
|
-
assert result.timeout?
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def test_zero_width_continuation_waits_for_new_input_before_matching_again
|
|
17
|
-
session, = pipe_session
|
|
18
|
-
session.buffer = "ready"
|
|
19
|
-
calls = 0
|
|
20
|
-
|
|
21
|
-
result = bounded(1) do
|
|
22
|
-
session.expect_result(timeout: 0.01) do
|
|
23
|
-
on(/(?=ready)/) do
|
|
24
|
-
calls += 1
|
|
25
|
-
Expect.continue
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
assert result.timeout?
|
|
31
|
-
assert_equal 1, calls
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def test_stalled_pattern_can_match_again_after_new_input
|
|
35
|
-
session, writer = pipe_session
|
|
36
|
-
session.buffer = "ready"
|
|
37
|
-
first_match = Queue.new
|
|
38
|
-
background do
|
|
39
|
-
first_match.pop
|
|
40
|
-
writer.write("!")
|
|
41
|
-
end
|
|
42
|
-
calls = 0
|
|
43
|
-
|
|
44
|
-
result = bounded(1) do
|
|
45
|
-
session.expect_result(timeout: 0.5) do
|
|
46
|
-
on(/(?=ready)/) do
|
|
47
|
-
calls += 1
|
|
48
|
-
first_match << true if calls == 1
|
|
49
|
-
calls == 1 ? Expect.continue : nil
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
assert result.matched?
|
|
55
|
-
assert_equal 2, calls
|
|
56
|
-
assert_equal "ready!", session.buffer
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def test_stalled_pattern_can_match_again_after_timeout_callback_changes_buffer
|
|
60
|
-
session, = pipe_session
|
|
61
|
-
session.buffer = "ready"
|
|
62
|
-
calls = 0
|
|
63
|
-
timeouts = 0
|
|
64
|
-
|
|
65
|
-
result = bounded(1) do
|
|
66
|
-
session.expect_result(timeout: 0.01) do
|
|
67
|
-
on(/(?=ready)/) do
|
|
68
|
-
calls += 1
|
|
69
|
-
calls == 1 ? Expect.continue : nil
|
|
70
|
-
end
|
|
71
|
-
timeout do
|
|
72
|
-
timeouts += 1
|
|
73
|
-
session.buffer = "ready!"
|
|
74
|
-
timeouts == 1 ? Expect.continue : nil
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
assert result.matched?
|
|
80
|
-
assert_equal 2, calls
|
|
81
|
-
assert_equal 1, timeouts
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def test_stdout_works_with_utf8_banner_and_ascii_regexp
|
|
85
|
-
session, writer = pipe_session
|
|
86
|
-
writer.write("欢迎登录\nprompt>")
|
|
87
|
-
assert_equal 1, session.expect(/prompt>/, timeout: 1)
|
|
88
|
-
assert_equal "欢迎登录\n".b, session.before
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def test_invalid_fixed_encoding_regexp_data_raises
|
|
92
|
-
session, = pipe_session
|
|
93
|
-
session.buffer = "\xffinvalid".b
|
|
94
|
-
assert_raises(EncodingError) { session.expect(/中文/, timeout: 0) }
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def test_replacing_log_with_invalid_target_preserves_current_log
|
|
98
|
-
session, = pipe_session
|
|
99
|
-
log = StringIO.new
|
|
100
|
-
session.log_to(log)
|
|
101
|
-
assert_raises(ArgumentError) { session.log_to(42) }
|
|
102
|
-
assert_same log, session.log_output
|
|
103
|
-
session.write_log("still open")
|
|
104
|
-
assert_equal "still open", log.string
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def test_new_session_predicates_use_ruby_truthiness
|
|
108
|
-
Expect.configure(raw_pty: 0, log_stdout: nil)
|
|
109
|
-
session = Expect.new
|
|
110
|
-
@sessions << session
|
|
111
|
-
assert session.raw_pty?
|
|
112
|
-
refute session.log_stdout?
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def test_read_only_regular_file
|
|
116
|
-
Tempfile.create("expect-input") do |file|
|
|
117
|
-
file.write("first\nsecond\n")
|
|
118
|
-
file.rewind
|
|
119
|
-
Expect.open(file) do |session|
|
|
120
|
-
assert_equal 1, session.expect(/^second$/, timeout: 0)
|
|
121
|
-
assert_equal "first\n", session.before
|
|
122
|
-
assert session.expect_result(:eof, timeout: 1).eof?
|
|
123
|
-
end
|
|
124
|
-
refute file.closed?
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def test_existing_pty_eio_is_eof
|
|
129
|
-
master, slave = PTY.open
|
|
130
|
-
@ios.push(master, slave)
|
|
131
|
-
session = Expect.open(master)
|
|
132
|
-
@sessions << session
|
|
133
|
-
slave.write("end")
|
|
134
|
-
slave.flush
|
|
135
|
-
assert_equal 1, session.expect("end", timeout: 1)
|
|
136
|
-
slave.close
|
|
137
|
-
assert bounded { session.expect_result(:eof, timeout: 1) }.eof?
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def test_distinct_reader_writer_io
|
|
141
|
-
incoming, producer = IO.pipe
|
|
142
|
-
consumer, outgoing = IO.pipe
|
|
143
|
-
@ios.push(incoming, producer, consumer, outgoing)
|
|
144
|
-
session = Expect.open(incoming, writer: outgoing)
|
|
145
|
-
@sessions << session
|
|
146
|
-
producer.write("ready")
|
|
147
|
-
assert_equal 1, session.expect("ready", timeout: 1)
|
|
148
|
-
assert_equal 3, session.write("abc")
|
|
149
|
-
assert_equal "abc", consumer.read(3)
|
|
150
|
-
session.close
|
|
151
|
-
refute incoming.closed?
|
|
152
|
-
refute outgoing.closed?
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def test_control_character_delivers_signal_to_foreground_child
|
|
156
|
-
session = child('Signal.trap("INT") { puts "interrupted"; exit 0 }; puts "ready"; sleep 30')
|
|
157
|
-
session.expect("ready", timeout: 2)
|
|
158
|
-
session.write("\x03")
|
|
159
|
-
assert_equal 1, session.expect("interrupted", timeout: 2)
|
|
160
|
-
assert_equal 0, session.wait(timeout: 1).exitstatus
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def test_multiple_real_pty_processes
|
|
164
|
-
first = child('puts "one"')
|
|
165
|
-
second = child('STDIN.gets; puts "two"', raw_pty: true)
|
|
166
|
-
seen = []
|
|
167
|
-
record_session = lambda do |session|
|
|
168
|
-
seen << session
|
|
169
|
-
Expect.continue(reset_timeout: false)
|
|
170
|
-
end
|
|
171
|
-
result = Expect.expect_result(timeout: 2) do
|
|
172
|
-
on(/one/, from: first, &record_session)
|
|
173
|
-
eof(from: first) do
|
|
174
|
-
# 明确建立两个进程的顺序,不能通过 sleep 推断启动和输出的先后。
|
|
175
|
-
second.puts("continue")
|
|
176
|
-
Expect.continue(reset_timeout: false)
|
|
177
|
-
end
|
|
178
|
-
on("two", from: second)
|
|
179
|
-
end
|
|
180
|
-
assert_equal [first], seen
|
|
181
|
-
assert_same second, result.session
|
|
182
|
-
assert_equal 3, result.number
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def test_signal_interruption_preserves_deadline
|
|
186
|
-
session, writer = pipe_session
|
|
187
|
-
old_handler = Signal.trap("USR1") { nil }
|
|
188
|
-
background do
|
|
189
|
-
4.times do
|
|
190
|
-
sleep 0.01
|
|
191
|
-
Process.kill("USR1", Process.pid)
|
|
192
|
-
end
|
|
193
|
-
writer.write("ready")
|
|
194
|
-
end
|
|
195
|
-
assert_equal(1, bounded { session.expect("ready", timeout: 1) })
|
|
196
|
-
ensure
|
|
197
|
-
Signal.trap("USR1", old_handler) if old_handler
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def test_gc_reclaims_abandoned_child
|
|
201
|
-
assert_gc_reclaims_abandoned_child
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def test_gc_reclaims_abandoned_child_with_log_callback_capturing_session
|
|
205
|
-
assert_gc_reclaims_abandoned_child(log_callback: true)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def test_invalid_options_raise_before_spawning
|
|
209
|
-
assert_raises(ArgumentError) { Expect.new(typo: true) }
|
|
210
|
-
session, = pipe_session
|
|
211
|
-
assert_raises(ArgumentError) { session.expect(0, "x") }
|
|
212
|
-
assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
|
|
213
|
-
assert_raises(ArgumentError) { session.on_sequence("") }
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def test_entrypoint_can_coexist_with_standard_library_expect
|
|
217
|
-
entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
|
|
218
|
-
script = <<~RUBY
|
|
219
|
-
require "rbconfig"
|
|
220
|
-
require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
|
|
221
|
-
require ARGV.fetch(0)
|
|
222
|
-
puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
|
|
223
|
-
RUBY
|
|
224
|
-
environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
|
|
225
|
-
output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
|
|
226
|
-
assert status.success?, output
|
|
227
|
-
assert_equal "true:constant:#{Expect::VERSION}\n", output
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
private
|
|
231
|
-
|
|
232
|
-
def assert_gc_reclaims_abandoned_child(log_callback: false)
|
|
233
|
-
script = <<~RUBY
|
|
234
|
-
require "expect"
|
|
235
|
-
require "rbconfig"
|
|
236
|
-
def abandoned
|
|
237
|
-
session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false)
|
|
238
|
-
session.log_to { |bytes| [session.pid, bytes] } if #{log_callback}
|
|
239
|
-
session.pid
|
|
240
|
-
end
|
|
241
|
-
# Ruby 的保守 GC 可能扫描到创建线程栈上残留的引用;先结束该线程,确保会话确实不可达。
|
|
242
|
-
pid = Thread.new { abandoned }.value
|
|
243
|
-
20.times do
|
|
244
|
-
GC.start
|
|
245
|
-
sleep 0.02
|
|
246
|
-
begin
|
|
247
|
-
Process.kill(0, pid)
|
|
248
|
-
rescue Errno::ESRCH
|
|
249
|
-
puts "reaped"
|
|
250
|
-
exit 0
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
Process.kill("KILL", pid) rescue nil
|
|
254
|
-
Process.waitpid(pid) rescue nil
|
|
255
|
-
abort "abandoned child survived GC"
|
|
256
|
-
RUBY
|
|
257
|
-
output, status = Open3.capture2e(RbConfig.ruby, "--disable-gems", "-I", File.expand_path("../lib", __dir__), "-e",
|
|
258
|
-
script)
|
|
259
|
-
assert status.success?, output
|
|
260
|
-
assert_equal "reaped\n", output
|
|
261
|
-
end
|
|
262
|
-
end
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "test_helper"
|
|
4
|
-
require "minitest/mock"
|
|
5
|
-
|
|
6
|
-
class InitializationFailureTest < ExpectTest
|
|
7
|
-
def test_new_closes_both_pty_ends_before_propagating_the_original_exception
|
|
8
|
-
initialization_errors.each do |failure|
|
|
9
|
-
master, slave = PTY.open
|
|
10
|
-
@ios.push(master, slave)
|
|
11
|
-
PTY.stub(:open, [master, slave]) do
|
|
12
|
-
without_ledger(failure) do
|
|
13
|
-
assert_same failure, assert_raises(failure.class) { Expect.new }
|
|
14
|
-
assert master.closed?, "master leaked before test cleanup"
|
|
15
|
-
assert slave.closed?, "slave leaked before test cleanup"
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def test_open_cleans_owned_handles_and_preserves_borrowed_handles
|
|
22
|
-
[true, false].product(initialization_errors).each do |own, failure|
|
|
23
|
-
reader, writer = IO.pipe
|
|
24
|
-
@ios.push(reader, writer)
|
|
25
|
-
without_ledger(failure) do
|
|
26
|
-
assert_same failure, assert_raises(failure.class) { Expect.open(reader, writer: writer, own: own) }
|
|
27
|
-
assert_equal own, reader.closed?
|
|
28
|
-
assert_equal own, writer.closed?
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def test_duplicate_owned_handle_is_attempted_once_even_when_close_fails
|
|
34
|
-
reader, writer = IO.pipe
|
|
35
|
-
@ios.push(reader, writer)
|
|
36
|
-
calls = 0
|
|
37
|
-
reader.stub(:close, lambda {
|
|
38
|
-
calls += 1
|
|
39
|
-
raise IOError, "injected close failure"
|
|
40
|
-
}) do
|
|
41
|
-
without_ledger(failure = Interrupt.new) do
|
|
42
|
-
assert_same failure, assert_raises(Interrupt) { Expect.open(reader, own: true) }
|
|
43
|
-
assert_equal 1, calls
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def test_cleanup_failure_still_attempts_other_handles_and_keeps_primary_exception
|
|
49
|
-
initialization_errors.each do |failure|
|
|
50
|
-
reader, writer = IO.pipe
|
|
51
|
-
@ios.push(reader, writer)
|
|
52
|
-
reader.stub(:close, -> { raise Errno::EIO }) do
|
|
53
|
-
without_ledger(failure) do
|
|
54
|
-
assert_same failure, assert_raises(failure.class) { Expect.open(reader, writer: writer, own: true) }
|
|
55
|
-
assert writer.closed?
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def test_new_cleanup_failure_still_closes_slave
|
|
62
|
-
master, slave = PTY.open
|
|
63
|
-
@ios.push(master, slave)
|
|
64
|
-
PTY.stub(:open, [master, slave]) do
|
|
65
|
-
master.stub(:close, -> { raise IOError, "injected close failure" }) do
|
|
66
|
-
without_ledger(failure = SystemExit.new(17)) do
|
|
67
|
-
assert_same failure, assert_raises(SystemExit) { Expect.new }
|
|
68
|
-
assert slave.closed?
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def test_invalid_non_io_argument_is_not_closed_by_the_fallback
|
|
75
|
-
reader, writer = IO.pipe
|
|
76
|
-
@ios.push(reader, writer)
|
|
77
|
-
invalid = Object.new
|
|
78
|
-
invalid.define_singleton_method(:close) { raise "must not close non-IO objects" }
|
|
79
|
-
without_ledger(failure = Interrupt.new) do
|
|
80
|
-
assert_same failure, assert_raises(Interrupt) { Expect.open(invalid, writer: writer, own: true) }
|
|
81
|
-
assert writer.closed?
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
private
|
|
86
|
-
|
|
87
|
-
def initialization_errors = [Interrupt.new("injected initialization interruption"), SystemExit.new(17)]
|
|
88
|
-
|
|
89
|
-
def without_ledger(failure, &)
|
|
90
|
-
Expect::SessionResources.stub(:new, ->(*, **) { raise failure }, &)
|
|
91
|
-
end
|
|
92
|
-
end
|
data/test/integration/README.md
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
# SSH 多脚本和日志验证
|
|
2
|
-
|
|
3
|
-
在仓库根目录运行,要求 Ruby 3.2+、SSH 服务,以及能运行 POSIX `/bin/sh` 的目标账户:
|
|
4
|
-
|
|
5
|
-
```sh
|
|
6
|
-
SSH_HOST=127.0.0.1 SSH_USER=crate ruby test/integration/ssh_scripts.rb
|
|
7
|
-
# 也可以通过 Rake:
|
|
8
|
-
SSH_HOST=127.0.0.1 SSH_USER=crate bundle exec rake test:ssh
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
脚本隐藏读取密码;也支持由运行环境提供 `EXPECT_PASSWORD`。密码不会作为 SSH 命令行参数传递,日志在认证完成后才开启。默认自动测试不连接
|
|
12
|
-
SSH、不读取密码。
|
|
13
|
-
|
|
14
|
-
支持 `SSH_PORT`(默认 22)、`EXPECT_LOG_DIR`(日志根目录)、`SSH_KNOWN_HOSTS`。连接非本机主机时,必须提供已有的受信任
|
|
15
|
-
known_hosts,例如:
|
|
16
|
-
|
|
17
|
-
```sh
|
|
18
|
-
SSH_HOST=192.0.2.10 SSH_USER=operator SSH_PORT=22 \
|
|
19
|
-
SSH_KNOWN_HOSTS="$HOME/.ssh/known_hosts" \
|
|
20
|
-
EXPECT_LOG_DIR=tmp/device-check ruby test/integration/ssh_scripts.rb
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
这是 POSIX shell 脚本测试;厂商专用 CLI(如交换机的 `show` 命令、分页提示符)需要对应设备的命令和预期输出,不能直接使用这些
|
|
24
|
-
shell fixtures。
|
|
25
|
-
|
|
26
|
-
## 同一个登录会话执行的脚本
|
|
27
|
-
|
|
28
|
-
| 脚本 | 验证内容 | 预期退出码 |
|
|
29
|
-
|------------------|---------------------------------------|---------------|
|
|
30
|
-
| `01_identity.sh` | 当前用户、远端 PTY、完成标记 | 0 |
|
|
31
|
-
| `02_output.sh` | stdout、stderr、中文和输出顺序 | 0 |
|
|
32
|
-
| `03_delayed.sh` | 分三次延迟输出,确保日志完整 | 0 |
|
|
33
|
-
| `04_failure.sh` | 明确输出错误并退出 | 7(预期失败) |
|
|
34
|
-
| `05_recovery.sh` | 上个脚本失败后继续执行,计算结果为 42 | 0 |
|
|
35
|
-
|
|
36
|
-
fixtures 位于 `test/fixtures/ssh_scripts/`,都不修改目标文件或设备配置。文件内容通过 SSH 的交互 shell 传给独立
|
|
37
|
-
`/bin/sh -c` 执行,不需要上传文件。每个脚本都有随机开始/结束标记,结束标记携带真实 `$?`;脚本输出和退出码都必须符合预期才算通过。
|
|
38
|
-
|
|
39
|
-
测试关闭终端回显和 shell 行编辑,使用单引号传输完整脚本,保留中文 UTF-8 和引号,避免 C locale 的交互式 readline
|
|
40
|
-
把高位字节当快捷键执行。末尾仍等待 shell 提示符,确认可以继续执行下一项。
|
|
41
|
-
|
|
42
|
-
另外执行两条日志控制命令:关闭日志后输出 `UNLOGGED_OUTPUT`,再以默认追加模式开启日志并输出 `APPEND_OK`。最后发送延迟输出及退出命令,只调用
|
|
43
|
-
`soft_close`,确认 `SESSION_FINAL_TAIL` 确实在关闭过程中写入日志。
|
|
44
|
-
|
|
45
|
-
## 检查和输出文件
|
|
46
|
-
|
|
47
|
-
每次执行在 `tmp/ssh-logs/` 下创建独立目录,目录权限 0700,文件权限 0600:
|
|
48
|
-
|
|
49
|
-
- `session.log`:收到的原始数据,以及手工添加的 `[SEND] 脚本名 sha256=...` 和 `[EXIT] 脚本名 status=...`。
|
|
50
|
-
- `report.json`:主机、用户、远端 TTY、每项脚本输出和退出码、校验结果、SSH 退出码、日志字节数及 SHA-256。
|
|
51
|
-
|
|
52
|
-
只有认证后的输出进入日志。Expect 自动记录接收字节,发送的脚本名称和摘要由 `write_log` 明确写入,便于审核执行了哪个
|
|
53
|
-
fixture;fixture 文件中的原文可以用报告内的 SHA-256 核对。
|
|
54
|
-
|
|
55
|
-
共 11 项日志/流程检查:完整输出、执行顺序、即时
|
|
56
|
-
flush、stdout/stderr、UTF-8、非零退出后恢复、覆盖模式、暂停记录、追加模式、关闭前尾部输出、密码不出现在日志中。另校验日志条目没有重复,日志句柄在停止记录和关闭会话后正确关闭。
|
|
57
|
-
|
|
58
|
-
成功返回进程退出码 0。认证失败、超时、脚本输出错误、意外退出码或日志校验失败均返回非零,并在有报告目录时留下 `passed: false`
|
|
59
|
-
的报告。不用仅看到终端输出就判断成功;请查看最终 PASS 和 `report.json` 的 `passed`。
|
|
60
|
-
|
|
61
|
-
## 无需 SSH 的回归测试
|
|
62
|
-
|
|
63
|
-
```sh
|
|
64
|
-
bundle exec ruby -Itest test/script_logging_test.rb
|
|
65
|
-
bundle exec rake test
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
`test/script_logging_test.rb` 使用真实本地 PTY 执行相同 fixtures,另外验证回调日志、借用 File 日志、错误退出/错误输出的反例、引号和
|
|
69
|
-
shell 元字符、日志错误上抛,以及超时后继续收集日志。它和 SSH 脚本共享测试 helper,不依赖远端账户。
|
|
70
|
-
|
|
71
|
-
## SSH interact 人工和自动验证
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
ruby examples/ssh_interact.rb
|
|
75
|
-
ruby examples/ssh_interact.rb --auto
|
|
76
|
-
# 自动方式也可通过 Rake 运行
|
|
77
|
-
bundle exec rake test:ssh_interact
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
使用上述同一套 SSH 环境变量和隐藏密码输入。人工模式显示 `EXPECT_SCRIPT_PROMPT>`;输入命令有回显,Ctrl-C 传给远端前台任务。在命令结束、回到
|
|
81
|
-
shell 提示符后按 Ctrl-],会关闭远端回显,执行 `MANUAL_AUTOMATION_RESUMED` 检查,再正常退出 SSH。也支持直接输入 `exit`:成功退出记为
|
|
82
|
-
`completion: remote_exit`,此路径不执行返回 expect 的检查。非零 SSH 退出仍判为失败。
|
|
83
|
-
|
|
84
|
-
`--auto` 创建真实本地 PTY,验证两次 `interact` 接管、当前用户和远端 TTY、stdout/stderr/中文、Ctrl-C、Ctrl-]、转义后的本地尾部不进入
|
|
85
|
-
SSH、恢复 `expect` 及终端与监听设置。4 项命令输出和 13 项交互/日志检查全部通过后才返回 0。
|
|
86
|
-
|
|
87
|
-
日志在认证之后开启;每次在 `tmp/ssh-interact/` 新建 0700 目录,保存 0600 的 `session.log` 和 `report.json`
|
|
88
|
-
。报告包含检查项、命令输出、SSH 退出码及日志摘要。自动模式也校验日志无重复、退出尾部不丢失、密码不出现在日志中。
|
|
89
|
-
|
|
90
|
-
无需 SSH 的回归位于 `test/interact_test.rb`,覆盖可见输入、Ctrl-C、两次接管、返回自动命令、超时、两端 EOF、回调异常和输出 IO
|
|
91
|
-
故障时的终端恢复。`test/script_logging_test.rb` 另验证 CRLF 输出边界,包括空输出、末尾有/无换行和多个换行。
|
|
92
|
-
|
|
93
|
-
## `ssh_auto.rb` 环境检查
|
|
94
|
-
|
|
95
|
-
```sh
|
|
96
|
-
ruby examples/ssh_auto.rb
|
|
97
|
-
# 无人值守执行(密码可以由运行环境提供 EXPECT_PASSWORD)
|
|
98
|
-
ruby examples/ssh_auto.rb --no-interact
|
|
99
|
-
# 或执行 Rake 的自动退出入口
|
|
100
|
-
bundle exec rake test:ssh_auto
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
该示例自动登录 `SSH_HOST`(默认 `127.0.0.1`),用本库的 `write` / `expect` 顺序执行文件顶部的 `COMMANDS`,显示真实输出并检查退出码。默认命令为
|
|
104
|
-
`id`、`uname -srm`、`sw_vers`、`df -h /` 和 `uptime`,不修改系统设置;可直接修改数组来下发其他 macOS/POSIX shell 命令。它不依赖
|
|
105
|
-
`test/support`,`ScriptProbe.check` 只供测试脚本使用。
|
|
106
|
-
|
|
107
|
-
执行成功后默认进入 `interact`,输入有回显,Ctrl-C 发给远端前台任务;`exit` 或 Ctrl-] 结束连接。使用 `--no-interact` 则执行完退出。
|
|
108
|
-
`EXPECT_LOG_DIR` 可指定日志目录,默认 `tmp/ssh-auto/`;每次创建独立 0600 日志,记录命令和远端输出,认证前关闭日志。此使用示例不生成
|
|
109
|
-
JSON 测试报告,详细日志断言仍由 `ssh_scripts.rb` 和 `ssh_interact.rb --auto` 验证。
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Opt-in: this script makes a real SSH connection. The default rake test does
|
|
4
|
-
# not load it, and no password belongs in fixture files or command arguments.
|
|
5
|
-
require "io/console"
|
|
6
|
-
require "json"
|
|
7
|
-
require "fileutils"
|
|
8
|
-
require "tmpdir"
|
|
9
|
-
require "time"
|
|
10
|
-
require_relative "../support/script_probe"
|
|
11
|
-
|
|
12
|
-
host = ENV.fetch("SSH_HOST", "127.0.0.1")
|
|
13
|
-
user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
|
|
14
|
-
port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
|
|
15
|
-
ScriptProbe.check((1..65_535).cover?(port), "SSH_PORT must be between 1 and 65535")
|
|
16
|
-
ScriptProbe.check([host, user].none? do |value|
|
|
17
|
-
value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
|
|
18
|
-
end, "invalid SSH host or user")
|
|
19
|
-
known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
|
|
20
|
-
ScriptProbe.check(known_hosts || %w[127.0.0.1 ::1 localhost].include?(host),
|
|
21
|
-
"SSH_KNOWN_HOSTS is required for remote hosts")
|
|
22
|
-
|
|
23
|
-
password = ENV.delete("EXPECT_PASSWORD")&.dup
|
|
24
|
-
unless password
|
|
25
|
-
$stderr.print("SSH password: ")
|
|
26
|
-
password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
|
|
27
|
-
$stderr.puts
|
|
28
|
-
end
|
|
29
|
-
ScriptProbe.check(password && !password.empty? && !password.match?(/[\r\n\x00]/), "a single-line password is required")
|
|
30
|
-
|
|
31
|
-
base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../../tmp/ssh-logs", __dir__)))
|
|
32
|
-
FileUtils.mkdir_p(base)
|
|
33
|
-
directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
|
|
34
|
-
log_path = File.join(directory, "session.log")
|
|
35
|
-
report_path = File.join(directory, "report.json")
|
|
36
|
-
report = { host: host, port: port, user: user, started_at: Time.now.utc.iso8601, passed: false }
|
|
37
|
-
runner = nil
|
|
38
|
-
session = nil
|
|
39
|
-
|
|
40
|
-
begin
|
|
41
|
-
Dir.mktmpdir("expect-known-hosts-") do |temporary|
|
|
42
|
-
policy = known_hosts ? "yes" : "accept-new"
|
|
43
|
-
hosts_path = known_hosts || File.join(temporary, "known_hosts")
|
|
44
|
-
args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
|
|
45
|
-
"-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
|
|
46
|
-
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
|
|
47
|
-
"-o", "StrictHostKeyChecking=#{policy}", "-o", "UserKnownHostsFile=#{hosts_path}",
|
|
48
|
-
"-l", user, host, "env ENV= PS1=#{Shellwords.escape(ScriptProbe::PROMPT)} /bin/sh -i"]
|
|
49
|
-
# Authentication is completed before opening any log. Parent defaults
|
|
50
|
-
# cannot accidentally turn on debug or console logging for this check.
|
|
51
|
-
session = Expect.spawn(*args, raw_pty: true, log_stdout: false, log_listeners: false,
|
|
52
|
-
debug_level: 0, write_timeout: 5)
|
|
53
|
-
authentication = session.expect_result(/password:\s*\z/i, /Permission denied/i, timeout: 10)
|
|
54
|
-
ScriptProbe.check(authentication.number == 1,
|
|
55
|
-
"SSH password prompt not received (#{authentication.error || "authentication rejected"})")
|
|
56
|
-
session.write(password, "\n")
|
|
57
|
-
runner = ScriptProbe::Runner.new(session, timeout: 10).ready!
|
|
58
|
-
report.merge!(ScriptProbe.verify_file_session(runner, log_path, user: user))
|
|
59
|
-
report[:ssh_exit_code] = session.exit_code
|
|
60
|
-
ScriptProbe.check(!File.binread(log_path).include?(password.b), "password detected in session log")
|
|
61
|
-
report[:checks] << "password_absent"
|
|
62
|
-
report[:passed] = true
|
|
63
|
-
end
|
|
64
|
-
rescue StandardError => error
|
|
65
|
-
# Error messages identify the failed check; raw authentication output is
|
|
66
|
-
# deliberately excluded from both the report and the console.
|
|
67
|
-
report[:error] = "#{error.class}: #{error.message}".gsub(password, "[REDACTED]")
|
|
68
|
-
report[:cases] ||= runner&.results || []
|
|
69
|
-
ensure
|
|
70
|
-
session&.close
|
|
71
|
-
if File.file?(log_path)
|
|
72
|
-
bytes = File.binread(log_path)
|
|
73
|
-
if bytes.include?(password.b)
|
|
74
|
-
File.binwrite(log_path, bytes.gsub(password.b, "[REDACTED]"))
|
|
75
|
-
report[:passed] = false
|
|
76
|
-
report[:error] = "password detected and removed from log"
|
|
77
|
-
end
|
|
78
|
-
report[:log_sha256] = Digest::SHA256.file(log_path).hexdigest
|
|
79
|
-
report[:log_bytes] = File.size(log_path)
|
|
80
|
-
end
|
|
81
|
-
password.replace("\0" * password.bytesize)
|
|
82
|
-
report[:finished_at] = Time.now.utc.iso8601
|
|
83
|
-
report[:log_path] = log_path
|
|
84
|
-
File.open(report_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
report.fetch(:cases, []).each do |result|
|
|
88
|
-
puts "#{result[:passed] ? "PASS" : "FAIL"} #{result[:name]} " \
|
|
89
|
-
"exit=#{result[:status]} expected=#{result[:expected_status]}"
|
|
90
|
-
end
|
|
91
|
-
puts "Log: #{log_path}"
|
|
92
|
-
puts "Report: #{report_path}"
|
|
93
|
-
abort(report[:error]) unless report[:passed]
|
|
94
|
-
puts "PASS SSH multi-script execution and #{report[:checks].length} logging checks; SSH exit=0"
|