scout-essentials 1.9.1 → 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/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_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
|
@@ -31,14 +31,14 @@ def self.setup(stream, options = {}, &block) # concurrent_stream.rb:12
|
|
|
31
31
|
|
|
32
32
|
- `threads ||= []`, `pids ||= []`, and new values are **concatenated** onto the
|
|
33
33
|
existing arrays — a second `setup` adds producers, it does not replace them
|
|
34
|
-
(
|
|
34
|
+
(`idempotent setup: cs? true
|
|
35
35
|
threads=1 pids=[4]`, `after 2nd threads arg: 2`).
|
|
36
36
|
- `std_err` is **reset to `""`** on every setup.
|
|
37
37
|
- `autojoin`, `no_fail`, `next`, `pair`, `filename`, `lock` are only assigned
|
|
38
38
|
when the corresponding option is non-nil, so an existing value survives.
|
|
39
39
|
- **The block becomes a callback**: a block passed to `setup` is treated as
|
|
40
40
|
`callback` and *composed* with any existing callback, so the new block runs
|
|
41
|
-
after the old one (
|
|
41
|
+
after the old one (observed `[:block, :block2]`).
|
|
42
42
|
|
|
43
43
|
This is why `Open.open_pipe` (thread mode) can call `setup` on both ends and
|
|
44
44
|
still compose a user callback, and why `add_callback` composes the same way.
|
|
@@ -61,8 +61,8 @@ stream.add_callback { cleanup_a }
|
|
|
61
61
|
stream.add_callback { cleanup_b } # join runs a, then b
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
:second]`)
|
|
64
|
+
(`add_callback order: [:first,
|
|
65
|
+
:second]`). There is no `add_abort_callback`;
|
|
66
66
|
`abort_callback = proc { |exception| ... }` replaces the slot (only `setup`
|
|
67
67
|
with an `:abort_callback` option composes it).
|
|
68
68
|
|
|
@@ -121,17 +121,17 @@ def abort(exception = nil) # concurrent_stream.rb:204
|
|
|
121
121
|
|
|
122
122
|
1. records `stream_exception ||= exception`,
|
|
123
123
|
2. marks the object with `AbortedStream.setup(self, exception)` and sets
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
`@aborted = true`; a second call only logs (`Already aborted stream`) and
|
|
125
|
+
returns — **idempotent**:
|
|
126
|
+
`abort idempotent: aborted? true`, `second abort: no raise, aborted? true`),
|
|
127
127
|
3. runs `abort_callback` with the exception,
|
|
128
128
|
4. `abort_threads`: raises `Aborted` (or the given exception) in each producer
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
thread and joins them — this is what unblocks a producer stuck writing to a
|
|
130
|
+
pipe nobody reads,
|
|
131
131
|
5. `abort_pids`: sends `SIGINT` to each pid,
|
|
132
132
|
6. clears both callbacks,
|
|
133
133
|
7. **propagates to `@pair`** if it responds to `abort` and is not already
|
|
134
|
-
|
|
134
|
+
aborted — killing one end of an `Open.open_pipe` pair takes down the other,
|
|
135
135
|
8. closes and unlocks in `ensure`.
|
|
136
136
|
|
|
137
137
|
Producers are expected to `rescue Aborted` and finish quietly; `Open.grep`,
|
|
@@ -151,7 +151,7 @@ This is how one failing producer takes the whole stream down: the exception is
|
|
|
151
151
|
stored in `stream_exception` (so a later `join`/`read` re-raises it even if the
|
|
152
152
|
current frame recovers), it is raised in every producer thread, and the stream
|
|
153
153
|
is aborted. `join_threads` and `join_pids` call it, and `ConcurrentStreamProcessFailed`
|
|
154
|
-
carries the offending `pid` plus the stream.
|
|
154
|
+
carries the offending `pid` plus the stream.
|
|
155
155
|
(`stream_raise_exception raised: demo`, `stream_exception set: #<Aborted: demo>`,
|
|
156
156
|
`no_fail join of failed: no raise`).
|
|
157
157
|
|
|
@@ -166,7 +166,7 @@ The standard producer wrapper (concurrent_stream.rb:286): it sets the stream up
|
|
|
166
166
|
with `kwargs`, runs the block, and in an `ensure` closes and joins the stream as
|
|
167
167
|
requested. `Aborted` and any other exception are logged, the stream is aborted
|
|
168
168
|
with the exception, and the exception is re-raised. `Open.open_pipe` (thread
|
|
169
|
-
mode) and `Open.sort_stream` are built on it.
|
|
169
|
+
mode) and `Open.sort_stream` are built on it.
|
|
170
170
|
(`process_stream: "z\ny\nx\n" src joined=true src closed=true`).
|
|
171
171
|
|
|
172
172
|
## `AbortedStream` and recovering the original cause
|
|
@@ -185,8 +185,7 @@ exception = (AbortedStream === content and content.exception) ? content.exceptio
|
|
|
185
185
|
That exact snippet is what `Open.sensible_write` uses: when copying a stream
|
|
186
186
|
into its tmp file raises, it recovers the original upstream exception from the
|
|
187
187
|
marker and re-raises *that* (deleting the target and tmp file), while a plain
|
|
188
|
-
`Aborted` is swallowed and cleaned up.
|
|
189
|
-
`probe_22`/`probe_13` sequences in `research/behavior-probes.md`.
|
|
188
|
+
`Aborted` is swallowed and cleaned up.
|
|
190
189
|
|
|
191
190
|
## `pair` means pipe ends, not stdout/stderr
|
|
192
191
|
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
This guide describes how testing works in `scout-essentials` and establishes conventions intended to be reusable across the Scout ecosystem.
|
|
4
|
+
|
|
5
|
+
The aim is to make tests easy to write, deterministic, isolated, and understandable by both developers and coding agents. Use the simplest test setup that demonstrates the behavior being tested. Shared helpers should remove repetitive setup, not obscure the test itself.
|
|
6
|
+
|
|
7
|
+
## 1. Running tests
|
|
8
|
+
|
|
9
|
+
From the `scout-essentials` checkout, with development dependencies installed:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bundle install
|
|
13
|
+
bundle exec rake test
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Run a single test file:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bundle exec rake test TEST=test/scout/test_testing.rb
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or invoke it directly:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bundle exec ruby -Ilib -Itest test/scout/test_testing.rb
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run the suite with coverage:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bundle exec rake simplecov
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The repository's `Rakefile` defines the `test` task using `test/**/*.rb` files matching `test_*.rb`, with `lib` and `test` on the load path. The `simplecov` task enables coverage through `COVERAGE=true`. The default Rake task delegates to the test suite.
|
|
35
|
+
|
|
36
|
+
These commands describe `scout-essentials`; other Scout repositories may have additional test tasks, integration suites, or external prerequisites. Check the consuming repository's `Rakefile` and test documentation before assuming the same commands apply.
|
|
37
|
+
|
|
38
|
+
## 2. Test organization
|
|
39
|
+
|
|
40
|
+
Tests should normally follow the source directory structure.
|
|
41
|
+
|
|
42
|
+
For example:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
lib/scout/
|
|
46
|
+
├── open.rb
|
|
47
|
+
├── open/
|
|
48
|
+
│ └── remote.rb
|
|
49
|
+
└── testing.rb
|
|
50
|
+
|
|
51
|
+
test/
|
|
52
|
+
├── test_helper.rb
|
|
53
|
+
└── scout/
|
|
54
|
+
├── test_open.rb
|
|
55
|
+
├── open/
|
|
56
|
+
│ └── test_remote.rb
|
|
57
|
+
└── test_testing.rb
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The convention is that `lib/scout/open/remote.rb` corresponds to `test/scout/open/test_remote.rb`. This is a convention, not an enforced one-to-one mapping: a source file may be covered by several tests, and related behavior may be tested together where that is clearer.
|
|
61
|
+
|
|
62
|
+
Every new behavior or bug fix should have appropriate test coverage. When adding a source file, normally add its corresponding test file as well.
|
|
63
|
+
|
|
64
|
+
### Test bootstrap
|
|
65
|
+
|
|
66
|
+
`test/test_helper.rb` is the local entry point for test setup. It configures the test framework, load paths, coverage, and any shared support needed by the repository.
|
|
67
|
+
|
|
68
|
+
Individual tests should load the bootstrap following the conventions of their repository and then require the code under test.
|
|
69
|
+
|
|
70
|
+
Keep these responsibilities separate:
|
|
71
|
+
|
|
72
|
+
- **Test bootstrap:** framework setup, shared isolation, and repository-wide test configuration.
|
|
73
|
+
- **Test support:** reusable fixtures, scenario builders, fake clients, and other test-only utilities.
|
|
74
|
+
- **Test files:** setup and assertions specific to the behavior being tested.
|
|
75
|
+
|
|
76
|
+
Rake discovers files named `test_*.rb` beneath `test/`. Name helper and fixture-builder files differently, usually under `test/support/`, so they are not collected as tests.
|
|
77
|
+
|
|
78
|
+
## 3. Fixtures: preparing test data
|
|
79
|
+
|
|
80
|
+
A *fixture* is data or state prepared for a test. Fixtures do not require a special framework. In Scout, ordinary files, Ruby methods, temporary directories, and simple fake objects are sufficient.
|
|
81
|
+
|
|
82
|
+
Choose between three basic approaches.
|
|
83
|
+
|
|
84
|
+
### Inline data
|
|
85
|
+
|
|
86
|
+
Use inline values for short inputs that are easy to understand where they appear.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
def test_parse_line
|
|
90
|
+
key, values = TSV.parse_line("gene1\tA\tB")
|
|
91
|
+
|
|
92
|
+
assert_equal "gene1", key
|
|
93
|
+
assert_equal %w(A B), values
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
There is no benefit in creating a separate fixture file for every small input. Keeping the data beside the assertion often makes the test easier to read.
|
|
98
|
+
|
|
99
|
+
### Static fixture files
|
|
100
|
+
|
|
101
|
+
Store data in files when it is sizeable, has a meaningful structure, represents an external format, or is reused by multiple tests.
|
|
102
|
+
|
|
103
|
+
New static test inputs should normally live under `test/fixtures/`, organized by purpose:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
test/fixtures/
|
|
107
|
+
├── backends/
|
|
108
|
+
│ ├── anthropic.json
|
|
109
|
+
│ └── anthropic_tool_use.json
|
|
110
|
+
└── data/
|
|
111
|
+
└── example.tsv
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
For example, an AI backend test can load a recorded provider response from JSON instead of contacting a real service. A TSV test can use a representative input file instead of embedding several dozen lines in Ruby.
|
|
115
|
+
|
|
116
|
+
Scout repositories may already use `test/data/` for established datasets. Keep those conventions where they make sense; do not move existing data merely to standardize directory names.
|
|
117
|
+
|
|
118
|
+
Fixture files belong to the repository whose behavior they test, not to the installed `scout-essentials` library. The shared helper resolves fixture paths relative to the consuming repository.
|
|
119
|
+
|
|
120
|
+
The generic fixture-path API is:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
require 'scout/testing'
|
|
124
|
+
|
|
125
|
+
path = Scout::Testing.fixture_path(
|
|
126
|
+
'backends/anthropic.json',
|
|
127
|
+
from: __FILE__
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
content = File.read(path)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`fixture_path` returns a path to the file; it does not parse the contents. Use the appropriate parser, such as `JSON.parse`, when needed. A repository may define a tiny local fixture loader to avoid repeating that parsing code.
|
|
134
|
+
|
|
135
|
+
### Scenario builders
|
|
136
|
+
|
|
137
|
+
Sometimes a test needs a group of related files or objects rather than one static input. Examples include workflow directories, persisted conversations, dependency graphs, or a project with source files and corresponding tests.
|
|
138
|
+
|
|
139
|
+
Create these structures with a small Ruby helper when building them inline would distract from the assertions or when several tests reuse the same scenario.
|
|
140
|
+
|
|
141
|
+
For example:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
module ChatFixtures
|
|
145
|
+
def write_chat(dir, name, content)
|
|
146
|
+
path = File.join(dir, name)
|
|
147
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
148
|
+
File.write(path, content)
|
|
149
|
+
path
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A more elaborate builder can create several related files and return their paths together. Prefer named results when a scenario has several components:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
{
|
|
158
|
+
root: root_chat,
|
|
159
|
+
jobs: jobs,
|
|
160
|
+
society_chats: society_chats
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
This is easier to understand than a positional array whose entries must be remembered.
|
|
165
|
+
|
|
166
|
+
Keep builders close to the tests that use them, usually in `test/support/` or a subsystem-specific support file. Introduce a shared builder only when the same meaningful scenario is needed in several places.
|
|
167
|
+
|
|
168
|
+
**Rule of thumb:** inline data for small examples, static files for substantial inputs, and Ruby builders for constructed scenarios.
|
|
169
|
+
|
|
170
|
+
## 4. Fakes, mocks, and external services
|
|
171
|
+
|
|
172
|
+
A fixture supplies data; a fake or mock replaces a dependency or controls its behavior. They are complementary.
|
|
173
|
+
|
|
174
|
+
Use the least elaborate option that tests the required contract:
|
|
175
|
+
|
|
176
|
+
- **Inline stub or fake:** when a test needs a small, controlled response from one dependency.
|
|
177
|
+
- **Recorded response plus fake client:** when testing how a provider adapter constructs requests and interprets responses.
|
|
178
|
+
- **Scripted mock backend:** when testing agent, chat, or workflow behavior without depending on a particular provider's protocol.
|
|
179
|
+
- **Real service:** when the service integration itself is the subject of the test.
|
|
180
|
+
|
|
181
|
+
For normal unit tests, avoid real network requests, live inference, large model downloads, and dependence on a developer's local configuration. Use recorded responses and controlled dependencies instead.
|
|
182
|
+
|
|
183
|
+
Keep genuine infrastructure tests distinct from ordinary unit tests. Their prerequisites must be explicit, and the default unit-test task should not unexpectedly invoke external services.
|
|
184
|
+
|
|
185
|
+
## 5. Temporary files and directories
|
|
186
|
+
|
|
187
|
+
Tests that create files should use isolated temporary storage rather than writing into shared project directories or relying on files left behind by previous runs.
|
|
188
|
+
|
|
189
|
+
The generic helper is available through an explicit require:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
require 'scout/testing'
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
For a temporary directory owned by one test:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
Scout::Testing.with_tmpdir do |dir|
|
|
199
|
+
path = File.join(dir, 'input.txt')
|
|
200
|
+
File.write(path, "example\n")
|
|
201
|
+
|
|
202
|
+
# Exercise the code that uses the file.
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The directory is removed in an `ensure` block, including when the test raises an exception.
|
|
207
|
+
|
|
208
|
+
Use the helper's `parent:` and `prefix:` options when a test needs a particular temporary location or recognizable directory name. Ensure that test processes do not share a cleanup target.
|
|
209
|
+
|
|
210
|
+
The repository's test bootstrap may expose a local `tmpdir` helper that returns a Scout `Path` object. Use it in tests that depend on Scout path semantics; use the generic temporary-directory helper when building reusable, framework-independent test support.
|
|
211
|
+
|
|
212
|
+
When a test creates data outside its temporary root, it must arrange cleanup for that data separately.
|
|
213
|
+
|
|
214
|
+
## 6. Shared testing helpers
|
|
215
|
+
|
|
216
|
+
The generic testing module is opt-in. It is deliberately not loaded by `require 'scout-essentials'` and does not depend on Scout storage abstractions.
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
require 'scout/testing'
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Its public helpers are:
|
|
223
|
+
|
|
224
|
+
| API | Purpose |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `Scout::Testing.create_tmpdir` | Create a unique temporary directory |
|
|
227
|
+
| `Scout::Testing.remove_tmpdir` | Remove temporary storage |
|
|
228
|
+
| `Scout::Testing.with_tmpdir` | Create temporary storage, yield it, and clean it up |
|
|
229
|
+
| `Scout::Testing.fixture_root` | Locate the fixture directory relative to a consumer |
|
|
230
|
+
| `Scout::Testing.fixture_path` | Resolve a fixture file relative to a consumer |
|
|
231
|
+
| `Scout::Testing::Assertions#assert_equal_path` | Compare normalized path strings |
|
|
232
|
+
| `Scout::Testing::Assertions#assert_path_exists` | Assert that a path exists |
|
|
233
|
+
|
|
234
|
+
The assertion methods can be included in a Test::Unit test class:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class TestExample < Test::Unit::TestCase
|
|
238
|
+
include Scout::Testing::Assertions
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The fixture helpers take `from:` to identify the consuming project's context. They support normal Git repositories and worktrees, as well as conventional `test/` and `lib/` layouts when Git metadata is absent. This is a practical discovery heuristic, not universal repository detection. For nonstandard layouts, pass an explicit `directory:` or construct the path directly.
|
|
243
|
+
|
|
244
|
+
The shared functions use Ruby standard-library filesystem operations. This is intentional: generic test setup should not depend on Scout's own path resolution or filesystem abstractions, since those abstractions may themselves be under test.
|
|
245
|
+
|
|
246
|
+
### Scout-specific state isolation
|
|
247
|
+
|
|
248
|
+
Tests that exercise Scout storage and cache behavior may also need to isolate process-global state. That functionality lives in a separately required module:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
require 'scout/testing'
|
|
252
|
+
require 'scout/testing/scout_state'
|
|
253
|
+
|
|
254
|
+
Scout::Testing.with_isolated_state('tmp/test-state') do
|
|
255
|
+
# Test code using isolated Scout storage.
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The Scout-state layer isolates and restores selected `TmpFile`, `Persist`, and `Open` settings and the `Persist::MEMORY_CACHE` mapping. It does not reset every global variable, environment setting, singleton, registry, or configuration source.
|
|
260
|
+
|
|
261
|
+
Callers may opt in to additional simple path settings by passing
|
|
262
|
+
`bindings: [[owner, accessor, relative_path], ...]` to `State.new` or
|
|
263
|
+
`with_isolated_state`. For example:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
Scout::Testing.with_isolated_state('tmp/test-state', bindings: [
|
|
267
|
+
[MyCache, :directory, 'my-cache']
|
|
268
|
+
]) do
|
|
269
|
+
# MyCache.directory points below tmp/test-state here.
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Each binding names a class/module accessor (such as `:directory` with a
|
|
274
|
+
`directory=` setter) and a non-empty relative path. The target directory is
|
|
275
|
+
created below the canonical isolation root; absolute paths, paths escaping
|
|
276
|
+
that root, and existing symlink components within it are rejected. Binding
|
|
277
|
+
state is saved before activation, including whether its conventional
|
|
278
|
+
`@<accessor>` instance variable was unset. Activation and block
|
|
279
|
+
failures trigger restoration. Every activated binding is restored even if a
|
|
280
|
+
setter fails; the implementation also restores the conventional backing
|
|
281
|
+
instance variable as a fallback. A restoration failure is reported as
|
|
282
|
+
`Scout::Testing::State::RestoreError` after all bindings and built-in state
|
|
283
|
+
have had a restoration attempt; its `errors` array contains the failures. If
|
|
284
|
+
this occurs while another exception is already propagating, the restore error
|
|
285
|
+
retains that original exception in `original_error`.
|
|
286
|
+
|
|
287
|
+
Additional bindings are for plain settings whose setter stores its value in
|
|
288
|
+
`@<accessor>`. They are not a general rollback mechanism for setters with
|
|
289
|
+
unrelated side effects. Directory creation rejects symlink components but is
|
|
290
|
+
not a race-proof filesystem transaction; use a unique state root and avoid
|
|
291
|
+
concurrent writers.
|
|
292
|
+
|
|
293
|
+
The shared `test/test_helper.rb` may load both layers because the tests in this repository use both. A consumer that only needs generic filesystem helpers should require only `scout/testing`.
|
|
294
|
+
|
|
295
|
+
Restoration of process-global state is not synchronization, including for
|
|
296
|
+
caller-supplied bindings. Tests using this state-isolation mechanism must not
|
|
297
|
+
run concurrently in the same Ruby process unless they provide additional
|
|
298
|
+
synchronization. Separate processes or serialized execution are appropriate
|
|
299
|
+
alternatives.
|
|
300
|
+
|
|
301
|
+
### Availability primitives
|
|
302
|
+
|
|
303
|
+
Generic environment-availability primitives live in a third, separately required module:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
require 'scout/testing/availability'
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Like the other testing layers, it is opt-in and is never loaded by `require 'scout-essentials'`.
|
|
310
|
+
|
|
311
|
+
Its scope is deliberately narrow: generic mechanisms only — PATH lookup, bounded child-process execution, and a bounded TCP connect. It contains no knowledge of interpreters, packages, models, or network services. Those probes belong to the layers that own them: the Python/R runtime probes to `scout-rig`, and the accelerator, model-cache, and endpoint probes to `scout-ai`. See the handoff notes `research/testing-availability-scout-rig-handoff.md` and `research/testing-availability-scout-ai-handoff.md` for those layers, including their corrected reference implementations.
|
|
312
|
+
|
|
313
|
+
The API surface is:
|
|
314
|
+
|
|
315
|
+
| API | Purpose |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `Scout::Testing::Availability.executable?(name)` | True when `name` resolves on `PATH`, or contains a path separator and names an executable file. No shell. |
|
|
318
|
+
| `Scout::Testing::Availability.run_command(argv, timeout:)` | Run `argv` (never a shell string) with a hard deadline; return a `CommandResult`. |
|
|
319
|
+
| `Scout::Testing::Availability.tcp_reachable?(host, port, timeout:)` | True when a TCP connection was established within the timeout. |
|
|
320
|
+
|
|
321
|
+
`run_command` drains both output pipes concurrently so a chatty child cannot deadlock a pipe, bounds the captured output per stream, and escalates `TERM` then `KILL` to the child's process group when the deadline passes. It is never memoized: availability can change between calls.
|
|
322
|
+
|
|
323
|
+
The returned `CommandResult` exposes `argv`, `success`/`success?`, `exit_status`, `stdout`, `stderr`, `timed_out`, `launch_error`, and `reason`. Launch failure (for example a missing executable) and timeout are reported on the result rather than raised; only programming errors — a non-positive or non-finite timeout, or an empty `argv` — raise `ArgumentError`.
|
|
324
|
+
|
|
325
|
+
For example, guarding a test on a local tool:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
require 'scout/testing/availability'
|
|
329
|
+
|
|
330
|
+
result = Scout::Testing::Availability.run_command(
|
|
331
|
+
[tool, '--version'], timeout: 10
|
|
332
|
+
)
|
|
333
|
+
omit result.reason unless result.success?
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Tests for these primitives follow the same offline discipline as the rest of this guide. The TCP tests bind a local `TCPServer` on `127.0.0.1` with an ephemeral port and add a negative case against a closed local port; the timeout tests use a short deadline against a child that sleeps much longer; no test contacts anything outside the machine.
|
|
337
|
+
|
|
338
|
+
## 7. Isolation and regression tests
|
|
339
|
+
|
|
340
|
+
A good test establishes its own relevant conditions, exercises the behavior, and asserts the observable result.
|
|
341
|
+
|
|
342
|
+
Follow these rules:
|
|
343
|
+
|
|
344
|
+
- Do not depend on state left by another test or on test-file execution order.
|
|
345
|
+
- Use isolated temporary storage for filesystem tests.
|
|
346
|
+
- Reset or restore caches, registries, environment variables, logger settings, and other global state that the test changes.
|
|
347
|
+
- Use the actual filesystem when testing filesystem semantics, including symlinks and path containment. A mock cannot establish that the real filesystem behaves as expected.
|
|
348
|
+
- Make failure cases explicit: invalid input, missing files, rejected operations, exceptions, and cleanup after failure.
|
|
349
|
+
- Avoid timing-based assertions when the same property can be checked deterministically.
|
|
350
|
+
- Keep test names and assertions focused on behavior rather than implementation details, unless the implementation detail is itself the contract.
|
|
351
|
+
|
|
352
|
+
For configuration or environment tests, prefer an existing scoped helper when one is available. For example, `Scout::Config.with_config` scopes configuration changes, and `Misc.with_env` restores environment changes. Explicitly save and restore any additional state that the test mutates.
|
|
353
|
+
|
|
354
|
+
A regression test should capture the smallest useful reproduction of a bug and establish the behavior that must remain stable. Do not reproduce an entire application scenario when a minimal example demonstrates the defect.
|
|
355
|
+
|
|
356
|
+
## 8. Unit tests, integration tests, and empirical probes
|
|
357
|
+
|
|
358
|
+
These are different forms of verification.
|
|
359
|
+
|
|
360
|
+
**Unit tests** establish repeatable behavior using controlled inputs and isolated data. They should normally be fast and should not depend on external infrastructure.
|
|
361
|
+
|
|
362
|
+
**Integration tests** exercise component boundaries or real services, tools, runtimes, and environments. Record their prerequisites and keep them separate from the default unit-test task where necessary.
|
|
363
|
+
|
|
364
|
+
**Empirical probes** investigate behavior under particular conditions and record observations. A successful probe does not automatically establish a deterministic contract. When a finding is stable and worth preserving, turn it into a regular test.
|
|
365
|
+
|
|
366
|
+
These are conceptual categories, not universal Rake task types. Each repository defines how its tests and probes are discovered and executed.
|
|
367
|
+
|
|
368
|
+
## 9. Boundaries across Scout repositories
|
|
369
|
+
|
|
370
|
+
The lowest-level testing support should remain small and reusable.
|
|
371
|
+
|
|
372
|
+
- `scout-essentials` owns generic filesystem helpers, fixture-root lookup, path assertions, its opt-in Scout-state isolation, and the generic availability primitives (`executable?`, `run_command`, `tcp_reachable?`).
|
|
373
|
+
- `scout-gear` owns workflow, Step, TSV, Entity, persistence-engine, and scheduler fixtures.
|
|
374
|
+
- `scout-ai` owns LLM mocks, fake provider clients, recorded provider payloads, agent scenarios, and Path Engine project fixtures.
|
|
375
|
+
- `scout-camp` owns rendering fixtures, temporary view roots, and request-level test support.
|
|
376
|
+
|
|
377
|
+
Do not put domain-specific setup in the generic test bootstrap simply because several repositories happen to use it. Share mechanics; keep fixtures close to the behavior they represent.
|
|
378
|
+
|
|
379
|
+
When adding helper code, use a clear name, one responsibility, and the simplest interface that serves its callers. Avoid introducing a general-purpose fixture framework unless repeated, concrete needs justify it.
|
|
380
|
+
|
|
381
|
+
## References
|
|
382
|
+
|
|
383
|
+
- [Start Here](../StartHere.md) — installation, overview, and developer-guide index.
|
|
384
|
+
- [Architecture](Architecture.md) — library responsibilities and boundaries.
|
|
385
|
+
- [Configuration](Configuration.md) — `Scout::Config` behavior.
|
|
386
|
+
- [Design Principles](DesignPrinciples.md) — observable library conventions.
|
|
387
|
+
- [Caching Results](../user/CachingResults.md) — persistence and caching behavior.
|
|
388
|
+
|
|
389
|
+
The implementation details described here are grounded in the `Rakefile`, `lib/scout/testing.rb`, `lib/scout/testing/scout_state.rb`, `lib/scout/testing/availability.rb`, `test/test_helper.rb`, and the testing-support tests. The `Rakefile` remains authoritative for the commands and discovery rules of this repository.
|
data/doc/user/AnnotatingData.md
CHANGED
|
@@ -6,9 +6,7 @@ changing their class or wrapping them. This page is the user-facing view of the
|
|
|
6
6
|
system; internals and design notes are in
|
|
7
7
|
[Annotation System](../developer/AnnotationSystem.md).
|
|
8
8
|
|
|
9
|
-
Every example below was run against the current `lib/scout
|
|
10
|
-
P36–P42 in `research/behavior-probes.md` plus
|
|
11
|
-
`tmp/rewrite_C/probe_01..04.rb` and `probe_09_annotated_array.rb`).
|
|
9
|
+
Every example below was run against the current `lib/scout/`.
|
|
12
10
|
|
|
13
11
|
## Defining an annotation
|
|
14
12
|
|
|
@@ -41,7 +39,7 @@ annotated = SampleInfo.setup(name, organism: 'Human')
|
|
|
41
39
|
annotated.equal?(name) # => true
|
|
42
40
|
```
|
|
43
41
|
|
|
44
|
-
Two consequences
|
|
42
|
+
Two consequences:
|
|
45
43
|
|
|
46
44
|
- a **frozen** object is `dup`ed first, so `setup` returns a different,
|
|
47
45
|
unfrozen copy — always use the return value;
|
|
@@ -88,7 +86,7 @@ Annotation.setup('S003', 'SampleInfo', sample.annotation_hash)
|
|
|
88
86
|
```
|
|
89
87
|
|
|
90
88
|
The type argument may be a `"A|B"` String of module names (unknown names are
|
|
91
|
-
only `Log.warn`ed and skipped
|
|
89
|
+
only `Log.warn`ed and skipped) or an Array of module objects.
|
|
92
90
|
|
|
93
91
|
`#serialize` produces the plain Hash (`annotation_info` merged with
|
|
94
92
|
`:literal`) consumed by the `:annotation` persistence driver. There is **no
|
|
@@ -104,8 +102,7 @@ in this gem.
|
|
|
104
102
|
|
|
105
103
|
`NamedArray` (`lib/scout/named_array.rb`) is a separate annotation module
|
|
106
104
|
that gives an Array named fields. It needs its **explicit**
|
|
107
|
-
`require 'scout/named_array'` — `scout-essentials.rb` does not load it
|
|
108
|
-
(probe_03):
|
|
105
|
+
`require 'scout/named_array'` — `scout-essentials.rb` does not load it:
|
|
109
106
|
|
|
110
107
|
```ruby
|
|
111
108
|
require 'scout/named_array'
|
|
@@ -116,7 +113,7 @@ row[:organism] # => "Human"
|
|
|
116
113
|
row['tissue'] # => "Liver"
|
|
117
114
|
```
|
|
118
115
|
|
|
119
|
-
Signatures
|
|
116
|
+
Signatures: `NamedArray.setup(array, names, *rest)` — the names are
|
|
120
117
|
a positional Array, **not** a `key:` keyword.
|
|
121
118
|
|
|
122
119
|
### Access is via `method_missing`
|
|
@@ -137,7 +134,7 @@ positionally by name.
|
|
|
137
134
|
### Array methods shadow field names
|
|
138
135
|
|
|
139
136
|
If a field is called `first`, `last`, `count`, `zip`, `sample` … the real
|
|
140
|
-
`Array` method wins
|
|
137
|
+
`Array` method wins:
|
|
141
138
|
|
|
142
139
|
```ruby
|
|
143
140
|
row2 = NamedArray.setup(%w[a b c], %w[first second third])
|
|
@@ -158,14 +155,13 @@ field → value.
|
|
|
158
155
|
|
|
159
156
|
Watch out for `id` — `annotation_id` (aliased `id`) is defined by the
|
|
160
157
|
annotation system itself, so a field named `id` collides and the digest is
|
|
161
|
-
returned instead
|
|
158
|
+
returned instead.
|
|
162
159
|
|
|
163
160
|
## `AnnotatedArray` — elements inherit the container's annotations
|
|
164
161
|
|
|
165
162
|
Annotate the *container*, then `extend AnnotatedArray`, and every element
|
|
166
163
|
handed out by `[]`, `first`, `last`, `each`, `collect`, `select`, `compact`,
|
|
167
|
-
`uniq`, `flatten`, `reverse`, `sort_by`, `subset`, `remove` is re-annotated
|
|
168
|
-
(probe_09):
|
|
164
|
+
`uniq`, `flatten`, `reverse`, `sort_by`, `subset`, `remove` is re-annotated:
|
|
169
165
|
|
|
170
166
|
```ruby
|
|
171
167
|
samples = SampleInfo.setup(%w[S001 S002 S003], organism: 'Human')
|
|
@@ -177,7 +173,7 @@ samples.collect { |s| s.length } # [4, 4, 4], elements annotated
|
|
|
177
173
|
samples.select { |s| s != 'S002' } # re-annotated array
|
|
178
174
|
```
|
|
179
175
|
|
|
180
|
-
Not overridden — **annotations are dropped** (
|
|
176
|
+
Not overridden — **annotations are dropped** (method owners are
|
|
181
177
|
`Array`/`Enumerable`): `map`, `zip`, `+`, `filter_map`, `flat_map`,
|
|
182
178
|
`each_slice`, `values_at`. `zip` in particular does not propagate annotations
|
|
183
179
|
to the *other* operand's elements.
|
|
@@ -51,7 +51,6 @@ every token it recognises:
|
|
|
51
51
|
- `--` stops the whole loop: everything after it is left in `args`.
|
|
52
52
|
|
|
53
53
|
```ruby
|
|
54
|
-
# probe_02 (tmp/rewrite_C/probe_02_sopt.rb)
|
|
55
54
|
argv = ['-o', 'Human', '--tissue', 'Liver', 'positional', '-d']
|
|
56
55
|
SOPT.consume(argv)
|
|
57
56
|
# => {:organism=>"Human", :tissue=>"Liver", :"dry-run"=>true}
|
|
@@ -75,8 +74,7 @@ Booleans are true unless the value is one of `F`, `false`, `FALSE`, `no`
|
|
|
75
74
|
`=` instead — this convenience swallows the next token, so use `=`.
|
|
76
75
|
|
|
77
76
|
A word that merely *follows* a boolean flag and is not one of those four is
|
|
78
|
-
**not** eaten: `['--dry-run', 'stray']` leaves `"stray"` in `ARGV
|
|
79
|
-
(probe_02).
|
|
77
|
+
**not** eaten: `['--dry-run', 'stray']` leaves `"stray"` in `ARGV`.
|
|
80
78
|
|
|
81
79
|
## State and reuse
|
|
82
80
|
|
|
@@ -88,7 +86,7 @@ A word that merely *follows* a boolean flag and is not one of those four is
|
|
|
88
86
|
- `SOPT::GOT_OPTIONS`, a module-level hash that is **merged into, never
|
|
89
87
|
reset**. Every `consume` call accumulates there, which is how
|
|
90
88
|
sub-commands that each declare their own inputs still end up with a
|
|
91
|
-
global picture of what was given (
|
|
89
|
+
global picture of what was given (two separate `consume` calls
|
|
92
90
|
on disjoint inputs both appear in `GOT_OPTIONS`).
|
|
93
91
|
|
|
94
92
|
`SOPT.get(opt_str)` is just `parse` followed by `consume(ARGV)`.
|
|
@@ -103,7 +101,7 @@ SOPT.require(options, :organism, :tissue)
|
|
|
103
101
|
# raises ParameterException: Parameter 'tissue' not given
|
|
104
102
|
```
|
|
105
103
|
|
|
106
|
-
`ParameterException < ScoutException < StandardError
|
|
104
|
+
`ParameterException < ScoutException < StandardError`, so plain
|
|
107
105
|
`rescue` works. There is **no** variant that extracts a subset of the
|
|
108
106
|
options — `SOPT.get` always parses a fresh string and consumes the whole
|
|
109
107
|
`ARGV`.
|
|
@@ -128,8 +126,8 @@ myprog [--organism=<string>] [--tissue=<string>] [--dry-run[=false]]
|
|
|
128
126
|
|
|
129
127
|
The header really is `## SYNOPSYS` — the misspelling is in the source
|
|
130
128
|
(`simple_opt/doc.rb:112`) and callers grep for it; do not "fix" it in your
|
|
131
|
-
matching code. `SOPT.usage` prints the doc and calls `exit 0` (
|
|
132
|
-
`SystemExit`
|
|
129
|
+
matching code. `SOPT.usage` prints the doc and calls `exit 0` (trapping
|
|
130
|
+
`SystemExit` reports status 0).
|
|
133
131
|
|
|
134
132
|
`SOPT.input_doc` (used by `doc`) is also the public way to format an
|
|
135
133
|
explicit option list, and `SOPT.input_array_doc` formats
|
|
@@ -145,25 +143,24 @@ searches for a free one:
|
|
|
145
143
|
|
|
146
144
|
1. an existing shortcut already bound to that exact long name is reused;
|
|
147
145
|
2. if the long name contains `-` or `_`, the initials of its parts
|
|
148
|
-
|
|
146
|
+
(`--max-cpu` → `-m` if free, else the accumulated initials);
|
|
149
147
|
3. if it contains digits, the first letter plus the number;
|
|
150
148
|
4. otherwise it walks forward through the letters.
|
|
151
149
|
|
|
152
150
|
If no shortcut can be found, `fix_shortcut` returns `nil` and the option
|
|
153
151
|
simply has no short form. **Collisions are silent**: declaring `-a--alpha`
|
|
154
152
|
and `-a--also` yields `{"a"=>"alpha", "al"=>"also"}` — the second entry
|
|
155
|
-
gets a longer shortcut rather than an error
|
|
156
|
-
(`tmp/rewrite_C/probe_07_sopt_extra.rb`): registering `t` while `-t` is
|
|
153
|
+
gets a longer shortcut rather than an error: registering `t` while `-t` is
|
|
157
154
|
bound to `tissue` yields `"th" => "threshold"`; `another_one` gets the
|
|
158
155
|
initials `"ao"`; `alpha2` gets `"a2"`.
|
|
159
156
|
|
|
160
157
|
`SOPT.delete_inputs(['organism'])` removes an input from `inputs`,
|
|
161
158
|
`input_shortcuts`, `shortcuts`, `input_types`, `input_defaults` and
|
|
162
159
|
`input_descriptions` (`simple_opt/accessor.rb:39`). `input_shortcuts` is the
|
|
163
|
-
reverse map `{'organism'=>'o'}
|
|
160
|
+
reverse map `{'organism'=>'o'}`.
|
|
164
161
|
|
|
165
162
|
`SOPT.reset` clears **only** `shortcuts` and the internal `all` registry;
|
|
166
|
-
`inputs` and the other per-input tables survive
|
|
163
|
+
`inputs` and the other per-input tables survive. Call
|
|
167
164
|
`SOPT.delete_inputs(SOPT.inputs.dup)` if you actually want an empty slate.
|
|
168
165
|
|
|
169
166
|
## Quirks to design around
|