#3484·ava

(High Severity Cross-Platform Bug): Windows Test Discovery Failure via Case-Sensitive Prefix Matching in `normalizeFileForMatching`

Author: codeCraft-RitikCreated Sep 15, 2026Updated Sep 15, 2026
Labelsneeds triage

Issue :- (High Severity Cross-Platform Bug): Windows Test Discovery Failure via Case-Sensitive Prefix Matching in normalizeFileForMatching

Metadata

  • Title: [Bug] Windows test discovery fails due to drive letter case sensitivity and root drive slicing in normalizeFileForMatching
  • Severity: High (Cross-Platform / Windows Compatibility)
  • Component: Path Normalization & Glob Matching (lib/glob-helpers.js)
  • Affected File: lib/glob-helpers.js (normalizeFileForMatching, lines 79–94)

Problem Summary

normalizeFileForMatching strips the current working directory from absolute file paths so that picomatch can match them against relative test patterns (e.g. test/**/*.js).

On Windows, this implementation suffers from two major bugs:

  1. Drive Letter Case Sensitivity: Windows file paths are case-insensitive, and Node.js frequently mixes lowercase (c:/...) and uppercase (C:/...) drive letters (e.g., from process.cwd() vs pathToFileURL vs resolve). Because file.startsWith(cwd) performs a case-sensitive string check, c:/project/test.js does NOT start with C:/project. The path is not stripped and is returned as a full absolute path, causing matches(file, filePatterns) to fail and test files to be silently ignored.
  2. Drive Root Path Corruption: The function assumes cwd does not end in a slash and computes file.slice(cwd.length + 1). When running from a drive root (e.g. C:/), cwd.length + 1 is 4, which slices off the first character of the filename (C:/test.js becomes est.js).

Root Cause Analysis

In lib/glob-helpers.js lines 79–94:

javascript
export function normalizeFileForMatching(cwd, file) {
    if (process.platform === 'win32') {
        cwd = slash(cwd);
        file = slash(file);
    }

    // Note that if `file` is outside `cwd` we can't normalize it. If this turns
    // out to be a real-world scenario we may have to make changes in calling code
    // to make sure the file isn't even selected for matching.
    if (!file.startsWith(cwd)) {
        return file;
    }

    // Assume `cwd` does *not* end in a slash.
    return file.slice(cwd.length + 1);
}
  • Scenario 1: cwd = "C:/Users/dev/project", file = "c:/Users/dev/project/test/app.test.js". file.startsWith(cwd) is false. Returns "c:/Users/dev/project/test/app.test.js". classify() passes this to picomatch('test/**/*.js'), which returns false. The test file is discarded.
  • Scenario 2: cwd = "C:/", file = "C:/test.js". file.slice(3 + 1) returns "est.js". The test file is corrupted.

Proposed Fix

Use standard path.relative (which natively handles Windows case-insensitivity, drive boundaries, and root slashes) combined with slash():

diff
--- a/lib/glob-helpers.js
+++ b/lib/glob-helpers.js
@@ -79,16 +79,12 @@ export function matches(file, patterns) {
 export function normalizeFileForMatching(cwd, file) {
-	if (process.platform === 'win32') {
-		cwd = slash(cwd);
-		file = slash(file);
-	}
-
-	// Note that if `file` is outside `cwd` we can't normalize it. If this turns
-	// out to be a real-world scenario we may have to make changes in calling code
-	// to make sure the file isn't even selected for matching.
-	if (!file.startsWith(cwd)) {
-		return file;
+	const rel = path.relative(cwd, file);
+	if (rel.startsWith('..') || path.isAbsolute(rel)) {
+		return slash(file);
 	}
 
-	// Assume `cwd` does *not* end in a slash.
-	return file.slice(cwd.length + 1);
+	return slash(rel);
 }

Bonus Finding: isLikeSelector Rejects Object.create(null) & Mutates Prototype via __proto__

Metadata

  • Component: Assertion Library (lib/like-selector.js)
  • Affected File: lib/like-selector.js (lines 3–15, 24–41)

Problem Summary

  1. isLikeSelector checks:
    javascript
    if (isPrimitive(selector) || (!Array.isArray(selector) && Reflect.getPrototypeOf(selector) !== Object.prototype)) {
        return false;
    }
    Null-prototype objects (Object.create(null)) have a prototype of null (null !== Object.prototype), causing isLikeSelector to reject clean dictionaries as invalid selectors.
  2. In selectComparable:
    javascript
    comparable[key] = Reflect.get(actual, key);
    If selector contains the key '__proto__', assigning comparable['__proto__'] = ... modifies the prototype of comparable instead of setting an own property, leading to prototype mutation during comparison. Use Reflect.defineProperty to safely preserve own properties.