Module: TurboTests::Utils::Paths
- Defined in:
- lib/turbo_tests/utils/paths.rb
Overview
Makes discovered spec paths relative to the cwd that owns the RSpec
configuration, in a way that survives the same directory being spelled
two different ways.
== Why string comparison of paths is not safe here
A path string is a name, not an identity. One directory can have
several valid names, and the two names can reach this module from
different APIs within the same process.
On the Windows CI runner (windows-latest, github/actions) this is not
hypothetical. Measured from the runner itself:
Dir.pwd => “C:/Users/RUNNER~1/AppData/Local/Temp/turbo-tests2-…-ujisy2”
ENV[“TEMP”] => “C:\Users\RUNNER~1\AppData\Local\Temp”
Dir.tmpdir => “C:/Users/RUNNER~1/AppData/Local/Temp”
Dir.glob result => “C:/Users/runneradmin/AppData/Local/Temp/turbo-tests2-…-ujisy2/gems/example/spec/example_spec.rb”
RUNNER~1 is an 8.3 short name; runneradmin is the long name of the
same directory. Dir.pwd and the environment report the short form while
Dir.glob — which RSpec uses to expand --pattern — reports the long
form. Both are correct, and they are never equal as strings.
In this codebase the mismatch has a second, platform-independent face:
a symlinked ancestor. On Fedora, /home/pboling is a symlink to
/var/home/pboling, so Dir.pwd can report one form while a path built
from an ENV var or a stored fixture reports the other. That is what makes
this bug reproducible in specs without a Windows runner.
== Approaches that do NOT work, and why
All four were tried on this branch and all failed. Recorded so nobody
repeats the sequence.
-
String prefix, the original code:
root_prefix = “#{root}/”
expanded.start_with?(root_prefix)Never matches when the two spellings differ, so the absolute temp path
leaks through. parallel_tests then calls File.stat on it during group
sizing. It also wrongly reports “project-other/spec” as inside
“project”, which is why spec/utils/paths_spec.rb covers siblings. -
Canonicalize with File.realpath, then compare:
File.realpath(path) == File.realpath(root) # conceptually
WRONG PREMISE: File.realpath does NOT expand 8.3 short names. Measured
on the runner, realpath of the short path returns the short path
unchanged:root_realpath => “C:/Users/RUNNER~1/AppData/Local/Temp/turbo-tests2-…”
Canonicalization therefore cannot reconcile the two spellings at all.
(kettle-dev’s Kettle::Dev::Paths.canonical documents realpath as
expanding 8.3 names; that comment is inaccurate. Worth correcting if
this module is ever extracted to a shared gem.)realpath also resolves symlinks, which introduces a second failure
mode: root and file can legitimately traverse different symlink paths
to one directory, and canonicalizing only one side makes them diverge. -
Component-wise comparison of the canonicalized parts (split on
File::SEPARATOR, compare element by element, case-insensitively on
Windows). Built on the same false premise as #2, so it fails
identically —RUNNER~1never equalsrunneradminin any case
folding. It additionally depended on File::ALT_SEPARATOR, which
measured as “” (empty string, not nil) on that runner, making the
separator-normalizingtra silent no-op. Do not trust ALT_SEPARATOR
to be present. -
Pathname#relative_path_from, Ruby’s own stdlib helper:
Pathname.new(file).relative_path_from(Pathname.new(root)).to_s
It is purely lexical. With differing spellings it walks all the way up
and back down, producing:”../../../../../runneradmin/AppData/Local/Temp/…/example_spec.rb”
Note it does not raise, so a
rescue ArgumentErrorguard cannot catch
it. The result is a plausible-looking but wrong relative path, which is
worse than the leak in #1 because it silently selects the wrong files.
The same nonsense appears on the symlink case:
“../../../../../../var/home/pboling/src/my/…”.
== The approach that works
File.identical? asks the filesystem whether two names denote one object,
by inode on Unix and by file index on Windows. It is spelling-agnostic,
which is exactly the property needed. Measured on the runner:
File.identical?(root, root) => true
File.identical?(file, file) => true
and a walk-up from the long-name file to the short-name root produced the
correct “gems/example/spec/example_spec.rb”, while the string-prefix and
Pathname baselines both failed in the same run.
So: ascend from the file, asking at each level “is this the root?”, and
re-join the basenames on the way back down. The relative path is built
from the file’s spelling, which is what the caller needs, since
parallel_tests stats these paths and spawns workers from this cwd.
== How this was finally diagnosed
The failure only reproduced on the Windows CI runner, and its output was
swallowed: these discovery specs run a subprocess via Open3.capture3 whose
stderr is only surfaced when the status check fails, and the assertion
that failed was the JSON comparison. Four blind fixes failed in a row.
What resolved it was a temporary, Windows-guarded spec
(if: Gem.win_platform?) that deliberately failed with the diagnostic
JSON embedded in the expectation message, so the runner’s real path forms
printed into the CI log. Guarding it kept the other 22 checks green while
one job reported the data. That spec is deleted; the findings it produced
are this comment plus spec/utils/paths_spec.rb.
Lesson: when a bug is environment-specific and the environment is not
available locally, spend the iteration on instrumenting the real
environment rather than on another hypothesized fix.
Class Method Summary collapse
-
.relative_from(file, root) ⇒ String
Returns the path of +file+ relative to +root+ when +file+ is +root+ itself or lives inside it, compared by filesystem identity rather than by string.
-
.within?(file, root) ⇒ Boolean
Returns true when +file+ is +root+ itself or lives inside it, compared by filesystem identity.
Class Method Details
.relative_from(file, root) ⇒ String
Returns the path of +file+ relative to +root+ when +file+ is +root+
itself or lives inside it, compared by filesystem identity rather than
by string.
Returns the expanded +file+ unchanged when it is not inside +root+
(there is nothing to strip) or when identity cannot be established,
e.g. because one side does not exist. That matches the long-standing
behavior of passing through paths outside the root untouched.
145 146 147 148 149 150 |
# File 'lib/turbo_tests/utils/paths.rb', line 145 def relative_from(file, root) = File.(file.to_s) relative = relative_to_root(, root) relative || end |
.within?(file, root) ⇒ Boolean
Returns true when +file+ is +root+ itself or lives inside it, compared
by filesystem identity.
Unlike a string prefix test this does not treat a sibling directory
that merely shares a prefix (“project-other”) as being inside “project”.
161 162 163 164 165 |
# File 'lib/turbo_tests/utils/paths.rb', line 161 def within?(file, root) return false if blank?(file) || blank?(root) relative_to_root(File.(file.to_s), root).is_a?(String) end |