Add supported module testing harness - #3579
Conversation
There was a problem hiding this comment.
Code review
Reviewed the new ModularPipelines.Testing harness (ModuleTester, RecordingCommandInterceptor, InMemoryFileSystemProvider, NoOpConsoleServices) and the supporting core changes (ICommandInterceptor, File.Exists/Folder.Exists routing through IFileSystemProvider, CommandResult.Ok). No CLAUDE.md violations found. Found several issues, ranked by severity — all verified against the code as it currently exists on this branch.
1. Command interception silently defeats ThrowOnNonZeroExitCode (bug)
ModularPipelines/src/ModularPipelines/Context/Command.cs
Lines 92 to 102 in 8a5cc93
When an ICommandInterceptor returns a result, the method returns immediately at line 99 — before Of(...) (the only caller of CreateCommandFailure, which throws CommandException when ExitCode != 0 && ThrowOnNonZeroExitCode, default true). So a module (or a test) that intercepts a command and returns a non-zero-exit-code CommandResult gets that result back silently instead of the CommandException a real failing command would throw. Any module whose error handling relies on the throw (which is the framework default) behaves differently under a stubbed command than it would in production — the exact opposite of what a test double should guarantee.
Suggested fix: route the intercepted result through the same failure-evaluation step (CreateCommandFailure, or a shared helper) instead of returning early. This is worth fixing at the abstraction level, not just for tests — ICommandInterceptor is a public seam usable outside tests too (per docs/docs/how-to/testing.md), so any production use of interception has the same silent-failure hole.
2. InMemoryFileSystemProvider.IsDescendant never matches anything under a root path (bug)
ModularPipelines/src/ModularPipelines.Testing/InMemoryFileSystemProvider.cs
Lines 335 to 383 in 8a5cc93
Normalize keeps the trailing separator only for root paths (/, C:\), so IsDescendant("/tmp/x.txt", "/") checks candidate[parent.Length], which lands on 't' (the first char of the child name) rather than a separator, and returns false. Since the constructor pre-registers the root as a directory, this is reachable: EnumerateFiles("/", "*", AllDirectories) silently returns empty even with files present, and DeleteDirectory("/", recursive: true) reports success while deleting none of the root's contents (only the root's own dictionary entry is removed). No existing test exercises operations against the root — the test suite only touches paths under GetTempPath().
Suggested fix: special-case parents that already end in a separator in IsDescendant (treat the boundary as already consumed), e.g. if (parent[^1] is '/' or '\\') return true; before the boundary-character check.
3. Filesystem virtualization is incomplete — Exists is virtualized, most everything else isn't (design gap)
ModularPipelines/src/ModularPipelines/FileSystem/Folder.cs
Lines 512 to 524 in 8a5cc93
This PR routes File.Exists/Folder.Exists through _provider, but Folder.GetFiles/GetFolders/ListFiles/ListFolders/Clean and File.Length/Attributes/CreationTime/LastWriteTimeUtc/Extension/IsReadOnly/Hidden/Name all still go straight to DirectoryInfo/FileInfo/System.IO.Directory, bypassing IFileSystemProvider entirely. Under InMemoryFileSystemProvider, a module can observe folder.Exists == true (in-memory) and then have folder.GetFiles() throw DirectoryNotFoundException or silently read unrelated files from the real disk — an inconsistent, half-virtualized filesystem that's worse than not virtualizing at all, because it looks trustworthy.
Suggested approach: rather than patching individual members opportunistically (which is how this partial state arose), treat IFileSystemProvider as the single seam for all of File/Folder's disk interaction — extend the provider interface to cover enumeration and metadata, and route every member through it. If full coverage isn't feasible in this PR, the safer interim behavior is for InMemoryFileSystemProvider-backed File/Folder instances to throw NotSupportedException on the un-virtualized members, so gaps fail loudly during test authoring instead of silently falling through to the real filesystem.
4. AppendAllTextAsync/CopyFile/MoveFile aren't actually thread-safe, despite the class doc comment (bug vs. documented contract)
The class is documented as "Thread-safe" on the strength of using ConcurrentDictionary, but AppendAllTextAsync does an unguarded read-modify-write (FileExists → GetFile → SetFile), and CopyFile/MoveFile do check-then-act (ContainsKey then SetFile/TryRemove). ConcurrentDictionary only makes each individual operation atomic — it does not make a sequence of operations atomic. Two concurrent appends to the same path can lose one; overwrite: false on CopyFile can still be raced into an overwrite.
Suggested fix: use ConcurrentDictionary.AddOrUpdate (with a CAS-retry factory) for the append path, and guard copy/move with a per-path lock or an atomic TryAdd-first pattern. If true thread-safety isn't actually needed for this harness's use case (single module, sequential execution), the simpler and more honest fix is to correct the doc comment rather than leave a contract the code doesn't meet.
5. Unseeded [DependsOn<T>] dependency hangs for up to 30 minutes instead of failing fast (API design)
ModularPipelines/src/ModularPipelines.Testing/ModuleTester.cs
Lines 148 to 191 in 8a5cc93
ModuleTester auto-registers a module's dependencies in DI but only resolves their results if seeded via WithDependencyResult (which internally forces the result via TrySetDistributedResult). It executes the module directly through IModuleExecutionPipeline, bypassing the scheduler (IDependencyWaiter) entirely. If a test forgets to seed a dependency and the module awaits it (context.GetModule<Dep>()), nothing ever completes that dependency's TaskCompletionSource — the await blocks until PipelineOptions.DefaultModuleTimeout (30 minutes) elapses and throws ModuleTimeoutException, instead of an immediate, clear "dependency not seeded" error. For a unit-testing library, a fast, precise failure on a missing test setup is the whole point — a half-hour hang is close to worst-case UX for a typo'd or forgotten WithDependencyResult call.
Suggested fix: validate at ExecuteAsync/build time that every [DependsOn<T>] dependency of the module under test has either been seeded or is otherwise resolvable, and throw immediately with a clear message naming the missing dependency if not.
Related, lower-severity architectural note: ModuleTester reimplements the single-module execution path by hand (ExecutionContextFactory → ModuleExecutionDelegateFactory.GetExecutor → IModuleExecutionPipeline) rather than reusing ModuleRunner, so ModuleRunner-owned behavior — attribute-based lifecycle handlers (IModuleStartHandler/IModuleEndHandler), global IModuleEventReceivers, dependency-skip propagation, mediator notifications — silently doesn't run under test even though it runs in production. Today's docs/docs/how-to/testing.md claims are narrow enough to still be technically accurate (it only promises skip conditions/timeouts/retries/direct hooks, which do run), but the gap is real and will only grow as more is added to ModuleRunner over time. A narrower shared seam — e.g. an internal single-module execution entry point used by both ModuleRunner and ModuleTester — would prevent this from silently drifting further as the engine evolves, versus maintaining two parallel orchestration implementations.
Nice addition overall — the builder API (ModuleTester.For<T>(), WithDependencyResult, InterceptCommands) is a clean, discoverable shape for module authors, and the File.Exists/Folder.Exists fix (routing through _provider instead of hitting FileInfo/DirectoryInfo directly) is a genuine pre-existing bug fix. The issues above are mostly about the harness's guarantees not fully matching what it implies (thread-safety, full filesystem virtualization, production-parity execution, fail-fast on missing setup) — worth tightening before this becomes the documented, supported way to test modules.
|
Addressed all five review findings in beb5acf and rebased onto current main.\n\n- intercepted nonzero command results now honor ThrowOnNonZeroExitCode and throw CommandException after logging\n- root paths now match descendants correctly\n- File/Folder enumeration, globbing, and Clean route through IFileSystemProvider; unsupported physical metadata/copy semantics fail loudly instead of touching disk\n- in-memory compound mutations are synchronized, including append/copy/move\n- required dependency results are validated before execution with a named, immediate setup error\n- docs describe dependency, command, and metadata contracts\n\nValidation: ModularPipelines.Testing.UnitTests 19/19; FolderTests 25/25; FileTests 29/29; changed-file analyzer gate passes at warning severity; whitespace gate passes. @codex review |
8a5cc93 to
beb5acf
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: beb5acff91
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code reviewRe-reviewed after the
All five are re-verified against the current code, not just the commit message, and each has direct test coverage. No CLAUDE.md violations found. Two design points remain worth tracking, neither blocking: A. Physical-metadata gating is identity-based, not capability-based. ModularPipelines/src/ModularPipelines/FileSystem/File.cs Lines 464 to 473 in beb5acf SystemFileSystemProvider is sealed with a private constructor, so the real filesystem can only ever be reached through that one singleton. But it means any future provider that legitimately backs onto real disk — a logging/auditing decorator around SystemFileSystemProvider, a chroot'd or read-only wrapper, etc. — would be misclassified as "unsupported" purely because it isn't that exact instance, even though FileInfo/DirectoryInfo would work fine against it. A capability contract (e.g. an optional interface the provider implements, or extending IFileSystemProvider itself to expose these members as the original review suggested) would let correctness be a property of what a provider can do rather than which concrete singleton it happens to be, so this doesn't need revisiting every time a new provider wraps or extends the physical one.
B. ModularPipelines/src/ModularPipelines.Testing/ModuleTester.cs Lines 120 to 167 in beb5acf ModuleRunner, so ModuleRunner-owned behavior (IModuleStartHandler/IModuleEndHandler, global IModuleEventReceivers, dependency-skip propagation) still doesn't run under test. The docs are scoped narrowly enough to stay accurate today, but as more responsibility accretes onto ModuleRunner over time, this is the kind of gap that grows silently rather than failing loudly. Worth eventually factoring out a shared single-module execution seam used by both ModuleRunner and ModuleTester, so the two orchestration paths can't drift apart.
Nice follow-through — all five issues were fixed at the root cause (not papered over) and each got direct regression coverage rather than just a manual claim in the PR description. |
All five requested changes were fixed in beb5acf; Claude re-reviewed the current implementation and explicitly confirmed every finding resolved in issue comment 5137754897.
Code reviewRe-checked after the latest commit ( That commit swaps All 5 previously-flagged issues (command interception bypassing No new issues found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 78d8a23954
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
There was a problem hiding this comment.
Code review
Re-reviewed at the current head (051f626e, "refactor(testing): simplify file opening"). The five issues from my first pass and the two from the second pass remain fixed and test-covered — nothing in 051f626e (a pure Open-method extraction: ValidateOpenArguments/GetInitialContents/ShouldInitializeFile/CreateStream) changes that behavior.
Since my last review, chatgpt-codex-connector posted six new P2 findings against this same head. I independently verified each against the current code; all six are real and none has been addressed yet:
1. GetFiles/GetFolders regression — full recursive walk before exclusion, SafeWalk semantics lost (src/ModularPipelines/FileSystem/Folder.cs:493-524)
On main, these methods use SafeWalk.EnumerateFolders/EnumerateFiles, which prune excluded directories before recursing into them and tolerate inaccessible directories. This PR replaces that with _provider.EnumerateDirectories(Path, "*", SearchOption.AllDirectories).Where(path => !IsExcludedByDirectoryFilter(...)). For SystemFileSystemProvider, that's Directory.EnumerateFiles(path, "*", AllDirectories) — it walks the entire tree first, then the predicate filters results after the fact. This is the one finding here that isn't testing-library-specific: it's a behavior change to the core File/Folder API used in production. Any pipeline excluding bin/obj/.git/node_modules/a permission-restricted directory specifically to avoid descending into it can now throw UnauthorizedAccessException (no more IgnoreInaccessible) or just pay for a much more expensive walk than before, exactly where the exclusion filter existed to prevent that. Worth fixing before merge, since it's a silent regression for any existing pipeline module, not just an edge case in the new harness.
2-5. InMemoryFileSystemProvider fidelity gaps (all reachable, all diverge from SystemFileSystemProvider):
SetFile(line 376) doesn't check_directories, andCreateDirectorydoesn't check_files— a file and a directory can coexist at the same path, which a real filesystem rejects.SetFileauto-creates the entire parent directory chain (CreateDirectory(Path.GetDirectoryName(normalized) ...), line 381) instead of requiring the parent to already exist. RealFile.WriteAllText/CreatethrowDirectoryNotFoundExceptionwhen the parent is missing; a module that forgot to create its output directory will pass under the harness and fail in production.CommittingMemoryStream(line 440) only guards writes viaEnsureWritable(); it never restricts reads.Open(path, mode, FileAccess.Write)returns a stream whoseCanReadis stilltrue(inherited fromMemoryStream) and whoseRead/ReadBytestill succeed, where a realFileStreamopened write-only throws on read.JoinLines(line 365) always appendsEnvironment.NewLine, soWriteAllLinesAsync/AppendAllLinesAsyncwith an empty sequence write a single newline character.System.IO.File.WriteAllLineswith an empty sequence produces a genuinely empty (0-byte) file.
6. RecordingCommandInterceptor records the pre-metadata result, not what the module receives (src/ModularPipelines.Testing/RecordingCommandInterceptor.cs:25, src/ModularPipelines/Context/Command.cs:92-103)
InterceptAsync enqueues the handler's raw CommandResult into _commands before Command.ExecuteCommandLineToolAsync calls ApplyCommandMetadata(intercepted, command). ApplyCommandMetadata overwrites CommandInput/WorkingDirectory/EnvironmentVariables on the result actually returned to the module — so run.Commands[i].Result in a test permanently reflects the interceptor's raw stub (e.g. CommandResult.Ok()'s defaults) rather than the enriched result the module code actually saw. A test asserting on the recorded working directory or env vars will get the wrong values.
Architectural observation
Items 2-5 are the third consecutive round where InMemoryFileSystemProvider ships a divergence from SystemFileSystemProvider that a reviewer had to spot by manual inspection (root-path matching, non-atomic ops, and now directory/file collisions, parent-existence, read/write access, and empty-line joining). Each round fixes the specific instances flagged, but the underlying cause — no mechanism that forces the two providers to agree — stays in place, so the next behavioral gap surfaces the same way. A parameterized/shared contract test suite ([Theory] over both provider instances, run against a real temp directory for the system provider and a fresh InMemoryFileSystemProvider for the other, asserting identical results and identical exception types for the same sequence of operations) would catch this class of bug systematically instead of one divergence at a time, and is worth adding before InMemoryFileSystemProvider grows further. The two design points I raised in the previous round (identity-based physical-metadata gating, ModuleTester reimplementing single-module execution instead of reusing ModuleRunner) are unchanged and still non-blocking.
No CLAUDE.md violations found. The extraction in 051f626e itself is clean and behavior-preserving.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 051f626e74
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
051f626 to
478346f
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 478346f025
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code reviewRe-checked after the two commits since my last review comment (
What changed:
Validation: built No new issues found, and no CLAUDE.md violations. The two design notes from my previous review (capability- vs identity-based physical-metadata gating, and |
Provide supported isolated module execution with dependency seeding, command interception/recording, in-memory files, and safe output defaults. Refs #3538
478346f to
4d39375
Compare
|
Rebased onto current main. Relevant tests pass 66/66; Testing package and core Release builds pass. @codex review |
Code reviewChecked the current head ( No files owned by this PR ( No new issues found — nothing to re-review since the rebase carried no functional changes for this PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d3937524d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d1fd25d40f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Addressed the three latest testing-harness findings in 4599871: AlwaysRun tests honor direct caller cancellation without inheriting production engine-failure cancellation; command timeouts now span interceptors and process execution; OpenRead retains shared-reader semantics while generic Open stays exclusive. Validation: ModularPipelines.Testing.UnitTests 37/37; CommandTests 16/16; Testing solution Release build 0 errors; changed production files warning-free. @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 459987170d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Closed four runtime-parity gaps in 0a6b816: exclusive handles now block direct reads/copies, interceptor timeouts are rechecked after handlers return, and physical ZIP create/extract preserves file timestamps. Validation: Testing suite 39/39; ZipTests 5/5; CommandTests 16/16; scoped whitespace clean. @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0a6b81647b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@claude review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 71a9361ba8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@claude review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4ce822ea80
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@claude review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 49500511bf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3af70686f7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| using (var destination = _fileSystemProvider.Open( | ||
| destinationPath, | ||
| FileMode.Create, | ||
| FileAccess.Write)) |
There was a problem hiding this comment.
Use CreateNew when overwrite is disabled
When overwriteFiles is false and another process or parallel module creates destinationPath after the existence check, opening it with FileMode.Create silently truncates that new file instead of reporting the required conflict. The previous ExtractToFile(..., overwrite: false) path performed this check atomically; select FileMode.CreateNew when overwriting is disabled.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d7b7247d9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return parent[^1] is '/' or '\\' | ||
| || candidate[parent.Length] is '/' or '\\'; |
There was a problem hiding this comment.
Treat backslashes as separators only on Windows
On Unix, \ is a valid filename character rather than a directory separator, so this classifies a sibling such as /tmp/root\outside/file.txt as a descendant of /tmp/root. Consequently recursive enumeration, deletion, or directory moves can include and mutate that sibling even though the system filesystem would not; use the platform directory separator rules when checking the boundary.
Useful? React with 👍 / 👎.
Summary
ModularPipelines.Testingwith isolatedModuleTestertyped/untyped APIs and dependency seedingValidation
src/ModularPipelines.Testing/ModularPipelines.Testing.slnModularPipelines.Testing.UnitTestspassed with coverageCloses #3538