From 013eb84e9dafa5e133d36cb137f79d3403dba1b7 Mon Sep 17 00:00:00 2001
From: Andrew Bradley <cspotcode@gmail.com>
Date: Mon, 21 Feb 2022 14:42:21 -0500
Subject: [PATCH 1/3] WIP

---
 src/esm.ts                                    |  92 ++++-
 src/test/esm-loader.spec.ts                   | 385 ++++++++++++++----
 src/test/helpers.ts                           |   7 +
 src/test/index.spec.ts                        | 201 +--------
 src/test/repl/node-repl-tla.ts                |  13 +-
 src/test/testlib.ts                           |  30 +-
 .../extensionless-entrypoint                  |   1 +
 .../requires-cjs-loader/index.js              |   1 +
 8 files changed, 421 insertions(+), 309 deletions(-)
 create mode 100644 tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint
 create mode 100644 tests/esm-loader-entrypoint-cjs-fallback/requires-cjs-loader/index.js

diff --git a/src/esm.ts b/src/esm.ts
index b27be4a0a..435975ad2 100644
--- a/src/esm.ts
+++ b/src/esm.ts
@@ -15,6 +15,7 @@ import {
 import { extname } from 'path';
 import * as assert from 'assert';
 import { normalizeSlashes } from './util';
+import { createRequire } from 'module';
 const {
   createResolve,
 } = require('../dist-raw/node-esm-resolve-implementation');
@@ -68,7 +69,7 @@ export namespace NodeLoaderHooksAPI2 {
       parentURL: string;
     },
     defaultResolve: ResolveHook
-  ) => Promise<{ url: string }>;
+  ) => Promise<{ url: string; format?: NodeLoaderHooksFormat }>;
   export type LoadHook = (
     url: string,
     context: {
@@ -123,7 +124,6 @@ export function createEsmHooks(tsNodeService: Service) {
   const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI
     ? { resolve, load, getFormat: undefined, transformSource: undefined }
     : { resolve, getFormat, transformSource, load: undefined };
-  return hooksAPI;
 
   function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) {
     // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo`
@@ -131,39 +131,88 @@ export function createEsmHooks(tsNodeService: Service) {
     return protocol === null || protocol === 'file:';
   }
 
+  /**
+   * Named "probably" as a reminder that this is a guess.
+   * node does not explicitly tell us if we're resolving the entrypoint or not.
+   */
+  function isProbablyEntrypoint(specifier: string, parentURL: string) {
+    return parentURL === undefined && specifier.startsWith('file://');
+  }
+  // Side-channel between `resolve()` and `load()` hooks
+  const rememberIsProbablyEntrypoint = new Set();
+  const rememberResolvedViaCommonjsFallback = new Set();
+
   async function resolve(
     specifier: string,
     context: { parentURL: string },
     defaultResolve: typeof resolve
-  ): Promise<{ url: string }> {
+  ): Promise<{ url: string; format?: NodeLoaderHooksFormat }> {
     const defer = async () => {
       const r = await defaultResolve(specifier, context, defaultResolve);
       return r;
     };
+    // See: https://github.com/nodejs/node/discussions/41711
+    // nodejs will likely implement a similar fallback.  Till then, we can do our users a favor and fallback today.
+    async function entrypointFallback(
+      cb: () => ReturnType<typeof resolve>
+    ): ReturnType<typeof resolve> {
+      try {
+        const resolution = await cb();
+        if (
+          resolution?.url &&
+          isProbablyEntrypoint(specifier, context.parentURL)
+        )
+          rememberIsProbablyEntrypoint.add(resolution.url);
+        return resolution;
+      } catch (esmResolverError) {
+        if (!isProbablyEntrypoint(specifier, context.parentURL))
+          throw esmResolverError;
+        try {
+          const resolution = pathToFileURL(
+            createRequire(process.cwd()).resolve(specifier)
+          ).toString();
+          rememberIsProbablyEntrypoint.add(resolution);
+          rememberResolvedViaCommonjsFallback.add(resolution);
+          return { url: resolution, format: 'commonjs' };
+        } catch (commonjsResolverError) {
+          throw new Error(
+            `Resolution via the ECMAScript loader failed.\n` +
+              `ts-node guessed that this resolution was likely the entrypoint script, so attempted a fallback to the CommonJS resolver.\n` +
+              `CommonJS resolver threw:\n` +
+              `${
+                (commonjsResolverError as Error)?.message ??
+                commonjsResolverError
+              }`
+          );
+        }
+      }
+    }
 
     const parsed = parseUrl(specifier);
     const { pathname, protocol, hostname } = parsed;
 
     if (!isFileUrlOrNodeStyleSpecifier(parsed)) {
-      return defer();
+      return entrypointFallback(defer);
     }
 
     if (protocol !== null && protocol !== 'file:') {
-      return defer();
+      return entrypointFallback(defer);
     }
 
     // Malformed file:// URL?  We should always see `null` or `''`
     if (hostname) {
       // TODO file://./foo sets `hostname` to `'.'`.  Perhaps we should special-case this.
-      return defer();
+      return entrypointFallback(defer);
     }
 
     // pathname is the path to be resolved
 
-    return nodeResolveImplementation.defaultResolve(
-      specifier,
-      context,
-      defaultResolve
+    return entrypointFallback(() =>
+      nodeResolveImplementation.defaultResolve(
+        specifier,
+        context,
+        defaultResolve
+      )
     );
   }
 
@@ -230,10 +279,23 @@ export function createEsmHooks(tsNodeService: Service) {
     const defer = (overrideUrl: string = url) =>
       defaultGetFormat(overrideUrl, context, defaultGetFormat);
 
+    // See: https://github.com/nodejs/node/discussions/41711
+    // nodejs will likely implement a similar fallback.  Till then, we can do our users a favor and fallback today.
+    async function entrypointFallback(
+      cb: () => ReturnType<typeof getFormat>
+    ): ReturnType<typeof getFormat> {
+      try {
+        return await cb();
+      } catch (getFormatError) {
+        if (!rememberIsProbablyEntrypoint.has(url)) throw getFormatError;
+        return { format: 'commonjs' };
+      }
+    }
+
     const parsed = parseUrl(url);
 
     if (!isFileUrlOrNodeStyleSpecifier(parsed)) {
-      return defer();
+      return entrypointFallback(defer);
     }
 
     const { pathname } = parsed;
@@ -248,9 +310,11 @@ export function createEsmHooks(tsNodeService: Service) {
     const ext = extname(nativePath);
     let nodeSays: { format: NodeLoaderHooksFormat };
     if (ext !== '.js' && !tsNodeService.ignored(nativePath)) {
-      nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js')));
+      nodeSays = await entrypointFallback(() =>
+        defer(formatUrl(pathToFileURL(nativePath + '.js')))
+      );
     } else {
-      nodeSays = await defer();
+      nodeSays = await entrypointFallback(defer);
     }
     // For files compiled by ts-node that node believes are either CJS or ESM, check if we should override that classification
     if (
@@ -300,4 +364,6 @@ export function createEsmHooks(tsNodeService: Service) {
 
     return { source: emittedJs };
   }
+
+  return hooksAPI;
 }
diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts
index 42dce0f0d..4d1d1a244 100644
--- a/src/test/esm-loader.spec.ts
+++ b/src/test/esm-loader.spec.ts
@@ -5,9 +5,14 @@
 import { context } from './testlib';
 import semver = require('semver');
 import {
+  BIN_PATH,
   CMD_ESM_LOADER_WITHOUT_PROJECT,
+  CMD_TS_NODE_WITHOUT_PROJECT_FLAG,
   contextTsNodeUnderTest,
   EXPERIMENTAL_MODULES_FLAG,
+  nodeSupportsEsmHooks,
+  nodeSupportsImportAssertions,
+  nodeUsesNewHooksApi,
   resetNodeEnvironment,
   TEST_DIR,
 } from './helpers';
@@ -15,9 +20,7 @@ import { createExec } from './exec-helpers';
 import { join, resolve } from 'path';
 import * as expect from 'expect';
 import type { NodeLoaderHooksAPI2 } from '../';
-
-const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0');
-const nodeSupportsImportAssertions = semver.gte(process.version, '17.1.0');
+import { pathToFileURL } from 'url';
 
 const test = context(contextTsNodeUnderTest);
 
@@ -25,119 +28,325 @@ const exec = createExec({
   cwd: TEST_DIR,
 });
 
-test.suite('createEsmHooks', (test) => {
-  if (semver.gte(process.version, '12.16.0')) {
-    test('should create proper hooks with provided instance', async () => {
-      const { err } = await exec(
-        `node ${EXPERIMENTAL_MODULES_FLAG} --loader ./loader.mjs index.ts`,
+test.suite('esm', (test) => {
+  test.suite('when node supports loader hooks', (test) => {
+    test.runIf(nodeSupportsEsmHooks);
+    test('should compile and execute as ESM', async () => {
+      const { err, stdout } = await exec(
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
         {
-          cwd: join(TEST_DIR, './esm-custom-loader'),
+          cwd: join(TEST_DIR, './esm'),
         }
       );
+      expect(err).toBe(null);
+      expect(stdout).toBe('foo bar baz biff libfoo\n');
+    });
+    test('should use source maps', async () => {
+      const { err, stdout } = await exec(
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT} "throw error.ts"`,
+        {
+          cwd: join(TEST_DIR, './esm'),
+        }
+      );
+      expect(err).not.toBe(null);
+      expect(err!.message).toMatch(
+        [
+          `${pathToFileURL(join(TEST_DIR, './esm/throw error.ts'))
+            .toString()
+            .replace(/%20/g, ' ')}:100`,
+          "  bar() { throw new Error('this is a demo'); }",
+          '                ^',
+          'Error: this is a demo',
+        ].join('\n')
+      );
+    });
 
-      if (err === null) {
-        throw new Error('Command was expected to fail, but it succeeded.');
-      }
-
-      expect(err.message).toMatch(/TS6133:\s+'unusedVar'/);
+    test.suite('supports experimental-specifier-resolution=node', (test) => {
+      test('via --experimental-specifier-resolution', async () => {
+        const { err, stdout } = await exec(
+          `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-specifier-resolution=node index.ts`,
+          { cwd: join(TEST_DIR, './esm-node-resolver') }
+        );
+        expect(err).toBe(null);
+        expect(stdout).toBe('foo bar baz biff libfoo\n');
+      });
+      test('via --es-module-specifier-resolution alias', async () => {
+        const { err, stdout } = await exec(
+          `${CMD_ESM_LOADER_WITHOUT_PROJECT} ${EXPERIMENTAL_MODULES_FLAG} --es-module-specifier-resolution=node index.ts`,
+          { cwd: join(TEST_DIR, './esm-node-resolver') }
+        );
+        expect(err).toBe(null);
+        expect(stdout).toBe('foo bar baz biff libfoo\n');
+      });
+      test('via NODE_OPTIONS', async () => {
+        const { err, stdout } = await exec(
+          `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
+          {
+            cwd: join(TEST_DIR, './esm-node-resolver'),
+            env: {
+              ...process.env,
+              NODE_OPTIONS: `${EXPERIMENTAL_MODULES_FLAG} --experimental-specifier-resolution=node`,
+            },
+          }
+        );
+        expect(err).toBe(null);
+        expect(stdout).toBe('foo bar baz biff libfoo\n');
+      });
     });
-  }
-});
 
-test.suite('hooks', (_test) => {
-  const test = _test.context(async (t) => {
-    const service = t.context.tsNodeUnderTest.create({
-      cwd: TEST_DIR,
+    test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is enabled', async () => {
+      const { err, stderr } = await exec(
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./index.js`,
+        {
+          cwd: join(TEST_DIR, './esm-err-require-esm'),
+        }
+      );
+      expect(err).not.toBe(null);
+      expect(stderr).toMatch(
+        'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:'
+      );
     });
-    t.teardown(() => {
-      resetNodeEnvironment();
+
+    test('defers to fallback loaders when URL should not be handled by ts-node', async () => {
+      const { err, stdout, stderr } = await exec(
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.mjs`,
+        {
+          cwd: join(TEST_DIR, './esm-import-http-url'),
+        }
+      );
+      expect(err).not.toBe(null);
+      // expect error from node's default resolver
+      expect(stderr).toMatch(
+        /Error \[ERR_UNSUPPORTED_ESM_URL_SCHEME\]:.*(?:\n.*){0,2}\n *at defaultResolve/
+      );
     });
-    return {
-      service,
-      hooks: t.context.tsNodeUnderTest.createEsmHooks(service),
-    };
-  });
 
-  if (nodeUsesNewHooksApi) {
-    test('Correctly determines format of data URIs', async (t) => {
-      const { hooks } = t.context;
-      const url = 'data:text/javascript,console.log("hello world");';
-      const result = await (hooks as NodeLoaderHooksAPI2).load(
-        url,
-        { format: undefined },
-        async (url, context, _ignored) => {
-          return { format: context.format!, source: '' };
+    test('should bypass import cache when changing search params', async () => {
+      const { err, stdout } = await exec(
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
+        {
+          cwd: join(TEST_DIR, './esm-import-cache'),
         }
       );
-      expect(result.format).toBe('module');
+      expect(err).toBe(null);
+      expect(stdout).toBe('log1\nlog2\nlog2\n');
     });
-  }
-});
 
-if (nodeSupportsImportAssertions) {
-  test.suite('Supports import assertions', (test) => {
-    test('Can import JSON using the appropriate flag and assertion', async (t) => {
+    test('should support transpile only mode via dedicated loader entrypoint', async () => {
       const { err, stdout } = await exec(
-        `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`,
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT}/transpile-only index.ts`,
         {
-          cwd: resolve(TEST_DIR, 'esm-import-assertions'),
+          cwd: join(TEST_DIR, './esm-transpile-only'),
         }
       );
       expect(err).toBe(null);
-      expect(stdout.trim()).toBe(
-        'A fuchsia car has 2 seats and the doors are open.\nDone!'
+      expect(stdout).toBe('');
+    });
+    test('should throw type errors without transpile-only enabled', async () => {
+      const { err, stdout } = await exec(
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
+        {
+          cwd: join(TEST_DIR, './esm-transpile-only'),
+        }
       );
+      if (err === null) {
+        throw new Error('Command was expected to fail, but it succeeded.');
+      }
+
+      expect(err.message).toMatch('Unable to compile TypeScript');
+      expect(err.message).toMatch(
+        new RegExp(
+          "TS2345: Argument of type '(?:number|1101)' is not assignable to parameter of type 'string'\\."
+        )
+      );
+      expect(err.message).toMatch(
+        new RegExp(
+          "TS2322: Type '(?:\"hello world\"|string)' is not assignable to type 'number'\\."
+        )
+      );
+      expect(stdout).toBe('');
     });
-  });
 
-  test.suite("Catch unexpected changes to node's loader context", (test) => {
-    /*
-     * This does not test ts-node.
-     * Rather, it is meant to alert us to potentially breaking changes in node's
-     * loader API.  If node starts returning more or less properties on `context`
-     * objects, we want to know, because it may indicate that our loader code
-     * should be updated to accomodate the new properties, either by proxying them,
-     * modifying them, or suppressing them.
-     */
-    test('Ensure context passed to loader by node has only expected properties', async (t) => {
-      const { stdout, stderr } = await exec(
-        `node --loader ./esm-loader-context/loader.mjs --experimental-json-modules ./esm-loader-context/index.mjs`
+    async function runModuleTypeTest(project: string, ext: string) {
+      const { err, stderr, stdout } = await exec(
+        `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./module-types/${project}/test.${ext}`,
+        {
+          env: {
+            ...process.env,
+            TS_NODE_PROJECT: `./module-types/${project}/tsconfig.json`,
+          },
+        }
       );
-      const rows = stdout.split('\n').filter((v) => v[0] === '{');
-      expect(rows.length).toBe(14);
-      rows.forEach((row) => {
-        const json = JSON.parse(row) as {
-          resolveContextKeys?: string[];
-          loadContextKeys?: string;
+      expect(err).toBe(null);
+      expect(stdout).toBe(`Failures: 0\n`);
+    }
+
+    test('moduleTypes should allow importing CJS in an otherwise ESM project', async (t) => {
+      // A notable case where you can use ts-node's CommonJS loader, not the ESM loader, in an ESM project:
+      // when loading a webpack.config.ts or similar config
+      const { err, stderr, stdout } = await exec(
+        `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/tsconfig.json ./module-types/override-to-cjs/test-webpack-config.cjs`
+      );
+      expect(err).toBe(null);
+      expect(stdout).toBe(``);
+
+      await runModuleTypeTest('override-to-cjs', 'cjs');
+      if (semver.gte(process.version, '14.13.1'))
+        await runModuleTypeTest('override-to-cjs', 'mjs');
+    });
+
+    test('moduleTypes should allow importing ESM in an otherwise CJS project', async (t) => {
+      await runModuleTypeTest('override-to-esm', 'cjs');
+      // Node 14.13.0 has a bug(?) where it checks for ESM-only syntax *before* we transform the code.
+      if (semver.gte(process.version, '14.13.1'))
+        await runModuleTypeTest('override-to-esm', 'mjs');
+    });
+
+    test.suite('createEsmHooks()', (test) => {
+      test('should create proper hooks with provided instance', async () => {
+        const { err } = await exec(
+          `node ${EXPERIMENTAL_MODULES_FLAG} --loader ./loader.mjs index.ts`,
+          {
+            cwd: join(TEST_DIR, './esm-custom-loader'),
+          }
+        );
+
+        if (err === null) {
+          throw new Error('Command was expected to fail, but it succeeded.');
+        }
+
+        expect(err.message).toMatch(/TS6133:\s+'unusedVar'/);
+      });
+    });
+
+    test.suite('unit test hooks', (_test) => {
+      const test = _test.context(async (t) => {
+        const service = t.context.tsNodeUnderTest.create({
+          cwd: TEST_DIR,
+        });
+        t.teardown(() => {
+          resetNodeEnvironment();
+        });
+        return {
+          service,
+          hooks: t.context.tsNodeUnderTest.createEsmHooks(service),
         };
-        if (json.resolveContextKeys) {
-          expect(json.resolveContextKeys).toEqual([
-            'conditions',
-            'importAssertions',
-            'parentURL',
-          ]);
-        } else if (json.loadContextKeys) {
-          try {
+      });
+
+      test.suite('data URIs', (test) => {
+        test.runIf(nodeUsesNewHooksApi);
+
+        test('Correctly determines format of data URIs', async (t) => {
+          const { hooks } = t.context;
+          const url = 'data:text/javascript,console.log("hello world");';
+          const result = await (hooks as NodeLoaderHooksAPI2).load(
+            url,
+            { format: undefined },
+            async (url, context, _ignored) => {
+              return { format: context.format!, source: '' };
+            }
+          );
+          expect(result.format).toBe('module');
+        });
+      });
+    });
+
+    test.suite('supports import assertions', (test) => {
+      test.runIf(nodeSupportsImportAssertions);
+
+      test('Can import JSON using the appropriate flag and assertion', async (t) => {
+        const { err, stdout } = await exec(
+          `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`,
+          {
+            cwd: resolve(TEST_DIR, 'esm-import-assertions'),
+          }
+        );
+        expect(err).toBe(null);
+        expect(stdout.trim()).toBe(
+          'A fuchsia car has 2 seats and the doors are open.\nDone!'
+        );
+      });
+    });
+
+    test.suite(
+      'Entrypoint resolution falls back to CommonJS resolver and format',
+      (test) => {}
+    );
+  });
+
+  test.suite('node >= 12.x.x', (test) => {
+    test.runIf(semver.gte(process.version, '12.0.0'));
+    test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is *not* enabled and node version is >= 12', async () => {
+      // Node versions >= 12 support package.json "type" field and so will throw an error when attempting to load ESM as CJS
+      const { err, stderr } = await exec(`${BIN_PATH} ./index.js`, {
+        cwd: join(TEST_DIR, './esm-err-require-esm'),
+      });
+      expect(err).not.toBe(null);
+      expect(stderr).toMatch(
+        'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:'
+      );
+    });
+  });
+  test.suite('node < 12.x.x', (test) => {
+    test.runIf(semver.lt(process.version, '12.0.0'));
+    test('Loads as CommonJS when attempting to require() an ESM script when ESM loader is *not* enabled and node version is < 12', async () => {
+      // Node versions less than 12 do not support package.json "type" field and so will load ESM as CommonJS
+      const { err, stdout } = await exec(`${BIN_PATH} ./index.js`, {
+        cwd: join(TEST_DIR, './esm-err-require-esm'),
+      });
+      expect(err).toBe(null);
+      expect(stdout).toMatch('CommonJS');
+    });
+  });
+});
+
+test.suite("Catch unexpected changes to node's loader context", (test) => {
+  // loader context includes import assertions, therefore this test requires support for import assertions
+  test.runIf(nodeSupportsImportAssertions);
+
+  /*
+   * This does not test ts-node.
+   * Rather, it is meant to alert us to potentially breaking changes in node's
+   * loader API.  If node starts returning more or less properties on `context`
+   * objects, we want to know, because it may indicate that our loader code
+   * should be updated to accomodate the new properties, either by proxying them,
+   * modifying them, or suppressing them.
+   */
+  test('Ensure context passed to loader by node has only expected properties', async (t) => {
+    const { stdout, stderr } = await exec(
+      `node --loader ./esm-loader-context/loader.mjs --experimental-json-modules ./esm-loader-context/index.mjs`
+    );
+    const rows = stdout.split('\n').filter((v) => v[0] === '{');
+    expect(rows.length).toBe(14);
+    rows.forEach((row) => {
+      const json = JSON.parse(row) as {
+        resolveContextKeys?: string[];
+        loadContextKeys?: string;
+      };
+      if (json.resolveContextKeys) {
+        expect(json.resolveContextKeys).toEqual([
+          'conditions',
+          'importAssertions',
+          'parentURL',
+        ]);
+      } else if (json.loadContextKeys) {
+        try {
+          expect(json.loadContextKeys).toEqual(['format', 'importAssertions']);
+        } catch (e) {
+          // HACK for https://github.com/TypeStrong/ts-node/issues/1641
+          if (process.version.includes('nightly')) {
             expect(json.loadContextKeys).toEqual([
               'format',
               'importAssertions',
+              'parentURL',
             ]);
-          } catch (e) {
-            // HACK for https://github.com/TypeStrong/ts-node/issues/1641
-            if (process.version.includes('nightly')) {
-              expect(json.loadContextKeys).toEqual([
-                'format',
-                'importAssertions',
-                'parentURL',
-              ]);
-            } else {
-              throw e;
-            }
+          } else {
+            throw e;
           }
-        } else {
-          throw new Error('Unexpected stdout in test.');
         }
-      });
+      } else {
+        throw new Error('Unexpected stdout in test.');
+      }
     });
   });
-}
+});
diff --git a/src/test/helpers.ts b/src/test/helpers.ts
index 83d45d3f9..2ff36f157 100644
--- a/src/test/helpers.ts
+++ b/src/test/helpers.ts
@@ -17,6 +17,13 @@ import semver = require('semver');
 const createRequire: typeof _createRequire = require('create-require');
 export { tsNodeTypes };
 
+export const nodeSupportsEsmHooks = semver.gte(process.version, '12.16.0');
+export const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0');
+export const nodeSupportsImportAssertions = semver.gte(
+  process.version,
+  '17.1.0'
+);
+
 export const ROOT_DIR = resolve(__dirname, '../..');
 export const DIST_DIR = resolve(__dirname, '..');
 export const TEST_DIR = join(__dirname, '../../tests');
diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts
index 8eef6bc0f..8539de5ed 100644
--- a/src/test/index.spec.ts
+++ b/src/test/index.spec.ts
@@ -3,7 +3,7 @@ import * as expect from 'expect';
 import { join, resolve, sep as pathSep } from 'path';
 import { tmpdir } from 'os';
 import semver = require('semver');
-import { ts } from './helpers';
+import { nodeSupportsEsmHooks, ts } from './helpers';
 import { lstatSync, mkdtempSync } from 'fs';
 import { npath } from '@yarnpkg/fslib';
 import type _createRequire from 'create-require';
@@ -359,7 +359,7 @@ test.suite('ts-node', (test) => {
       });
     });
 
-    if (semver.gte(process.version, '12.16.0')) {
+    if (nodeSupportsEsmHooks) {
       test('swc transpiler supports native ESM emit', async () => {
         const { err, stdout } = await exec(
           `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./index.ts`,
@@ -1058,203 +1058,6 @@ test.suite('ts-node', (test) => {
       );
     });
   });
-
-  test.suite('esm', (test) => {
-    if (semver.gte(process.version, '12.16.0')) {
-      test('should compile and execute as ESM', async () => {
-        const { err, stdout } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
-          {
-            cwd: join(TEST_DIR, './esm'),
-          }
-        );
-        expect(err).toBe(null);
-        expect(stdout).toBe('foo bar baz biff libfoo\n');
-      });
-      test('should use source maps', async () => {
-        const { err, stdout } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT} "throw error.ts"`,
-          {
-            cwd: join(TEST_DIR, './esm'),
-          }
-        );
-        expect(err).not.toBe(null);
-        expect(err!.message).toMatch(
-          [
-            `${pathToFileURL(join(TEST_DIR, './esm/throw error.ts'))
-              .toString()
-              .replace(/%20/g, ' ')}:100`,
-            "  bar() { throw new Error('this is a demo'); }",
-            '                ^',
-            'Error: this is a demo',
-          ].join('\n')
-        );
-      });
-
-      test.suite('supports experimental-specifier-resolution=node', (test) => {
-        test('via --experimental-specifier-resolution', async () => {
-          const { err, stdout } = await exec(
-            `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-specifier-resolution=node index.ts`,
-            { cwd: join(TEST_DIR, './esm-node-resolver') }
-          );
-          expect(err).toBe(null);
-          expect(stdout).toBe('foo bar baz biff libfoo\n');
-        });
-        test('via --es-module-specifier-resolution alias', async () => {
-          const { err, stdout } = await exec(
-            `${CMD_ESM_LOADER_WITHOUT_PROJECT} ${EXPERIMENTAL_MODULES_FLAG} --es-module-specifier-resolution=node index.ts`,
-            { cwd: join(TEST_DIR, './esm-node-resolver') }
-          );
-          expect(err).toBe(null);
-          expect(stdout).toBe('foo bar baz biff libfoo\n');
-        });
-        test('via NODE_OPTIONS', async () => {
-          const { err, stdout } = await exec(
-            `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
-            {
-              cwd: join(TEST_DIR, './esm-node-resolver'),
-              env: {
-                ...process.env,
-                NODE_OPTIONS: `${EXPERIMENTAL_MODULES_FLAG} --experimental-specifier-resolution=node`,
-              },
-            }
-          );
-          expect(err).toBe(null);
-          expect(stdout).toBe('foo bar baz biff libfoo\n');
-        });
-      });
-
-      test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is enabled', async () => {
-        const { err, stderr } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./index.js`,
-          {
-            cwd: join(TEST_DIR, './esm-err-require-esm'),
-          }
-        );
-        expect(err).not.toBe(null);
-        expect(stderr).toMatch(
-          'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:'
-        );
-      });
-
-      test('defers to fallback loaders when URL should not be handled by ts-node', async () => {
-        const { err, stdout, stderr } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.mjs`,
-          {
-            cwd: join(TEST_DIR, './esm-import-http-url'),
-          }
-        );
-        expect(err).not.toBe(null);
-        // expect error from node's default resolver
-        expect(stderr).toMatch(
-          /Error \[ERR_UNSUPPORTED_ESM_URL_SCHEME\]:.*(?:\n.*){0,2}\n *at defaultResolve/
-        );
-      });
-
-      test('should bypass import cache when changing search params', async () => {
-        const { err, stdout } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
-          {
-            cwd: join(TEST_DIR, './esm-import-cache'),
-          }
-        );
-        expect(err).toBe(null);
-        expect(stdout).toBe('log1\nlog2\nlog2\n');
-      });
-
-      test('should support transpile only mode via dedicated loader entrypoint', async () => {
-        const { err, stdout } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT}/transpile-only index.ts`,
-          {
-            cwd: join(TEST_DIR, './esm-transpile-only'),
-          }
-        );
-        expect(err).toBe(null);
-        expect(stdout).toBe('');
-      });
-      test('should throw type errors without transpile-only enabled', async () => {
-        const { err, stdout } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`,
-          {
-            cwd: join(TEST_DIR, './esm-transpile-only'),
-          }
-        );
-        if (err === null) {
-          throw new Error('Command was expected to fail, but it succeeded.');
-        }
-
-        expect(err.message).toMatch('Unable to compile TypeScript');
-        expect(err.message).toMatch(
-          new RegExp(
-            "TS2345: Argument of type '(?:number|1101)' is not assignable to parameter of type 'string'\\."
-          )
-        );
-        expect(err.message).toMatch(
-          new RegExp(
-            "TS2322: Type '(?:\"hello world\"|string)' is not assignable to type 'number'\\."
-          )
-        );
-        expect(stdout).toBe('');
-      });
-
-      async function runModuleTypeTest(project: string, ext: string) {
-        const { err, stderr, stdout } = await exec(
-          `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./module-types/${project}/test.${ext}`,
-          {
-            env: {
-              ...process.env,
-              TS_NODE_PROJECT: `./module-types/${project}/tsconfig.json`,
-            },
-          }
-        );
-        expect(err).toBe(null);
-        expect(stdout).toBe(`Failures: 0\n`);
-      }
-
-      test('moduleTypes should allow importing CJS in an otherwise ESM project', async (t) => {
-        // A notable case where you can use ts-node's CommonJS loader, not the ESM loader, in an ESM project:
-        // when loading a webpack.config.ts or similar config
-        const { err, stderr, stdout } = await exec(
-          `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/tsconfig.json ./module-types/override-to-cjs/test-webpack-config.cjs`
-        );
-        expect(err).toBe(null);
-        expect(stdout).toBe(``);
-
-        await runModuleTypeTest('override-to-cjs', 'cjs');
-        if (semver.gte(process.version, '14.13.1'))
-          await runModuleTypeTest('override-to-cjs', 'mjs');
-      });
-
-      test('moduleTypes should allow importing ESM in an otherwise CJS project', async (t) => {
-        await runModuleTypeTest('override-to-esm', 'cjs');
-        // Node 14.13.0 has a bug(?) where it checks for ESM-only syntax *before* we transform the code.
-        if (semver.gte(process.version, '14.13.1'))
-          await runModuleTypeTest('override-to-esm', 'mjs');
-      });
-    }
-
-    if (semver.gte(process.version, '12.0.0')) {
-      test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is *not* enabled and node version is >= 12', async () => {
-        // Node versions >= 12 support package.json "type" field and so will throw an error when attempting to load ESM as CJS
-        const { err, stderr } = await exec(`${BIN_PATH} ./index.js`, {
-          cwd: join(TEST_DIR, './esm-err-require-esm'),
-        });
-        expect(err).not.toBe(null);
-        expect(stderr).toMatch(
-          'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:'
-        );
-      });
-    } else {
-      test('Loads as CommonJS when attempting to require() an ESM script when ESM loader is *not* enabled and node version is < 12', async () => {
-        // Node versions less than 12 do not support package.json "type" field and so will load ESM as CommonJS
-        const { err, stdout } = await exec(`${BIN_PATH} ./index.js`, {
-          cwd: join(TEST_DIR, './esm-err-require-esm'),
-        });
-        expect(err).toBe(null);
-        expect(stdout).toMatch('CommonJS');
-      });
-    }
-  });
 });
 
 test('Falls back to transpileOnly when ts compiler returns emitSkipped', async () => {
diff --git a/src/test/repl/node-repl-tla.ts b/src/test/repl/node-repl-tla.ts
index 5c4962e78..c3926566f 100644
--- a/src/test/repl/node-repl-tla.ts
+++ b/src/test/repl/node-repl-tla.ts
@@ -4,6 +4,7 @@ import { Stream } from 'stream';
 import semver = require('semver');
 import { ts } from '../helpers';
 import type { ContextWithTsNodeUnderTest } from './helpers';
+import { nodeSupportsEsmHooks } from '../helpers';
 
 interface SharedObjects extends ContextWithTsNodeUnderTest {
   TEST_DIR: string;
@@ -127,12 +128,12 @@ export async function upstreamTopLevelAwaitTests({
     [
       'Bar',
       // Adjusted since ts-node supports older versions of node
-      semver.gte(process.version, '12.16.0')
+      nodeSupportsEsmHooks
         ? 'Uncaught ReferenceError: Bar is not defined'
         : 'ReferenceError: Bar is not defined',
       // Line increased due to TS added lines
       {
-        line: semver.gte(process.version, '12.16.0') ? 4 : 5,
+        line: nodeSupportsEsmHooks ? 4 : 5,
       },
     ],
 
@@ -144,12 +145,12 @@ export async function upstreamTopLevelAwaitTests({
     [
       'j',
       // Adjusted since ts-node supports older versions of node
-      semver.gte(process.version, '12.16.0')
+      nodeSupportsEsmHooks
         ? 'Uncaught ReferenceError: j is not defined'
         : 'ReferenceError: j is not defined',
       // Line increased due to TS added lines
       {
-        line: semver.gte(process.version, '12.16.0') ? 4 : 5,
+        line: nodeSupportsEsmHooks ? 4 : 5,
       },
     ],
 
@@ -158,12 +159,12 @@ export async function upstreamTopLevelAwaitTests({
     [
       'return 42; await 5;',
       // Adjusted since ts-node supports older versions of node
-      semver.gte(process.version, '12.16.0')
+      nodeSupportsEsmHooks
         ? 'Uncaught SyntaxError: Illegal return statement'
         : 'SyntaxError: Illegal return statement',
       // Line increased due to TS added lines
       {
-        line: semver.gte(process.version, '12.16.0') ? 4 : 5,
+        line: nodeSupportsEsmHooks ? 4 : 5,
       },
     ],
 
diff --git a/src/test/testlib.ts b/src/test/testlib.ts
index 4ce806dd5..377d93ef3 100644
--- a/src/test/testlib.ts
+++ b/src/test/testlib.ts
@@ -34,6 +34,7 @@ export const test = createTestInterface({
   beforeEachFunctions: [],
   mustDoSerial: false,
   automaticallyDoSerial: false,
+  automaticallySkip: false,
   separator: ' > ',
   titlePrefix: undefined,
 });
@@ -96,6 +97,11 @@ export interface TestInterface<
 
   runSerially(): void;
 
+  /** Skip tests unless this condition is met */
+  skipUnless(conditional: boolean): void;
+  /** If conditional is true, run tests, otherwise skip them */
+  runIf(conditional: boolean): void;
+
   // TODO add teardownEach
 }
 function createTestInterface<Context>(opts: {
@@ -103,11 +109,12 @@ function createTestInterface<Context>(opts: {
   separator: string | undefined;
   mustDoSerial: boolean;
   automaticallyDoSerial: boolean;
+  automaticallySkip: boolean;
   beforeEachFunctions: Function[];
 }): TestInterface<Context> {
   const { titlePrefix, separator = ' > ' } = opts;
   const beforeEachFunctions = [...(opts.beforeEachFunctions ?? [])];
-  let { mustDoSerial, automaticallyDoSerial } = opts;
+  let { mustDoSerial, automaticallyDoSerial, automaticallySkip } = opts;
   let hookDeclared = false;
   let suiteOrTestDeclared = false;
   function computeTitle(title: string | undefined) {
@@ -142,13 +149,20 @@ function createTestInterface<Context>(opts: {
     }
     hookDeclared = true;
   }
+  function assertOrderingForDeclaringSkipUnless() {
+    if (suiteOrTestDeclared) {
+      throw new Error(
+        'skipUnless or runIf must be declared before declaring sub-suites or tests'
+      );
+    }
+  }
   /**
    * @param avaDeclareFunction either test or test.serial
    */
   function declareTest(
     title: string | undefined,
     macros: Function[],
-    avaDeclareFunction: Function,
+    avaDeclareFunction: Function & { skip: Function },
     args: any[]
   ) {
     const wrappedMacros = macros.map((macro) => {
@@ -164,7 +178,11 @@ function createTestInterface<Context>(opts: {
       };
     });
     const computedTitle = computeTitle(title);
-    avaDeclareFunction(computedTitle, wrappedMacros, ...args);
+    (automaticallySkip ? avaDeclareFunction.skip : avaDeclareFunction)(
+      computedTitle,
+      wrappedMacros,
+      ...args
+    );
   }
   function test(...inputArgs: any[]) {
     assertOrderingForDeclaringTest();
@@ -234,9 +252,11 @@ function createTestInterface<Context>(opts: {
     title: string,
     cb: (test: TestInterface<Context>) => void
   ) {
+    suiteOrTestDeclared = true;
     const newApi = createTestInterface<Context>({
       mustDoSerial,
       automaticallyDoSerial,
+      automaticallySkip,
       separator,
       titlePrefix: computeTitle(title),
       beforeEachFunctions,
@@ -246,5 +266,9 @@ function createTestInterface<Context>(opts: {
   test.runSerially = function () {
     automaticallyDoSerial = true;
   };
+  test.skipUnless = test.runIf = function (runIfTrue: boolean) {
+    assertOrderingForDeclaringSkipUnless();
+    automaticallySkip = automaticallySkip || !runIfTrue;
+  };
   return test as any;
 }
diff --git a/tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint b/tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint
new file mode 100644
index 000000000..b9d3e23cb
--- /dev/null
+++ b/tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint
@@ -0,0 +1 @@
+console.log('Hello world!');
diff --git a/tests/esm-loader-entrypoint-cjs-fallback/requires-cjs-loader/index.js b/tests/esm-loader-entrypoint-cjs-fallback/requires-cjs-loader/index.js
new file mode 100644
index 000000000..b9d3e23cb
--- /dev/null
+++ b/tests/esm-loader-entrypoint-cjs-fallback/requires-cjs-loader/index.js
@@ -0,0 +1 @@
+console.log('Hello world!');

From 203325bba830b747988b11b7de70a4cb7d6137e7 Mon Sep 17 00:00:00 2001
From: Andrew Bradley <cspotcode@gmail.com>
Date: Mon, 21 Feb 2022 16:48:04 -0500
Subject: [PATCH 2/3] fix

---
 src/esm.ts                                      |  8 +++++++-
 src/test/esm-loader.spec.ts                     | 17 ++++++++++++++++-
 .../index.js                                    |  0
 3 files changed, 23 insertions(+), 2 deletions(-)
 rename tests/esm-loader-entrypoint-cjs-fallback/{requires-cjs-loader => relies-upon-cjs-resolution}/index.js (100%)

diff --git a/src/esm.ts b/src/esm.ts
index 435975ad2..0c91201cb 100644
--- a/src/esm.ts
+++ b/src/esm.ts
@@ -168,8 +168,14 @@ export function createEsmHooks(tsNodeService: Service) {
         if (!isProbablyEntrypoint(specifier, context.parentURL))
           throw esmResolverError;
         try {
+          let cjsSpecifier = specifier;
+          // Attempt to convert from ESM file:// to CommonJS path
+          try {
+            if (specifier.startsWith('file://'))
+              cjsSpecifier = fileURLToPath(specifier);
+          } catch {}
           const resolution = pathToFileURL(
-            createRequire(process.cwd()).resolve(specifier)
+            createRequire(process.cwd()).resolve(cjsSpecifier)
           ).toString();
           rememberIsProbablyEntrypoint.add(resolution);
           rememberResolvedViaCommonjsFallback.add(resolution);
diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts
index 2b408dfaa..da85437fe 100644
--- a/src/test/esm-loader.spec.ts
+++ b/src/test/esm-loader.spec.ts
@@ -277,7 +277,22 @@ test.suite('esm', (test) => {
 
     test.suite(
       'Entrypoint resolution falls back to CommonJS resolver and format',
-      (test) => {}
+      (test) => {
+        test('extensionless entrypoint', async (t) => {
+          const { err, stdout } = await exec(
+            `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint`
+          );
+          expect(err).toBe(null);
+          expect(stdout.trim()).toBe('Hello world!');
+        });
+        test('relies upon CommonJS resolution', async (t) => {
+          const { err, stdout } = await exec(
+            `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution`
+          );
+          expect(err).toBe(null);
+          expect(stdout.trim()).toBe('Hello world!');
+        });
+      }
     );
   });
 
diff --git a/tests/esm-loader-entrypoint-cjs-fallback/requires-cjs-loader/index.js b/tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js
similarity index 100%
rename from tests/esm-loader-entrypoint-cjs-fallback/requires-cjs-loader/index.js
rename to tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js

From b0eb980d9e980480eaf329bbdb28bfb0736abf3d Mon Sep 17 00:00:00 2001
From: Andrew Bradley <cspotcode@gmail.com>
Date: Mon, 21 Feb 2022 17:14:59 -0500
Subject: [PATCH 3/3] rather than throw our own error, throw the error from
 node's ESM loader

---
 src/esm.ts                  | 10 +---------
 src/test/esm-loader.spec.ts |  7 +++++++
 2 files changed, 8 insertions(+), 9 deletions(-)

diff --git a/src/esm.ts b/src/esm.ts
index 0c91201cb..b42af64bd 100644
--- a/src/esm.ts
+++ b/src/esm.ts
@@ -181,15 +181,7 @@ export function createEsmHooks(tsNodeService: Service) {
           rememberResolvedViaCommonjsFallback.add(resolution);
           return { url: resolution, format: 'commonjs' };
         } catch (commonjsResolverError) {
-          throw new Error(
-            `Resolution via the ECMAScript loader failed.\n` +
-              `ts-node guessed that this resolution was likely the entrypoint script, so attempted a fallback to the CommonJS resolver.\n` +
-              `CommonJS resolver threw:\n` +
-              `${
-                (commonjsResolverError as Error)?.message ??
-                commonjsResolverError
-              }`
-          );
+          throw esmResolverError;
         }
       }
     }
diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts
index da85437fe..d4a943798 100644
--- a/src/test/esm-loader.spec.ts
+++ b/src/test/esm-loader.spec.ts
@@ -292,6 +292,13 @@ test.suite('esm', (test) => {
           expect(err).toBe(null);
           expect(stdout.trim()).toBe('Hello world!');
         });
+        test('fails as expected when entrypoint does not exist at all', async (t) => {
+          const { err, stderr } = await exec(
+            `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./esm-loader-entrypoint-cjs-fallback/does-not-exist`
+          );
+          expect(err).toBeDefined();
+          expect(stderr).toContain(`Cannot find module `);
+        });
       }
     );
   });