expect-pty 0.5.1 → 0.5.3

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.
data/lib/expect.rb CHANGED
@@ -24,6 +24,7 @@ class Expect
24
24
  private_constant :CONFIGURATION_MUTEX
25
25
 
26
26
  class SpawnError < StandardError; end
27
+ class ReentrancyError < StandardError; end
27
28
 
28
29
  # 已被底层接受的字节不可撤回;调用方可据此只处理尚未写出的后缀。
29
30
  class WriteTimeout < IOError
@@ -70,7 +71,7 @@ class Expect
70
71
  raise
71
72
  ensure
72
73
  if session && (block_given? || !spawned)
73
- session.__send__(:cleanup_preserving_failure, failed) do
74
+ session.__send__(:cleanup, failed: failed) do
74
75
  session.close(graceful: spawned && session.graceful_close?)
75
76
  end
76
77
  end
@@ -89,8 +90,9 @@ class Expect
89
90
  raise
90
91
  ensure
91
92
  if session && (block_given? || !initialized)
92
- session.__send__(:cleanup_preserving_failure, failed) do
93
- session.close(graceful: initialized && session.graceful_close?)
93
+ session.__send__(:cleanup, failed: failed) do
94
+ session.__send__(:cleanup_session, io, writer: writer, own: own,
95
+ graceful: initialized && session.graceful_close?)
94
96
  end
95
97
  end
96
98
  end
@@ -189,7 +191,7 @@ class Expect
189
191
  failed = true
190
192
  raise
191
193
  ensure
192
- cleanup_preserving_failure(failed) { close(graceful: false) } if @resources && !initialized
194
+ cleanup(failed:) { cleanup_session(master, writer: master, slave: slave, own: true) } unless initialized
193
195
  end
194
196
 
195
197
  # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
@@ -273,7 +275,12 @@ class Expect
273
275
  def pid = @resources.pid
274
276
 
275
277
  # 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
276
- def process_status = @resources.reap
278
+ def process_status
279
+ @resources.reap
280
+ rescue Errno::EINTR
281
+ # 单次轮询被中断时状态仍未知;wait/close 会在原期限内继续,不在这里无限重试。
282
+ @resources.status
283
+ end
277
284
 
278
285
  def exit_code = process_status&.exitstatus
279
286
 
@@ -332,7 +339,8 @@ class Expect
332
339
  offset = 0
333
340
  while offset < data.bytesize
334
341
  begin
335
- count = writer.write_nonblock(data.byteslice(offset, READ_SIZE), exception: false)
342
+ chunk = data.byteslice(offset, READ_SIZE)
343
+ count = writer.write_nonblock(chunk, exception: false)
336
344
  rescue Errno::EINTR
337
345
  raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
338
346
 
@@ -360,6 +368,10 @@ class Expect
360
368
  next
361
369
  end
362
370
  else
371
+ unless count.is_a?(Integer) && count.positive? && count <= chunk.bytesize
372
+ raise IOError, "write must return the number of accepted bytes"
373
+ end
374
+
363
375
  offset += count
364
376
  end
365
377
  end
@@ -400,15 +412,7 @@ class Expect
400
412
 
401
413
  # 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
402
414
  def wait(timeout: nil)
403
- period = Expect.duration(timeout)
404
- deadline = period && (Expect.monotonic + period)
405
- loop do
406
- status = process_status
407
- return status if status || !pid
408
- return nil if deadline && Expect.monotonic >= deadline
409
-
410
- sleep(deadline ? [0.01, deadline - Expect.monotonic].min.clamp(0, 0.01) : 0.01)
411
- end
415
+ wait_for_child(Expect.duration(timeout))
412
416
  end
413
417
 
414
418
  # 先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。
@@ -425,8 +429,8 @@ class Expect
425
429
 
426
430
  read_available
427
431
  end
428
- finish_close(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
429
- term_timeout: term_timeout, force: false)
432
+ close_resources(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
433
+ term_timeout: term_timeout, force: false)
430
434
  end
431
435
 
432
436
  # 立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。
@@ -434,7 +438,7 @@ class Expect
434
438
  period = Expect.duration(timeout)
435
439
  raise ArgumentError, "hard_close timeout must be finite" unless period
436
440
 
437
- finish_close(timeout: period, term_timeout: period, force: true)
441
+ close_resources(timeout: period, term_timeout: period, force: true)
438
442
  end
439
443
 
440
444
  # 通用生命周期清理:可先软关闭,ensure 中硬关闭兜底;正常完成返回 nil。
@@ -445,20 +449,29 @@ class Expect
445
449
  failed = true
446
450
  raise
447
451
  ensure
448
- cleanup_preserving_failure(failed) { hard_close }
452
+ cleanup(failed:) { hard_close }
449
453
  end
450
454
 
451
455
  private
452
456
 
457
+ # 账本发布前只按局部所有权清理;发布后沿用完整关闭流程,避免两套生命周期状态。
458
+ def cleanup_session(reader, writer:, own:, slave: nil, graceful: false)
459
+ if @resources
460
+ close(graceful: graceful)
461
+ elsif own
462
+ SessionResources.close_handles(reader, writer, slave)
463
+ end
464
+ end
465
+
453
466
  # 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
454
- def cleanup_preserving_failure(failed)
467
+ def cleanup(failed:)
455
468
  yield
456
469
  rescue IOError, SystemCallError
457
470
  raise unless failed
458
471
  end
459
472
 
460
473
  # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
461
- def finish_close(timeout:, term_timeout:, force:)
474
+ def close_resources(timeout:, term_timeout:, force:)
462
475
  failure = nil
463
476
  # 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
464
477
  cleanup = lambda do |&step|
@@ -480,7 +493,7 @@ class Expect
480
493
  @relay_outputs&.clear
481
494
  @relay_history&.clear
482
495
  @relay_callback = nil
483
- status = finish_child_close(timeout: timeout, term_timeout: term_timeout, force: force)
496
+ status = close_child(timeout: timeout, term_timeout: term_timeout, force: force)
484
497
  completed = true
485
498
  status
486
499
  ensure
@@ -494,17 +507,41 @@ class Expect
494
507
  end
495
508
 
496
509
  # 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
497
- def finish_child_close(timeout:, term_timeout:, force:)
510
+ def close_child(timeout:, term_timeout:, force:)
498
511
  return process_status unless @resources.owner == Process.pid && pid
499
- return process_status if wait(timeout: timeout)
500
512
 
501
- signal_child("TERM")
502
- return process_status if wait(timeout: term_timeout)
513
+ status = wait(timeout: timeout)
514
+ return status if status || !pid
503
515
 
516
+ status = wait_for_child(term_timeout, signal: "TERM")
517
+ return status if status || !pid
504
518
  return unless force
505
519
 
506
- signal_child("KILL")
507
- wait(timeout: 1)
520
+ wait_for_child(1, signal: "KILL")
521
+ end
522
+
523
+ # 每阶段只计算一次期限;回收或信号被中断后仍沿用剩余预算,零预算也先做一次尝试。
524
+ def wait_for_child(period, signal: nil)
525
+ deadline = period && (Expect.monotonic + period)
526
+ loop do
527
+ status = process_status
528
+ return status if status || !pid || @resources.owner != Process.pid
529
+
530
+ begin
531
+ signal_child(signal) if signal
532
+ signal = nil
533
+ rescue Errno::EINTR
534
+ # 下轮先回收再重试信号,避免在无限等待或持续中断时忙等。
535
+ nil
536
+ end
537
+ # ESRCH 后可能已完成回收;即使预算耗尽,也要返回刚获得的状态。
538
+ return process_status unless pid
539
+
540
+ remaining = deadline && (deadline - Expect.monotonic)
541
+ return nil if remaining && remaining <= 0
542
+
543
+ sleep(remaining ? [0.01, remaining].min : 0.01)
544
+ end
508
545
  end
509
546
 
510
547
  # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
@@ -580,13 +617,13 @@ class Expect
580
617
  # 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
581
618
  raise unless @pty
582
619
 
583
- return finish_read
620
+ return mark_eof
584
621
  rescue EOFError
585
- return finish_read
622
+ return mark_eof
586
623
  end
587
624
  return nil if data == :wait_readable
588
625
 
589
- return finish_read if data.nil?
626
+ return mark_eof if data.nil?
590
627
 
591
628
  data = data.b
592
629
  buffer << data
@@ -599,7 +636,7 @@ class Expect
599
636
  data
600
637
  end
601
638
 
602
- def finish_read
639
+ def mark_eof
603
640
  @eof = true
604
641
  flush_log
605
642
  flush_diagnostics(:received)
@@ -619,7 +656,7 @@ class Expect
619
656
 
620
657
  # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
621
658
  def signal_child(signal)
622
- return unless alive? && @resources.owner == Process.pid
659
+ return unless pid && @resources.owner == Process.pid
623
660
 
624
661
  Process.kill(signal, pid)
625
662
  rescue Errno::ESRCH
data/script/release.rb CHANGED
@@ -26,7 +26,7 @@ class Release
26
26
  end
27
27
 
28
28
  def run
29
- notes = self.class.release_notes(File.read("CHANGELOG.md"), @version)
29
+ notes = self.class.read_notes("CHANGELOG.md", @version)
30
30
  unless @dry_run || capture("git", "status", "--porcelain").empty?
31
31
  raise "Commit all source changes before publishing"
32
32
  end
@@ -46,7 +46,7 @@ class Release
46
46
  @checksum_file = File.join(directory, "SHA256SUMS")
47
47
  @notes_file = File.join(directory, "release-notes.md")
48
48
  File.write(@checksum_file, "#{@sha256} #{File.basename(@artifact)}\n")
49
- File.write(@notes_file, "#{notes}\n")
49
+ File.write(@notes_file, "#{notes}\n", encoding: "UTF-8")
50
50
  puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
51
51
  return puts "Dry run complete: #{@artifact}" if @dry_run
52
52
 
@@ -61,6 +61,14 @@ class Release
61
61
  end
62
62
 
63
63
  # 有未归档的变更时拒绝发布,避免把新接口放进旧版本或遗漏发布说明。
64
+ def self.read_notes(path, version)
65
+ changelog = File.read(path, encoding: "UTF-8")
66
+ raise "Invalid UTF-8 in #{path}" unless changelog.valid_encoding?
67
+
68
+ release_notes(changelog, version)
69
+ end
70
+
71
+ # 纯章节解析与文件编码边界分离,本地入口和工作流共用 read_notes。
64
72
  def self.release_notes(changelog, version)
65
73
  raise "Use a stable X.Y.Z version" unless /\A\d+\.\d+\.\d+\z/.match?(version)
66
74
 
@@ -0,0 +1,92 @@
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
@@ -8,9 +8,11 @@ SSH_HOST=127.0.0.1 SSH_USER=crate ruby test/integration/ssh_scripts.rb
8
8
  SSH_HOST=127.0.0.1 SSH_USER=crate bundle exec rake test:ssh
9
9
  ```
10
10
 
11
- 脚本隐藏读取密码;也支持由运行环境提供 `EXPECT_PASSWORD`。密码不会作为 SSH 命令行参数传递,日志在认证完成后才开启。默认自动测试不连接 SSH、不读取密码。
11
+ 脚本隐藏读取密码;也支持由运行环境提供 `EXPECT_PASSWORD`。密码不会作为 SSH 命令行参数传递,日志在认证完成后才开启。默认自动测试不连接
12
+ SSH、不读取密码。
12
13
 
13
- 支持 `SSH_PORT`(默认 22)、`EXPECT_LOG_DIR`(日志根目录)、`SSH_KNOWN_HOSTS`。连接非本机主机时,必须提供已有的受信任 known_hosts,例如:
14
+ 支持 `SSH_PORT`(默认 22)、`EXPECT_LOG_DIR`(日志根目录)、`SSH_KNOWN_HOSTS`。连接非本机主机时,必须提供已有的受信任
15
+ known_hosts,例如:
14
16
 
15
17
  ```sh
16
18
  SSH_HOST=192.0.2.10 SSH_USER=operator SSH_PORT=22 \
@@ -18,23 +20,27 @@ SSH_HOST=192.0.2.10 SSH_USER=operator SSH_PORT=22 \
18
20
  EXPECT_LOG_DIR=tmp/device-check ruby test/integration/ssh_scripts.rb
19
21
  ```
20
22
 
21
- 这是 POSIX shell 脚本测试;厂商专用 CLI(如交换机的 `show` 命令、分页提示符)需要对应设备的命令和预期输出,不能直接使用这些 shell fixtures。
23
+ 这是 POSIX shell 脚本测试;厂商专用 CLI(如交换机的 `show` 命令、分页提示符)需要对应设备的命令和预期输出,不能直接使用这些
24
+ shell fixtures。
22
25
 
23
26
  ## 同一个登录会话执行的脚本
24
27
 
25
- | 脚本 | 验证内容 | 预期退出码 |
26
- | --- | --- | --- |
27
- | `01_identity.sh` | 当前用户、远端 PTY、完成标记 | 0 |
28
- | `02_output.sh` | stdout、stderr、中文和输出顺序 | 0 |
29
- | `03_delayed.sh` | 分三次延迟输出,确保日志完整 | 0 |
30
- | `04_failure.sh` | 明确输出错误并退出 | 7(预期失败) |
31
- | `05_recovery.sh` | 上个脚本失败后继续执行,计算结果为 42 | 0 |
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 |
32
35
 
33
- fixtures 位于 `test/fixtures/ssh_scripts/`,都不修改目标文件或设备配置。文件内容通过 SSH 的交互 shell 传给独立 `/bin/sh -c` 执行,不需要上传文件。每个脚本都有随机开始/结束标记,结束标记携带真实 `$?`;脚本输出和退出码都必须符合预期才算通过。
36
+ fixtures 位于 `test/fixtures/ssh_scripts/`,都不修改目标文件或设备配置。文件内容通过 SSH 的交互 shell 传给独立
37
+ `/bin/sh -c` 执行,不需要上传文件。每个脚本都有随机开始/结束标记,结束标记携带真实 `$?`;脚本输出和退出码都必须符合预期才算通过。
34
38
 
35
- 测试关闭终端回显和 shell 行编辑,使用单引号传输完整脚本,保留中文 UTF-8 和引号,避免 C locale 的交互式 readline 把高位字节当快捷键执行。末尾仍等待 shell 提示符,确认可以继续执行下一项。
39
+ 测试关闭终端回显和 shell 行编辑,使用单引号传输完整脚本,保留中文 UTF-8 和引号,避免 C locale 的交互式 readline
40
+ 把高位字节当快捷键执行。末尾仍等待 shell 提示符,确认可以继续执行下一项。
36
41
 
37
- 另外执行两条日志控制命令:关闭日志后输出 `UNLOGGED_OUTPUT`,再以默认追加模式开启日志并输出 `APPEND_OK`。最后发送延迟输出及退出命令,只调用 `soft_close`,确认 `SESSION_FINAL_TAIL` 确实在关闭过程中写入日志。
42
+ 另外执行两条日志控制命令:关闭日志后输出 `UNLOGGED_OUTPUT`,再以默认追加模式开启日志并输出 `APPEND_OK`。最后发送延迟输出及退出命令,只调用
43
+ `soft_close`,确认 `SESSION_FINAL_TAIL` 确实在关闭过程中写入日志。
38
44
 
39
45
  ## 检查和输出文件
40
46
 
@@ -43,11 +49,14 @@ fixtures 位于 `test/fixtures/ssh_scripts/`,都不修改目标文件或设备
43
49
  - `session.log`:收到的原始数据,以及手工添加的 `[SEND] 脚本名 sha256=...` 和 `[EXIT] 脚本名 status=...`。
44
50
  - `report.json`:主机、用户、远端 TTY、每项脚本输出和退出码、校验结果、SSH 退出码、日志字节数及 SHA-256。
45
51
 
46
- 只有认证后的输出进入日志。Expect 自动记录接收字节,发送的脚本名称和摘要由 `write_log` 明确写入,便于审核执行了哪个 fixture;fixture 文件中的原文可以用报告内的 SHA-256 核对。
52
+ 只有认证后的输出进入日志。Expect 自动记录接收字节,发送的脚本名称和摘要由 `write_log` 明确写入,便于审核执行了哪个
53
+ fixture;fixture 文件中的原文可以用报告内的 SHA-256 核对。
47
54
 
48
- 共 11 项日志/流程检查:完整输出、执行顺序、即时 flush、stdout/stderr、UTF-8、非零退出后恢复、覆盖模式、暂停记录、追加模式、关闭前尾部输出、密码不出现在日志中。另校验日志条目没有重复,日志句柄在停止记录和关闭会话后正确关闭。
55
+ 共 11 项日志/流程检查:完整输出、执行顺序、即时
56
+ flush、stdout/stderr、UTF-8、非零退出后恢复、覆盖模式、暂停记录、追加模式、关闭前尾部输出、密码不出现在日志中。另校验日志条目没有重复,日志句柄在停止记录和关闭会话后正确关闭。
49
57
 
50
- 成功返回进程退出码 0。认证失败、超时、脚本输出错误、意外退出码或日志校验失败均返回非零,并在有报告目录时留下 `passed: false` 的报告。不用仅看到终端输出就判断成功;请查看最终 PASS 和 `report.json` 的 `passed`。
58
+ 成功返回进程退出码 0。认证失败、超时、脚本输出错误、意外退出码或日志校验失败均返回非零,并在有报告目录时留下 `passed: false`
59
+ 的报告。不用仅看到终端输出就判断成功;请查看最终 PASS 和 `report.json` 的 `passed`。
51
60
 
52
61
  ## 无需 SSH 的回归测试
53
62
 
@@ -56,7 +65,8 @@ bundle exec ruby -Itest test/script_logging_test.rb
56
65
  bundle exec rake test
57
66
  ```
58
67
 
59
- `test/script_logging_test.rb` 使用真实本地 PTY 执行相同 fixtures,另外验证回调日志、借用 File 日志、错误退出/错误输出的反例、引号和 shell 元字符、日志错误上抛,以及超时后继续收集日志。它和 SSH 脚本共享测试 helper,不依赖远端账户。
68
+ `test/script_logging_test.rb` 使用真实本地 PTY 执行相同 fixtures,另外验证回调日志、借用 File 日志、错误退出/错误输出的反例、引号和
69
+ shell 元字符、日志错误上抛,以及超时后继续收集日志。它和 SSH 脚本共享测试 helper,不依赖远端账户。
60
70
 
61
71
  ## SSH interact 人工和自动验证
62
72
 
@@ -67,13 +77,18 @@ ruby examples/ssh_interact.rb --auto
67
77
  bundle exec rake test:ssh_interact
68
78
  ```
69
79
 
70
- 使用上述同一套 SSH 环境变量和隐藏密码输入。人工模式显示 `EXPECT_SCRIPT_PROMPT>`;输入命令有回显,Ctrl-C 传给远端前台任务。在命令结束、回到 shell 提示符后按 Ctrl-],会关闭远端回显,执行 `MANUAL_AUTOMATION_RESUMED` 检查,再正常退出 SSH。也支持直接输入 `exit`:成功退出记为 `completion: remote_exit`,此路径不执行返回 expect 的检查。非零 SSH 退出仍判为失败。
80
+ 使用上述同一套 SSH 环境变量和隐藏密码输入。人工模式显示 `EXPECT_SCRIPT_PROMPT>`;输入命令有回显,Ctrl-C 传给远端前台任务。在命令结束、回到
81
+ shell 提示符后按 Ctrl-],会关闭远端回显,执行 `MANUAL_AUTOMATION_RESUMED` 检查,再正常退出 SSH。也支持直接输入 `exit`:成功退出记为
82
+ `completion: remote_exit`,此路径不执行返回 expect 的检查。非零 SSH 退出仍判为失败。
71
83
 
72
- `--auto` 创建真实本地 PTY,验证两次 `interact` 接管、当前用户和远端 TTY、stdout/stderr/中文、Ctrl-C、Ctrl-]、转义后的本地尾部不进入 SSH、恢复 `expect` 及终端与监听设置。4 项命令输出和 13 项交互/日志检查全部通过后才返回 0。
84
+ `--auto` 创建真实本地 PTY,验证两次 `interact` 接管、当前用户和远端 TTY、stdout/stderr/中文、Ctrl-C、Ctrl-]、转义后的本地尾部不进入
85
+ SSH、恢复 `expect` 及终端与监听设置。4 项命令输出和 13 项交互/日志检查全部通过后才返回 0。
73
86
 
74
- 日志在认证之后开启;每次在 `tmp/ssh-interact/` 新建 0700 目录,保存 0600 的 `session.log` 和 `report.json`。报告包含检查项、命令输出、SSH 退出码及日志摘要。自动模式也校验日志无重复、退出尾部不丢失、密码不出现在日志中。
87
+ 日志在认证之后开启;每次在 `tmp/ssh-interact/` 新建 0700 目录,保存 0600 的 `session.log` 和 `report.json`
88
+ 。报告包含检查项、命令输出、SSH 退出码及日志摘要。自动模式也校验日志无重复、退出尾部不丢失、密码不出现在日志中。
75
89
 
76
- 无需 SSH 的回归位于 `test/interact_test.rb`,覆盖可见输入、Ctrl-C、两次接管、返回自动命令、超时、两端 EOF、回调异常和输出 IO 故障时的终端恢复。`test/script_logging_test.rb` 另验证 CRLF 输出边界,包括空输出、末尾有/无换行和多个换行。
90
+ 无需 SSH 的回归位于 `test/interact_test.rb`,覆盖可见输入、Ctrl-C、两次接管、返回自动命令、超时、两端 EOF、回调异常和输出 IO
91
+ 故障时的终端恢复。`test/script_logging_test.rb` 另验证 CRLF 输出边界,包括空输出、末尾有/无换行和多个换行。
77
92
 
78
93
  ## `ssh_auto.rb` 环境检查
79
94
 
@@ -85,6 +100,10 @@ ruby examples/ssh_auto.rb --no-interact
85
100
  bundle exec rake test:ssh_auto
86
101
  ```
87
102
 
88
- 该示例自动登录 `SSH_HOST`(默认 `127.0.0.1`),用本库的 `write` / `expect` 顺序执行文件顶部的 `COMMANDS`,显示真实输出并检查退出码。默认命令为 `id`、`uname -srm`、`sw_vers`、`df -h /` 和 `uptime`,不修改系统设置;可直接修改数组来下发其他 macOS/POSIX shell 命令。它不依赖 `test/support`,`ScriptProbe.check` 只供测试脚本使用。
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` 只供测试脚本使用。
89
106
 
90
- 执行成功后默认进入 `interact`,输入有回显,Ctrl-C 发给远端前台任务;`exit` 或 Ctrl-] 结束连接。使用 `--no-interact` 则执行完退出。`EXPECT_LOG_DIR` 可指定日志目录,默认 `tmp/ssh-auto/`;每次创建独立 0600 日志,记录命令和远端输出,认证前关闭日志。此使用示例不生成 JSON 测试报告,详细日志断言仍由 `ssh_scripts.rb` 和 `ssh_interact.rb --auto` 验证。
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` 验证。