From 9ec695f6f59c038d3178a8bfb5f65898fb789205 Mon Sep 17 00:00:00 2001 From: technicallybrantley <77166260+technicallybrantley@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:11:42 -0500 Subject: [PATCH] fix(desktop): make the test loader work on Windows The resolve hook hands nextResolve absolute filesystem paths. Node's ESM resolver requires URLs or relative specifiers: POSIX absolute paths happen to be coerced, but a Windows path like C:\... parses as a URL with protocol 'c:', so every desktop unit-test run on Windows dies immediately with ERR_UNSUPPORTED_ESM_URL_SCHEME - on a clean tree, before any test executes. CI never sees it (Linux runners). Convert absolute paths to file:// URLs (pathToFileURL) at the three nextResolve call sites. On POSIX the resulting URL is identical to what node coerced before; on Windows the loader now works. With this change the full desktop suite (318 files, 3487 tests) passes on Windows 11 / node 24.14.1. Independently reported by another Windows contributor in #2634's testing notes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YFkHsUe1UUBBuvL81Zoe3n Signed-off-by: technicallybrantley <77166260+technicallybrantley@users.noreply.github.com> --- desktop/test-loader-hooks.mjs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs index c71cc18599..f7ada29a99 100644 --- a/desktop/test-loader-hooks.mjs +++ b/desktop/test-loader-hooks.mjs @@ -1,4 +1,4 @@ -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; @@ -13,6 +13,17 @@ const repoRoot = path.resolve( "..", ); +// `nextResolve` requires specifiers to be URLs or relative paths. Passing an +// absolute filesystem path happens to work on POSIX (node coerces it), but on +// Windows an absolute path like `C:\...` parses as a URL with protocol `c:` +// and every test run dies with ERR_UNSUPPORTED_ESM_URL_SCHEME. Hand absolute +// paths to node as proper file:// URLs on all platforms. +function toFileSpecifier(candidatePath) { + return path.isAbsolute(candidatePath) + ? pathToFileURL(candidatePath).href + : candidatePath; +} + function resolveSourcePath(basePath) { // Existence decides, not path.extname — a dotted basename like // `ProfileAvatarEditor.utils` (→ .utils.ts on disk) looks like an @@ -63,7 +74,7 @@ export function resolve(specifier, context, nextResolve) { } if (specifier === "@features-manifest") { const resolved = path.join(repoRoot, "preview-features.json"); - return nextResolve(resolved, context); + return nextResolve(toFileSpecifier(resolved), context); } if (specifier.startsWith("@/")) { const stripped = specifier.slice(2); @@ -73,7 +84,7 @@ export function resolve(specifier, context, nextResolve) { // Otherwise paths like `@/.../foo.mjs` would be coerced into `foo.mjs.ts` // and fail to resolve. const resolved = resolveSourcePath(`${srcRoot}/${stripped}`); - return nextResolve(resolved ?? `${srcRoot}/${stripped}`, context); + return nextResolve(toFileSpecifier(resolved ?? `${srcRoot}/${stripped}`), context); } // Resolve extensionless relative TS imports (e.g. `./parseImeta`) — the app's // bundler adds the extension, but node's ESM resolver does not. Without this, @@ -90,7 +101,7 @@ export function resolve(specifier, context, nextResolve) { path.resolve(path.dirname(parentPath), specifier), ); if (resolved) { - return nextResolve(resolved, context); + return nextResolve(toFileSpecifier(resolved), context); } return nextResolve(specifier, context); }