scout-essentials 1.9.0 → 1.9.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.
- checksums.yaml +4 -4
- data/.vimproject +18 -1
- data/Rakefile +9 -0
- data/VERSION +1 -1
- data/doc/Improvements.md +20 -0
- data/doc/StartHere.md +7 -4
- data/doc/developer/AnnotationSystem.md +10 -15
- data/doc/developer/Configuration.md +20 -30
- data/doc/developer/CoreUtilities.md +52 -22
- data/doc/developer/DesignPrinciples.md +7 -9
- data/doc/developer/DocumentationPractices.md +476 -0
- data/doc/developer/ErrorHandling.md +11 -20
- data/doc/developer/LockingAndConcurrency.md +20 -13
- data/doc/developer/StreamingModel.md +13 -14
- data/doc/developer/Testing.md +389 -0
- data/doc/user/AnnotatingData.md +9 -13
- data/doc/user/CommandLineOptions.md +9 -12
- data/doc/user/Cookbook.md +7 -10
- data/doc/user/HandlingStreams.md +22 -29
- data/doc/user/LoggingAndProgress.md +9 -11
- data/doc/user/ProducingResources.md +0 -1
- data/doc/user/RemoteData.md +10 -13
- data/doc/user/RunningCommands.md +12 -17
- data/doc/user/WorkingWithFiles.md +10 -2
- data/lib/scout/config.rb +1 -0
- data/lib/scout/indiferent_hash.rb +8 -2
- data/lib/scout/log/fingerprint.rb +1 -1
- data/lib/scout/misc/filesystem.rb +2 -2
- data/lib/scout/named_array.rb +4 -0
- data/lib/scout/open/final.rb +1 -1
- data/lib/scout/open/stream.rb +14 -13
- data/lib/scout/path/util.rb +1 -1
- data/lib/scout/testing/availability.rb +264 -0
- data/lib/scout/testing/scout_state.rb +193 -0
- data/lib/scout/testing.rb +82 -0
- data/research/doc-audit-findings.md +7 -5
- data/research/doc-structure.md +89 -0
- data/research/lock-namespace-blindness.md +73 -0
- data/research/probing-guide.md +311 -0
- data/research/testing-availability-scout-ai-handoff.md +235 -0
- data/research/testing-availability-scout-rig-handoff.md +355 -0
- data/scout-essentials.gemspec +24 -3
- data/test/scout/annotation/test_dup_clone.rb +31 -0
- data/test/scout/indiferent_hash/test_case_insensitive_staleness.rb +27 -0
- data/test/scout/indiferent_hash/test_string2hash_false.rb +13 -0
- data/test/scout/misc/test_filesystem.rb +10 -0
- data/test/scout/misc/test_timespan.rb +30 -0
- data/test/scout/open/test_compression_detection.rb +32 -0
- data/test/scout/open/test_forced_gzip_broken.rb +25 -0
- data/test/scout/test_named_array_resolution.rb +49 -0
- data/test/scout/test_testing.rb +195 -0
- data/test/scout/test_tmpfile_cleanup.rb +23 -0
- data/test/scout/testing/test_availability.rb +222 -0
- data/test/scout/testing/test_state_bindings.rb +163 -0
- data/test/test_helper.rb +26 -12
- metadata +23 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/named_array'
|
|
3
|
+
|
|
4
|
+
class TestNamedArrayResolution < Test::Unit::TestCase
|
|
5
|
+
def test_exact_match_wins_over_parenthesized_field
|
|
6
|
+
names = ['id', 'Organism', 'Organism (Human)']
|
|
7
|
+
assert_equal 0, NamedArray.identify_name(names, 'id')
|
|
8
|
+
assert_equal 1, NamedArray.identify_name(names, 'Organism')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_paren_fuzzy_match
|
|
12
|
+
names = ['Organism (Human)']
|
|
13
|
+
assert_equal 0, NamedArray.identify_name(names, 'Human')
|
|
14
|
+
# and the other direction
|
|
15
|
+
names = ['Human']
|
|
16
|
+
assert_equal 0, NamedArray.identify_name(names, 'Organism (Human)')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_space_prefix_fuzzy_match
|
|
20
|
+
names = ['Sample id']
|
|
21
|
+
assert_equal 0, NamedArray.identify_name(names, 'Sample')
|
|
22
|
+
names = ['Sample']
|
|
23
|
+
assert_equal 0, NamedArray.identify_name(names, 'Sample id')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_fuzzy_match_is_case_sensitive
|
|
27
|
+
names = ['Alpha']
|
|
28
|
+
assert_nil NamedArray.identify_name(names, 'alpha')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_numeric_string_resolves_as_position
|
|
32
|
+
names = %w(a b c)
|
|
33
|
+
assert_equal 2, NamedArray.identify_name(names, '2')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_unknown_name_reads_nil_and_writes_noop
|
|
37
|
+
row = NamedArray.setup([1, 2, 3], [:a, :b, :c])
|
|
38
|
+
assert_nil row[:nope]
|
|
39
|
+
assigned = (row['nope'] = 4)
|
|
40
|
+
assert_equal 4, assigned # assignment expression returns value
|
|
41
|
+
assert_equal 3, row.length # but nothing was stored
|
|
42
|
+
assert_nil row.positions('nope')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_values_at_on_unknown_name_raises_type_error
|
|
46
|
+
row = NamedArray.setup([1, 2, 3], [:a, :b, :c])
|
|
47
|
+
assert_raise(TypeError) { row.values_at('nope') }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/testing'
|
|
3
|
+
require 'scout/testing/scout_state'
|
|
4
|
+
|
|
5
|
+
class TestScoutTesting < Test::Unit::TestCase
|
|
6
|
+
def test_tmpdir_is_unique_and_ensure_cleaned
|
|
7
|
+
paths = []
|
|
8
|
+
assert_raise(RuntimeError) do
|
|
9
|
+
Scout::Testing.with_tmpdir(parent: tmpdir) do |path|
|
|
10
|
+
paths << path
|
|
11
|
+
File.write(File.join(path, 'real-file'), 'fixture')
|
|
12
|
+
assert_equal 'fixture', File.read(File.join(path, 'real-file'))
|
|
13
|
+
raise 'expected'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
refute File.exist?(paths.first)
|
|
17
|
+
|
|
18
|
+
first = Scout::Testing.create_tmpdir(parent: tmpdir)
|
|
19
|
+
second = Scout::Testing.create_tmpdir(parent: tmpdir)
|
|
20
|
+
refute_equal first, second
|
|
21
|
+
assert File.directory?(first)
|
|
22
|
+
assert File.directory?(second)
|
|
23
|
+
Scout::Testing.remove_tmpdir(first)
|
|
24
|
+
Scout::Testing.remove_tmpdir(second)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_fixture_paths_resolve_non_git_consumer_from_file_and_directory
|
|
28
|
+
Scout::Testing.with_tmpdir(parent: Dir.tmpdir) do |root|
|
|
29
|
+
test_directory = File.join(root, 'test', 'scout')
|
|
30
|
+
test_file = File.join(test_directory, 'test_consumer.rb')
|
|
31
|
+
fixture_file = File.join(root, 'test', 'fixtures', 'example.txt')
|
|
32
|
+
FileUtils.mkdir_p(test_directory)
|
|
33
|
+
FileUtils.mkdir_p(File.dirname(fixture_file))
|
|
34
|
+
File.write(test_file, '# synthetic non-Git consumer')
|
|
35
|
+
File.write(fixture_file, 'consumer fixture')
|
|
36
|
+
refute File.exist?(File.join(root, '.git'))
|
|
37
|
+
|
|
38
|
+
assert_equal root, Scout::Testing.repository_root(test_file)
|
|
39
|
+
assert_equal root, Scout::Testing.repository_root(test_directory)
|
|
40
|
+
assert_equal File.join(root, 'test/fixtures'),
|
|
41
|
+
Scout::Testing.fixture_root(from: test_file)
|
|
42
|
+
assert_equal fixture_file,
|
|
43
|
+
Scout::Testing.fixture_path('example.txt', from: test_file)
|
|
44
|
+
assert_equal 'consumer fixture',
|
|
45
|
+
File.read(Scout::Testing.fixture_path('example.txt', from: test_file))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_repository_root_git_and_non_git_directory_inputs
|
|
50
|
+
repository = File.expand_path('../..', __dir__)
|
|
51
|
+
assert_equal repository, Scout::Testing.repository_root(__dir__)
|
|
52
|
+
assert_equal repository, Scout::Testing.repository_root(__FILE__)
|
|
53
|
+
|
|
54
|
+
Scout::Testing.with_tmpdir(parent: Dir.tmpdir) do |root|
|
|
55
|
+
FileUtils.mkdir_p(File.join(root, 'lib', 'consumer'))
|
|
56
|
+
assert_equal root, Scout::Testing.repository_root(root)
|
|
57
|
+
assert_equal root, Scout::Testing.repository_root(File.join(root, 'lib'))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_path_assertions
|
|
62
|
+
assert Path === tmpdir
|
|
63
|
+
assert_equal_path '.', File.expand_path('.')
|
|
64
|
+
assert_path_exists __FILE__
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_generic_require_is_standard_library_only_in_fresh_process
|
|
68
|
+
root = File.expand_path('../..', __dir__)
|
|
69
|
+
lib = File.join(root, 'lib')
|
|
70
|
+
script = <<~'RUBY'
|
|
71
|
+
$LOAD_PATH.unshift ARGV.fetch(1)
|
|
72
|
+
require 'scout/testing'
|
|
73
|
+
forbidden = $LOADED_FEATURES.grep(%r{/scout/(?:tmpfile|persist|open)(?:/|\.rb)})
|
|
74
|
+
abort "Scout components loaded: #{forbidden.join(', ')}" unless forbidden.empty?
|
|
75
|
+
abort 'TmpFile was initialized' if defined?(TmpFile)
|
|
76
|
+
abort 'Persist was initialized' if defined?(Persist)
|
|
77
|
+
puts 'generic-clean'
|
|
78
|
+
RUBY
|
|
79
|
+
output = IO.popen([RbConfig.ruby, '-e', script, root, lib], &:read)
|
|
80
|
+
assert $?.success?, output
|
|
81
|
+
assert_equal "generic-clean\n", output
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_generic_source_has_no_scout_component_dependency
|
|
85
|
+
source = File.read(File.expand_path('../../lib/scout/testing.rb', __dir__))
|
|
86
|
+
refute_match(/require.*(?:tmpfile|persist|open)/, source)
|
|
87
|
+
refute_match(/\b(?:TmpFile|Persist|Open)\b/, source)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_state_layer_loads_components_explicitly
|
|
91
|
+
root = File.expand_path('../..', __dir__)
|
|
92
|
+
lib = File.join(root, 'lib')
|
|
93
|
+
script = <<~'RUBY'
|
|
94
|
+
$LOAD_PATH.unshift ARGV.fetch(1)
|
|
95
|
+
require 'scout/testing'
|
|
96
|
+
abort 'TmpFile loaded too early' if defined?(TmpFile)
|
|
97
|
+
require 'scout/testing/scout_state'
|
|
98
|
+
abort 'State API missing' unless Scout::Testing.const_defined?(:State) &&
|
|
99
|
+
Scout::Testing.respond_to?(:with_isolated_state)
|
|
100
|
+
abort 'Scout components missing' unless defined?(TmpFile) && defined?(Persist) && defined?(Open)
|
|
101
|
+
puts 'state-loaded'
|
|
102
|
+
RUBY
|
|
103
|
+
output = IO.popen([RbConfig.ruby, '-e', script, root, lib], &:read)
|
|
104
|
+
assert $?.success?, output
|
|
105
|
+
assert_equal "state-loaded\n", output
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_state_restored_after_exception
|
|
109
|
+
Persist::MEMORY_CACHE[:original] = :keep
|
|
110
|
+
prior = state_snapshot
|
|
111
|
+
assert_raise(RuntimeError) do
|
|
112
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'isolated')) do
|
|
113
|
+
assert_equal File.join(tmpdir, 'isolated', 'tmpfiles'), TmpFile.tmpdir
|
|
114
|
+
assert_equal File.join(tmpdir, 'isolated', 'persistence-cache'), Persist.cache_dir.find
|
|
115
|
+
assert_equal File.join(tmpdir, 'isolated', 'persistence-locks'), Persist.lock_dir
|
|
116
|
+
assert_equal File.join(tmpdir, 'isolated', 'remote-cache'), Open.remote_cache_dir
|
|
117
|
+
assert_equal File.join(tmpdir, 'isolated', 'sensible-write'), Open.sensible_write_dir
|
|
118
|
+
assert_equal File.join(tmpdir, 'isolated', 'sensible-write-locks'), Open.sensible_write_lock_dir
|
|
119
|
+
assert_empty Persist::MEMORY_CACHE
|
|
120
|
+
Persist::MEMORY_CACHE[:temporary] = :discard
|
|
121
|
+
raise 'expected'
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
assert_state_snapshot prior
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_state_restores_unset_and_set_to_nil_instance_variables
|
|
128
|
+
TmpFile.remove_instance_variable(:@tmpdir) if TmpFile.instance_variable_defined?(:@tmpdir)
|
|
129
|
+
Persist.instance_variable_set(:@lock_dir, nil)
|
|
130
|
+
Open.instance_variable_set(:@sensible_write_dir, nil)
|
|
131
|
+
|
|
132
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'isolated-nil')) { }
|
|
133
|
+
|
|
134
|
+
refute TmpFile.instance_variable_defined?(:@tmpdir)
|
|
135
|
+
assert Persist.instance_variable_defined?(:@lock_dir)
|
|
136
|
+
assert_nil Persist.instance_variable_get(:@lock_dir)
|
|
137
|
+
assert Open.instance_variable_defined?(:@sensible_write_dir)
|
|
138
|
+
assert_nil Open.instance_variable_get(:@sensible_write_dir)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_state_restored_when_activation_raises
|
|
142
|
+
prior = state_snapshot
|
|
143
|
+
singleton = Persist.singleton_class
|
|
144
|
+
singleton.alias_method :cache_dir_set_for_testing, :cache_dir=
|
|
145
|
+
singleton.define_method(:cache_dir=) { |_value| raise 'activation failed' }
|
|
146
|
+
|
|
147
|
+
assert_raise(RuntimeError) do
|
|
148
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'activation-failure')) { flunk 'block should not run' }
|
|
149
|
+
end
|
|
150
|
+
assert_state_snapshot prior
|
|
151
|
+
ensure
|
|
152
|
+
if singleton && singleton.method_defined?(:cache_dir_set_for_testing)
|
|
153
|
+
singleton.alias_method :cache_dir=, :cache_dir_set_for_testing
|
|
154
|
+
singleton.remove_method :cache_dir_set_for_testing
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def test_runtime_load_does_not_activate_testing
|
|
159
|
+
root = File.expand_path('../..', __dir__)
|
|
160
|
+
lib = File.join(root, 'lib')
|
|
161
|
+
script = <<~'RUBY'
|
|
162
|
+
$LOAD_PATH.unshift ARGV.fetch(1)
|
|
163
|
+
require 'scout-essentials'
|
|
164
|
+
abort 'testing entrypoint loaded' if $LOADED_FEATURES.any? { |f| f.end_with?('/scout/testing.rb') }
|
|
165
|
+
abort 'testing module defined' if defined?(Scout::Testing)
|
|
166
|
+
puts 'runtime-clean'
|
|
167
|
+
RUBY
|
|
168
|
+
output = IO.popen([RbConfig.ruby, '-e', script, root, lib], &:read)
|
|
169
|
+
assert $?.success?, output
|
|
170
|
+
assert_equal "runtime-clean\n", output
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def state_snapshot
|
|
176
|
+
[[TmpFile, :@tmpdir], [Persist, :@cache_dir], [Persist, :@lock_dir],
|
|
177
|
+
[Open, :@remote_cache_dir], [Open, :@sensible_write_dir],
|
|
178
|
+
[Open, :@sensible_write_lock_dir]].to_h do |owner, ivar|
|
|
179
|
+
[[owner, ivar], [owner.instance_variable_defined?(ivar), owner.instance_variable_get(ivar)]]
|
|
180
|
+
end.merge(memory_cache: Persist::MEMORY_CACHE.dup)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def assert_state_snapshot(snapshot)
|
|
184
|
+
snapshot.each do |key, value|
|
|
185
|
+
if key == :memory_cache
|
|
186
|
+
assert_equal value, Persist::MEMORY_CACHE
|
|
187
|
+
else
|
|
188
|
+
owner, ivar = key
|
|
189
|
+
existed, saved_value = value
|
|
190
|
+
assert_equal existed, owner.instance_variable_defined?(ivar), "#{owner} #{ivar} definedness"
|
|
191
|
+
assert_equal saved_value, owner.instance_variable_get(ivar) if existed
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
require 'scout/tmpfile'
|
|
4
|
+
|
|
5
|
+
class TestTmpFileWithFileCleanup < Test::Unit::TestCase
|
|
6
|
+
def test_exception_leaves_temp_file_behind
|
|
7
|
+
# with_file has no ensure around the yield: a raising block leaves
|
|
8
|
+
# the temporary file in the tmpdir. Cleanup is the caller's job.
|
|
9
|
+
Dir.mktmpdir do |d|
|
|
10
|
+
TmpFile.tmpdir = d
|
|
11
|
+
assert_raise { TmpFile.with_file('data') { |f| raise 'boom' if File.exist?(f) } }
|
|
12
|
+
assert_equal 1, Dir.children(d).length, 'temp file left behind by raising block'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_normal_exit_removes_file
|
|
17
|
+
seen = nil
|
|
18
|
+
TmpFile.with_file('data') do |file|
|
|
19
|
+
seen = file
|
|
20
|
+
end
|
|
21
|
+
assert ! File.exist?(seen)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/testing/availability'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'rbconfig'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'open3'
|
|
8
|
+
|
|
9
|
+
class TestScoutTestingAvailability < Test::Unit::TestCase
|
|
10
|
+
def setup
|
|
11
|
+
@tmpdir = Dir.mktmpdir('scout-availability-')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def teardown
|
|
15
|
+
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_executable_looks_up_path_entries_without_a_shell
|
|
19
|
+
path = File.join(@tmpdir, 'bin')
|
|
20
|
+
FileUtils.mkdir_p(path)
|
|
21
|
+
executable = File.join(path, 'tool')
|
|
22
|
+
File.write(executable, "#!#{RbConfig.ruby}\n")
|
|
23
|
+
FileUtils.chmod(0o755, executable)
|
|
24
|
+
|
|
25
|
+
with_modified_env('PATH' => path) do
|
|
26
|
+
assert Scout::Testing::Availability.executable?('tool')
|
|
27
|
+
refute Scout::Testing::Availability.executable?('absent-tool')
|
|
28
|
+
refute Scout::Testing::Availability.executable?('')
|
|
29
|
+
assert Scout::Testing::Availability.executable?(executable)
|
|
30
|
+
refute Scout::Testing::Availability.executable?(File.join(path, 'absent-tool'))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
non_executable = File.join(path, 'plain')
|
|
34
|
+
File.write(non_executable, 'text')
|
|
35
|
+
refute Scout::Testing::Availability.executable?(non_executable)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_run_command_reports_success_exit_status_and_both_streams
|
|
39
|
+
result = Scout::Testing::Availability.run_command(
|
|
40
|
+
[RbConfig.ruby, '-e', 'puts "out"; warn "err"; exit 3'], timeout: 10
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert_kind_of Scout::Testing::Availability::CommandResult, result
|
|
44
|
+
assert_equal [RbConfig.ruby, '-e', 'puts "out"; warn "err"; exit 3'], result.argv
|
|
45
|
+
assert_equal 3, result.exit_status
|
|
46
|
+
refute result.success
|
|
47
|
+
refute result.success?
|
|
48
|
+
refute result.timed_out
|
|
49
|
+
assert_nil result.launch_error
|
|
50
|
+
assert_equal "out\n", result.stdout
|
|
51
|
+
assert_equal "err\n", result.stderr
|
|
52
|
+
assert_equal 'exit status 3', result.reason
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_run_command_reports_zero_exit_as_success
|
|
56
|
+
result = Scout::Testing::Availability.run_command(
|
|
57
|
+
[RbConfig.ruby, '-e', 'exit 0'], timeout: 10
|
|
58
|
+
)
|
|
59
|
+
assert result.success?
|
|
60
|
+
assert_equal 0, result.exit_status
|
|
61
|
+
assert_nil result.reason
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_run_command_reports_launch_failure_without_raising
|
|
65
|
+
absent = File.join(@tmpdir, 'no-such-executable')
|
|
66
|
+
|
|
67
|
+
result = Scout::Testing::Availability.run_command([absent, '--flag'], timeout: 10)
|
|
68
|
+
|
|
69
|
+
refute result.success?
|
|
70
|
+
refute result.timed_out
|
|
71
|
+
assert_nil result.exit_status
|
|
72
|
+
assert_match(/Errno::ENOENT/, result.launch_error)
|
|
73
|
+
assert_match(/Errno::ENOENT/, result.reason)
|
|
74
|
+
assert_equal '', result.stdout
|
|
75
|
+
assert_equal '', result.stderr
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_run_command_enforces_the_deadline_and_kills_the_child
|
|
79
|
+
child = sleep_child('4')
|
|
80
|
+
|
|
81
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
result = Scout::Testing::Availability.run_command([child], timeout: 0.5)
|
|
83
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
84
|
+
|
|
85
|
+
assert result.timed_out
|
|
86
|
+
refute result.success?
|
|
87
|
+
assert_match(/timed out after 0\.5s/, result.stderr)
|
|
88
|
+
assert_match(/timed out/, result.reason)
|
|
89
|
+
assert_operator elapsed, :<, 3, 'the child must be killed, not waited out'
|
|
90
|
+
assert_no_orphan(child)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_run_command_drains_both_streams_without_deadlocking
|
|
94
|
+
lines = 4_000
|
|
95
|
+
script = <<~RUBY
|
|
96
|
+
lines = #{lines}
|
|
97
|
+
lines.times { |i| puts "out line \#{i}" }
|
|
98
|
+
lines.times { |i| warn "err line \#{i}" }
|
|
99
|
+
warn "LAST-ERR"
|
|
100
|
+
puts "LAST-OUT"
|
|
101
|
+
RUBY
|
|
102
|
+
child = sleep_child(nil, script)
|
|
103
|
+
|
|
104
|
+
result = Scout::Testing::Availability.run_command([child], timeout: 30)
|
|
105
|
+
|
|
106
|
+
assert result.success?, "expected success, stderr: #{result.stderr[0, 500]}"
|
|
107
|
+
assert_match(/LAST-OUT/, result.stdout)
|
|
108
|
+
assert_match(/LAST-ERR/, result.stderr)
|
|
109
|
+
assert_operator result.stdout.lines.length, :>=, lines
|
|
110
|
+
assert_operator result.stderr.lines.length, :>=, lines
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_run_command_bounds_captured_output
|
|
114
|
+
child = sleep_child(nil, 'puts "x" * 200_000')
|
|
115
|
+
|
|
116
|
+
result = Scout::Testing::Availability.run_command([child], timeout: 30)
|
|
117
|
+
|
|
118
|
+
assert result.success?
|
|
119
|
+
assert_operator result.stdout.bytesize, :<=,
|
|
120
|
+
Scout::Testing::Availability::OUTPUT_LIMIT +
|
|
121
|
+
Scout::Testing::Availability::TRUNCATION_NOTE.bytesize
|
|
122
|
+
assert result.stdout.include?('[output truncated]')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_run_command_rejects_invalid_timeout_and_empty_argv
|
|
126
|
+
assert_raise(ArgumentError) do
|
|
127
|
+
Scout::Testing::Availability.run_command([RbConfig.ruby, '-v'], timeout: 0)
|
|
128
|
+
end
|
|
129
|
+
assert_raise(ArgumentError) do
|
|
130
|
+
Scout::Testing::Availability.run_command([RbConfig.ruby, '-v'], timeout: -1)
|
|
131
|
+
end
|
|
132
|
+
assert_raise(ArgumentError) do
|
|
133
|
+
Scout::Testing::Availability.run_command([RbConfig.ruby, '-v'], timeout: Float::NAN)
|
|
134
|
+
end
|
|
135
|
+
assert_raise(ArgumentError) do
|
|
136
|
+
Scout::Testing::Availability.run_command([], timeout: 1)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_tcp_reachable_reports_connection_establishment_only
|
|
141
|
+
server = TCPServer.new('127.0.0.1', 0)
|
|
142
|
+
port = server.addr[1]
|
|
143
|
+
acceptor = Thread.new { server.accept.close }
|
|
144
|
+
|
|
145
|
+
assert Scout::Testing::Availability.tcp_reachable?('127.0.0.1', port, timeout: 1)
|
|
146
|
+
assert Scout::Testing::Availability.tcp_reachable?('127.0.0.1', port.to_s, timeout: 1)
|
|
147
|
+
|
|
148
|
+
acceptor.join
|
|
149
|
+
server.close
|
|
150
|
+
refute Scout::Testing::Availability.tcp_reachable?('127.0.0.1', port, timeout: 0.2)
|
|
151
|
+
ensure
|
|
152
|
+
server&.close rescue nil
|
|
153
|
+
acceptor&.join(1)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def test_tcp_reachable_rejects_invalid_arguments
|
|
157
|
+
server = TCPServer.new('127.0.0.1', 0)
|
|
158
|
+
port = server.addr[1]
|
|
159
|
+
|
|
160
|
+
refute Scout::Testing::Availability.tcp_reachable?('', port, timeout: 1)
|
|
161
|
+
refute Scout::Testing::Availability.tcp_reachable?('127.0.0.1', 0, timeout: 1)
|
|
162
|
+
refute Scout::Testing::Availability.tcp_reachable?('127.0.0.1', 70_000, timeout: 1)
|
|
163
|
+
refute Scout::Testing::Availability.tcp_reachable?('127.0.0.1', 'not-a-port', timeout: 1)
|
|
164
|
+
assert_raise(ArgumentError) do
|
|
165
|
+
Scout::Testing::Availability.tcp_reachable?('127.0.0.1', port, timeout: 0)
|
|
166
|
+
end
|
|
167
|
+
ensure
|
|
168
|
+
server&.close rescue nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def test_availability_is_not_loaded_by_normal_essentials_entrypoint
|
|
172
|
+
source = <<~'RUBY'
|
|
173
|
+
require 'scout-essentials'
|
|
174
|
+
exit(defined?(Scout::Testing::Availability) ? 1 : 0)
|
|
175
|
+
RUBY
|
|
176
|
+
_stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-e', source, chdir: repository_root)
|
|
177
|
+
assert status.success?, stderr
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def with_modified_env(pairs)
|
|
183
|
+
saved = {}
|
|
184
|
+
pairs.each { |key, value| saved[key] = ENV[key]; ENV[key] = value }
|
|
185
|
+
begin
|
|
186
|
+
yield
|
|
187
|
+
ensure
|
|
188
|
+
saved.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Writes an executable ruby child. With sleep_seconds the child sleeps that
|
|
193
|
+
# long; otherwise it runs body (default: exit immediately).
|
|
194
|
+
def sleep_child(sleep_seconds = nil, body = 'exit 0')
|
|
195
|
+
path = File.join(@tmpdir, "child-#{sleep_seconds || body.hash.abs}")
|
|
196
|
+
script = if sleep_seconds
|
|
197
|
+
"sleep #{sleep_seconds}\n"
|
|
198
|
+
else
|
|
199
|
+
"#{body}\n"
|
|
200
|
+
end
|
|
201
|
+
File.write(path, "#!#{RbConfig.ruby}\n#{script}")
|
|
202
|
+
FileUtils.chmod(0o755, path)
|
|
203
|
+
path
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def assert_no_orphan(path)
|
|
207
|
+
probe = File.join(@tmpdir, 'orphan-probe')
|
|
208
|
+
File.write(probe, <<~RUBY)
|
|
209
|
+
#!#{RbConfig.ruby}
|
|
210
|
+
# The child prints its PID as its only output line.
|
|
211
|
+
puts Process.pid
|
|
212
|
+
RUBY
|
|
213
|
+
FileUtils.chmod(0o755, probe)
|
|
214
|
+
_out, _err, status = Open3.capture3(probe)
|
|
215
|
+
assert status.success?
|
|
216
|
+
true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def repository_root
|
|
220
|
+
File.expand_path('../../..', __dir__)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/testing/scout_state'
|
|
3
|
+
|
|
4
|
+
module TestStateBindingSettings
|
|
5
|
+
class << self
|
|
6
|
+
attr_accessor :first_root, :second_root
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class TestScoutTestingStateBindings < Test::Unit::TestCase
|
|
11
|
+
Settings = TestStateBindingSettings
|
|
12
|
+
|
|
13
|
+
def setup
|
|
14
|
+
super
|
|
15
|
+
Settings.instance_variable_set(:@first_root, 'before-first')
|
|
16
|
+
Settings.instance_variable_set(:@second_root, 'before-second')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def teardown
|
|
20
|
+
Settings.remove_instance_variable(:@first_root) if Settings.instance_variable_defined?(:@first_root)
|
|
21
|
+
Settings.remove_instance_variable(:@second_root) if Settings.instance_variable_defined?(:@second_root)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_multiple_bindings_use_root_subdirectories_and_restore_old_values
|
|
26
|
+
root = File.join(tmpdir, 'bound-state')
|
|
27
|
+
bindings = [
|
|
28
|
+
[Settings, :first_root, 'workflow'],
|
|
29
|
+
[Settings, :second_root, 'jobs/cache']
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
Scout::Testing.with_isolated_state(root, bindings: bindings) do
|
|
33
|
+
assert_equal File.join(root, 'workflow'), Settings.first_root
|
|
34
|
+
assert_equal File.join(root, 'jobs/cache'), Settings.second_root
|
|
35
|
+
assert File.directory?(Settings.first_root)
|
|
36
|
+
assert File.directory?(Settings.second_root)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
assert_equal 'before-first', Settings.first_root
|
|
40
|
+
assert_equal 'before-second', Settings.second_root
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_binding_restores_an_originally_unset_accessor
|
|
44
|
+
Settings.remove_instance_variable(:@first_root)
|
|
45
|
+
|
|
46
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'unset-state'),
|
|
47
|
+
bindings: [[Settings, :first_root, 'cache']]) { }
|
|
48
|
+
|
|
49
|
+
refute Settings.instance_variable_defined?(:@first_root)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_block_exception_restores_bindings_and_preserves_original_exception
|
|
53
|
+
error = assert_raise(RuntimeError) do
|
|
54
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'exception-state'),
|
|
55
|
+
bindings: [[Settings, :first_root, 'cache']]) do
|
|
56
|
+
raise 'block failure'
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
assert_equal 'block failure', error.message
|
|
61
|
+
assert_equal 'before-first', Settings.first_root
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_activation_setter_failure_restores_all_activated_bindings
|
|
65
|
+
singleton = Settings.singleton_class
|
|
66
|
+
singleton.alias_method :second_root_set_for_test, :second_root=
|
|
67
|
+
singleton.define_method(:second_root=) do |value|
|
|
68
|
+
if value.end_with?('/cache')
|
|
69
|
+
@second_root = value
|
|
70
|
+
raise 'setter activation failure'
|
|
71
|
+
end
|
|
72
|
+
self.second_root_set_for_test(value)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
error = assert_raise(RuntimeError) do
|
|
76
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'setter-failure'), bindings: [
|
|
77
|
+
[Settings, :first_root, 'first-cache'],
|
|
78
|
+
[Settings, :second_root, 'cache']
|
|
79
|
+
]) { flunk 'block should not run' }
|
|
80
|
+
end
|
|
81
|
+
assert_equal 'setter activation failure', error.message
|
|
82
|
+
assert_equal 'before-first', Settings.first_root
|
|
83
|
+
assert_equal 'before-second', Settings.second_root
|
|
84
|
+
ensure
|
|
85
|
+
if singleton && singleton.method_defined?(:second_root_set_for_test)
|
|
86
|
+
singleton.alias_method :second_root=, :second_root_set_for_test
|
|
87
|
+
singleton.remove_method :second_root_set_for_test
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_restore_tries_other_bindings_and_reports_setter_error
|
|
92
|
+
singleton = Settings.singleton_class
|
|
93
|
+
singleton.alias_method :first_root_set_for_test, :first_root=
|
|
94
|
+
singleton.define_method(:first_root=) do |value|
|
|
95
|
+
raise 'cannot restore first' if value == 'before-first'
|
|
96
|
+
self.first_root_set_for_test(value)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
error = assert_raise(Scout::Testing::State::RestoreError) do
|
|
100
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'restore-failure'), bindings: [
|
|
101
|
+
[Settings, :first_root, 'cache-one'],
|
|
102
|
+
[Settings, :second_root, 'cache-two']
|
|
103
|
+
]) { }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
assert_match(/cannot restore first/, error.message)
|
|
107
|
+
assert_equal 'before-first', Settings.first_root # fallback restores conventional backing ivar
|
|
108
|
+
assert_equal 'before-second', Settings.second_root # later binding still restored
|
|
109
|
+
assert_equal 1, error.errors.length
|
|
110
|
+
ensure
|
|
111
|
+
if singleton && singleton.method_defined?(:first_root_set_for_test)
|
|
112
|
+
singleton.alias_method :first_root=, :first_root_set_for_test
|
|
113
|
+
singleton.remove_method :first_root_set_for_test
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_restore_error_keeps_the_original_block_exception
|
|
118
|
+
singleton = Settings.singleton_class
|
|
119
|
+
singleton.alias_method :first_root_set_for_test, :first_root=
|
|
120
|
+
singleton.define_method(:first_root=) do |value|
|
|
121
|
+
raise 'cannot restore first' if value == 'before-first'
|
|
122
|
+
self.first_root_set_for_test(value)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
error = assert_raise(Scout::Testing::State::RestoreError) do
|
|
126
|
+
Scout::Testing.with_isolated_state(File.join(tmpdir, 'restore-with-block-error'),
|
|
127
|
+
bindings: [[Settings, :first_root, 'cache']]) do
|
|
128
|
+
raise 'original block error'
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
assert_equal 'original block error', error.original_error.message
|
|
133
|
+
assert_equal 'before-first', Settings.first_root
|
|
134
|
+
ensure
|
|
135
|
+
if singleton && singleton.method_defined?(:first_root_set_for_test)
|
|
136
|
+
singleton.alias_method :first_root=, :first_root_set_for_test
|
|
137
|
+
singleton.remove_method :first_root_set_for_test
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_binding_refuses_symlink_components_under_state_root
|
|
142
|
+
root = File.join(tmpdir, 'symlink-state')
|
|
143
|
+
outside = File.join(tmpdir, 'outside-state')
|
|
144
|
+
FileUtils.mkdir_p(root)
|
|
145
|
+
FileUtils.mkdir_p(outside)
|
|
146
|
+
File.symlink(outside, File.join(root, 'linked'))
|
|
147
|
+
|
|
148
|
+
assert_raise(ArgumentError) do
|
|
149
|
+
Scout::Testing.with_isolated_state(root,
|
|
150
|
+
bindings: [[Settings, :first_root, 'linked/nested']]) { flunk 'block should not run' }
|
|
151
|
+
end
|
|
152
|
+
refute File.exist?(File.join(outside, 'nested'))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_binding_rejects_absolute_and_root_escaping_paths
|
|
156
|
+
assert_raise(ArgumentError) do
|
|
157
|
+
Scout::Testing::State.new(tmpdir, bindings: [[Settings, :first_root, '/tmp/outside']])
|
|
158
|
+
end
|
|
159
|
+
assert_raise(ArgumentError) do
|
|
160
|
+
Scout::Testing::State.new(tmpdir, bindings: [[Settings, :first_root, '../outside']])
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|