This is a handoff document. It specifies the test suite to be implemented
(by another agent) against the current behavior of rbmanager/*.cs at
commit 96f9763. It contains no test code; it defines scope, required
refactoring, the full test-case inventory, and the execution plan.
Ground rules for the implementer:
- Pin current behavior. Where this plan flags a behavior as questionable (section 6), write the test against what the code does today and mark it with a comment referencing the open question. Do not change product behavior as part of the test PR, except for the minimal seams in section 3.
- Tests must never touch the real
%LOCALAPPDATA%\Ruby, the realHKCU\EnvironmentPATH value, or the network. Every test that would do so goes through a seam or a loopback server. - All tests are Windows-only by nature (junctions, registry, cmd.exe). Do not add cross-platform shims.
The product is a single AOT-published exe rb.exe (net8.0-windows).
Layout: %LOCALAPPDATA%\Ruby with rubies\<name> for installs,
current as an NTFS junction to the active install, bin\rb.exe for
the tool itself.
Command surface and contracts:
| Command | Behavior | Exit |
|---|---|---|
rb setup |
Copy own exe to <root>\bin\rb.exe (skip if already running from there, case-insensitive), append that dir to user PATH |
0 |
rb install <zip|url> |
If URL, download to %TEMP% first (deleted in finally). Zip must contain exactly one root dir named ruby-*; extract under rubies, inject the embedded operating_system.rb trust hook into lib\ruby\site_ruby\rubygems\defaults\, then switch current to it and ensure current\bin on user PATH. Refuse if already installed |
0 / 1 |
rb list |
Installed names sorted, active one starred | 0 |
rb use <query> |
Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the current junction, ensure PATH |
0 / 1 |
rb uninstall <query> |
Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 |
rb msvc enable [shell] |
Locate VsDevCmd via vswhere, compute the env delta of activation, print per-shell assignments plus an unset of NoDefaultCurrentDirectoryInExePath. Shell defaults to PowerShell; cmd/bat selects cmd syntax. No toolchain: actionable warning on stderr |
0 / 1 |
rb msvc exec <cmd...> |
Same delta applied to a cmd /s /c child (so .cmd shims resolve); removes NoDefaultCurrentDirectoryInExePath; propagates the child's exit code |
child / 1 |
| anything else | Usage text | 2 |
Any thrown exception is caught in Main, printed as rb: <message> to
stderr, exit 1.
- Framework: xUnit (current stable), single test project
rbmanager.Tests/rbmanager.Tests.csproj,net8.0-windows, referencing the product project. Add arbmanager.slnbinding both. - The product classes are
internal static. AddInternalsVisibleTo("rbmanager.Tests")(csprojItemGroup, not an attribute file). - Three layers, tagged with xUnit traits so they can be filtered:
Category=Unit: pure logic, no filesystem, no processes.Category=Integration: real filesystem in a per-test temp dir, test-scoped registry subkey, stub.batprocesses. Runs on any Windows machine with no VS installed.Category=E2E: drives the builtrb.exeas a process end to end.Category=RequiresVS: opt-in tests that need a real VS Build Tools install; excluded by default.
- Isolation:
- Every filesystem test creates a unique temp root
(
Path.Combine(Path.GetTempPath(), "rbmanager-tests", Guid...)) and deletes it inDispose. Junction targets and junction points both live inside it. - Tests that redirect
Console.Out/Console.Erroror mutate the process environment (Program/Msvcin-process tests) go in one xUnit collection ([Collection("process-global")]) so they never run in parallel with each other. E2E tests spawn processes and can stay parallel because each gets its own root via the env seam. - Registry tests use a per-test subkey under
HKCU\Software\rbmanager-tests\<guid>and delete it after.
- Every filesystem test creates a unique temp root
(
- Zip fixtures are generated in test code with
ZipArchive(a fakeruby-9.9.x-x64-mswin64_140/tree containingbin/ruby.exestub bytes and a couple of nested files). No binary fixtures are committed. - The URL install path is tested with an
HttpListenerbound tohttp://127.0.0.1:<free port>/serving a generated zip. No external network.
Keep this to the minimum below; each item is a mechanical change.
- Root redirection.
Program.Root/Rubies/Currentarestatic readonlyfields computed from the Known Folder API, which ignores theLOCALAPPDATAenvironment variable, so tests cannot redirect it from outside. Change the three fields into properties (or a smallPathsholder) computed from a base directory that isEnvironment.GetEnvironmentVariable("RBMANAGER_ROOT")when set and the current value otherwise, re-read on each access (no caching in a static initializer, so E2E and in-process tests can both redirect). This also gives E2E tests their redirect for free. UserPath.Ensureseam. Add an internal overloadEnsure(string entry, RegistryKey environmentKey, bool broadcast); the public method opensHKCU\Environmentand passesbroadcast: true. Tests pass their scratch subkey andbroadcast: falseso noWM_SETTINGCHANGEis sent. For E2E, honor an env varRBMANAGER_ENV_KEYnaming an alternative HKCU-relative subkey (and suppress the broadcast when it is set) so a fullrb installrun never touches the real PATH.Msvcseams. MakeVsWherean internal settable property (env overrideRBMANAGER_VSWHEREfor E2E), and makeActivatedDelta,LocateVsDevCmd,ParseShell,Assignment,Unset,QuoteArginternal instead of private.ActivatedDelta(vsdevcmd)already takes the bat path as a parameter, so a stub.batfixture exercises the whole activation pipeline without VS. Add an internal seam for the VsDevCmd path used byEnable/Exec(RBMANAGER_VSDEVCMDenv override checked beforeLocateVsDevCmd) so Enable/Exec are E2E-testable with the stub.- No other production changes. In particular do not restructure
Program's command methods; in-process tests call the internal methods directly withConsole.SetOut/SetErrorcapture, and the dispatch/exit-code contract is covered at the E2E layer.
Names below are indicative; group into one class per production class plus one E2E class.
- Single root
ruby-4.0.5-x64-mswin64_140/with nested entries → returns the root name. - Single root not starting with
ruby-→ throwsunexpected root directory '<name>' (want ruby-*). - Two root dirs → throws
must contain a single root directory. - One root dir plus one root-level file (e.g.
README.txt) → the file counts as a second root → throws (pin; see 6.4). - Empty zip → empty roots array →
must contain a single root directory. - Entries using backslash separators (build such an archive manually
via
ZipArchive.CreateEntry(@"ruby-x\bin\ruby.exe")) → every entry becomes its own root → throws (pin as known limitation; see 6.4).
Seed the redirected root with e.g. ruby-4.0.5-x64-mswin64_140,
ruby-4.1.0-x64-mswin64_140.
- Exact full name → that name.
- Unique substring
4.0.5→ full name. - Case-insensitive substring
RUBY-4.0→ full name. - No match →
no installed ruby matches '<q>'. - Ambiguous substring
4.→ error message listing both matches, comma-separated, in sorted order. - Exact name that is also a substring of another install (seed
ruby-4.1.0andruby-4.1.0-rc1, queryruby-4.1.0) → currently ambiguous → error (pin; flagged in 6.1). - No
rubiesdirectory at all → behaves as empty → no-match error.
All with redirected root and the registry/broadcast seam. Console captured. These call the internal methods directly.
Installfrom a local fixture zip: extracts torubies\<name>,currentjunction points at it,current\binappended to the scratch PATH value, returns 0, output containsInstalled <name>.- Trust hook injected: after install,
<install>\lib\ruby\site_ruby\rubygems\defaults\operating_system.rbexists and is byte-identical tooverlay\site_ruby\rubygems\defaults\operating_system.rb. - Install the same zip twice →
InvalidOperationException<name> is already installed; the existing tree is untouched. - Install a second version →
currentnow targets the new one (pin: install always switches; see 6.2). - Install from URL: loopback
HttpListenerserving the fixture zip → installs normally and the downloaded temp file (%TEMP%\<zip name>) is gone afterwards. - Install from URL returning 404 → exception propagates (exit-1 path), no install dir created, no leftover temp file.
- Install from URL whose path ends in
/(empty file name) → pin whatever happens today (expected:File.Createon a directory path fails; see 6.3). - Bad zip (root not
ruby-*): nothing created underrubies. Listwith no rubies dir → prints nothing, returns 0.Listwith two installs and one active → sorted output,*on the active line, two leading spaces otherwise ("* name"/" name").Listwith installs but nocurrentjunction → no starred line.Usewith unique query → junction retargeted, PATH ensured, printsNow using <full name>, returns 0.Useambiguous/no-match → throws, junction unchanged.Uninstallnon-active version → its dir removed,currentstill points at the active one, printsUninstalled <name>.Uninstallthe active version → junction removed (path no longer exists), install dir removed, output includes therunrb use`` hint line.Uninstallwith ambiguous query → throws, nothing deleted.- Exploratory: manually delete the junction target dir, then call
CurrentTargetandUninstallof that name. Pin observed behavior with a comment (dangling-junction semantics ofDirectoryInfo.Exists/LinkTargetdecide the path taken; see 6.5).
- (E2E) Run the built
rb.exe setupwithRBMANAGER_ROOTandRBMANAGER_ENV_KEYset →<root>\bin\rb.exeexists and is byte-identical to the source exe, scratch PATH contains<root>\bin, exit 0, output has both lines. - (E2E) Run setup again from the copied exe itself
(
<root>\bin\rb.exe setup) → no self-copy error (source==dest is skipped), exit 0. - (E2E) Setup when
bin\rb.exealready exists → overwritten.
Drive the exe built by dotnet build (see section 5 for AOT).
- No args → usage on stdout, exit 2.
- Unknown command → usage, exit 2.
installwith no argument,usewith no argument,msvcwith no subcommand,msvc execwith no command → usage, exit 2 (themsvc execpattern requires a non-empty command).- Failing command (e.g.
use nosuch) → stderr starts withrb:, exit 1, stdout empty. - Full lifecycle: install A → list (A starred) → install B → list (B starred) → use A → uninstall B → list (only A) → uninstall A → list (empty). All through the process boundary with a shared per-test root.
- Create junction to an existing dir →
Directory.Exists(junction)true,new DirectoryInfo(junction).LinkTargetequals the full target path, and a file created in the target is visible through the junction. - Relative target path → resolved with
Path.GetFullPath(LinkTarget is absolute). - Target with trailing backslash → trimmed, identical result.
- Target does not exist →
DirectoryNotFoundExceptionnaming the path. - Target path containing spaces and non-ASCII characters → round-trips.
- Junction path already exists as an empty directory → succeeds (Create tolerates a pre-existing empty dir).
- Junction path exists as a non-empty directory →
FSCTL_SET_REPARSE_POINTfails →IOExceptionwith the Win32 error (pin the observed error). - Recreate flow as
SwitchTodoes it: create junction to A,Directory.Delete(junction), create junction to B → LinkTarget is B and A's contents are intact (deleting a junction never deletes the target's contents). CurrentTargetparsing: junction to...\rubies\ruby-x→ returnsruby-x; no junction → null; plain directory (not a reparse point) atcurrent→LinkTargetnull → null.
All against HKCU\Software\rbmanager-tests\<guid> with
broadcast: false.
- No
Pathvalue → value created with exactly the entry, kindExpandString. - Existing REG_SZ value → append
;entry, kind stays REG_SZ. - Existing REG_EXPAND_SZ with
%SystemRoot%\toolsinside → appended, kind stays ExpandString,%SystemRoot%still unexpanded in the stored value (read back withDoNotExpandEnvironmentNames). - Entry already present, exact → value unchanged (compare raw string before/after).
- Entry present with different case → unchanged.
- Entry present with surrounding whitespace / empty segments
(
;; C:\x ;) → recognized, unchanged. - Existing value with trailing semicolons → trimmed before append
(no
;;in result). - Empty-string existing value → result is exactly the entry, no
leading
;.
ParseShell:null,powershell,pwsh,ps→ PowerShell;cmd,bat→ Cmd;zsh→ throwsunknown shell 'zsh'. (Note case sensitivity:PowerShellthrows today. Pin; see 6.6.)Assignmentcmd:set "K=V"verbatim, including when V contains spaces,%, and quotes (no escaping today; pin).AssignmentPowerShell:$env:K = 'V'; aVcontaining'is doubled; aVcontaining$stays literal.Unsetboth shells: exact strings (set "K="/Remove-Item Env:\K -ErrorAction SilentlyContinue).QuoteArg: bare word unchanged; contains space → wrapped in quotes; empty string →""; tab → quoted; embedded"→ not escaped (pin as known limitation; see 6.7).
Stub .bat fixture written per test, e.g. sets RB_TEST_NEW=hello,
modifies PATH by prefixing a marker dir, sets a var whose value
contains = and one containing non-ASCII, and exit /b 0.
ActivatedDelta(stub)→ contains exactly the added and changed variables; unchanged inherited variables are absent.- Delta detects a changed value for a preexisting variable (set a known var in the test process first, have the stub change it).
- Value containing
=→ split on the first=only; key and full value preserved. - Stub exiting nonzero →
VsDevCmd activation failed. Enable(via theRBMANAGER_VSDEVCMDseam) PowerShell → stdout lines are valid$env:assignments for the stub's vars and the final line is theRemove-Item Env:\NoDefaultCurrentDirectoryInExePathunset; exit 0. Same for cmd syntax.Enable/Execwith the toolchain seam pointing nowhere andVsWhereset to a nonexistent path → stderr warning containing the winget hint, exit 1, stdout empty (the warning must not go to stdout, sincerb msvc enable | Invoke-Expressionwould eval it).LocateVsDevCmdwithVsWherenonexistent → null (covered behaviorally by 66; also assert directly).Execwith stub: runcmd /c setas the command, capture output → child sees the stub's variables and does not seeNoDefaultCurrentDirectoryInExePath(set it in the test process first).Execexit-code propagation:rb msvc exec cmd /c exit 7→ 7.Execresolves.cmdshims: put ahello.cmdon the stub-added PATH dir,msvc exec hello→ runs it (proves thecmd /s /crouting and PATHEXT behavior).Execargument quoting: an argument with spaces survives to the child (child echoes%1-style or a tiny script writes its argv to a file).
Skipped unless vswhere resolves an install (use a runtime skip, e.g.
Assert.Skip/SkippableFact).
LocateVsDevCmdreturns an existingVsDevCmd.bat.ActivatedDeltaincludesINCLUDE,LIB, and aPATHchange.rb msvc exec cl(E2E) exits 0 with cl's banner on stderr.
dotnet publish -r win-x64the product, runrb.exe install <fixture zip>with the seams set → succeeds and the trust hook file is written (verifies the embedded resource and theLibraryImportP/Invokes survive AOT; this is the one place the AOT binary differs meaningfully from the CoreCLR build).
Phased so each phase leaves the tree green.
- Phase 0 (seams): section 3 changes,
rbmanager.sln, empty test project with one smoke test. Gate:dotnet buildof both projects and a manualrb liststill working. - Phase 1: Unit tests (4.1, 4.8).
- Phase 2: Integration tests (4.2, 4.3, 4.6, 4.7, 4.9). This is the bulk; implement Junction and UserPath first since Program's tests depend on their correctness for assertions.
- Phase 3: E2E (4.4, 4.5), building the exe once per test run via a
fixture (
dotnet build -c Release, cache the output path in a collection fixture). - Phase 4: opt-in suites (4.10, 4.11).
Run commands:
dotnet test rbmanager.sln --filter "Category=Unit|Category=Integration" # default, no VS needed
dotnet test rbmanager.sln --filter "Category=E2E"
dotnet test rbmanager.sln --filter "Category=RequiresVS" # only on a VS machine
dotnet test rbmanager.sln --filter "Category=Publish" # AOT publish; needs VS
Note (implementation): case 32 ("setup run from the copied exe") moved out
of the default E2E suite into the Publish suite. The framework-dependent
Debug apphost rb.exe cannot run once copied away from its rb.dll, so
the self-copy-skip path is only meaningful against the self-contained AOT
single-file exe, which the Publish suite already builds.
Full verification at the end of each phase, not after every test added.
There is no CI wiring in scope for this repo yet; the suite must pass
locally on a Windows 11 machine without Visual Studio for everything
except RequiresVS.
Rough size: about 75 test cases, one test class per production class
plus CliE2eTests, and shared helpers for temp roots, fixture zips,
console capture, the scratch registry key, and the stub bat.
Write the tests against current behavior and reference this section in a comment. Each is a product decision to make separately.
Resolvegives exact matches no precedence: a name that is exactly the query is still ambiguous when the query is also a substring of another install (case 12).installalways switchescurrentto the new install, even when another version was deliberately active (case 17).install <url>derives the temp filename from the URL path; an empty last segment breaks it, and two parallel installs of the same filename would collide in%TEMP%(case 20 pins the former).- Zip handling assumes
/-separated entry names and treats a root-level file as a second root (cases 4, 6). - Dangling
current(target deleted out of band) has unpinned semantics inCurrentTarget/Uninstall(case 30 pins it). ParseShellis case-sensitive (PowerShellis rejected).QuoteArgdoes not escape embedded quotes;rb msvc execwith an argument containing"produces a broken cmd line (case 60 pins the helper's output only).