From f8b572cb24e41f3f1d41a5290eff1b6a6267716a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 21 Feb 2022 22:29:36 -0500 Subject: [PATCH] Implement #1649: When entrypoint fails to resolve via ESM, fallback to CommonJS resolution (#1654) * WIP * fix * rather than throw our own error, throw the error from node's ESM loader --- src/esm.ts | 90 +++- src/test/esm-loader.spec.ts | 414 ++++++++++++++---- src/test/helpers.ts | 7 + src/test/index.spec.ts | 208 +-------- src/test/repl/node-repl-tla.ts | 13 +- src/test/testlib.ts | 30 +- .../extensionless-entrypoint | 1 + .../relies-upon-cjs-resolution/index.js | 1 + 8 files changed, 448 insertions(+), 316 deletions(-) create mode 100644 tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint create mode 100644 tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js diff --git a/src/esm.ts b/src/esm.ts index b27be4a0a..b42af64bd 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,86 @@ 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 + ): ReturnType { + 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 { + 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(cjsSpecifier) + ).toString(); + rememberIsProbablyEntrypoint.add(resolution); + rememberResolvedViaCommonjsFallback.add(resolution); + return { url: resolution, format: 'commonjs' }; + } catch (commonjsResolverError) { + throw esmResolverError; + } + } + } 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 +277,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 + ): ReturnType { + 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 +308,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 +362,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..d4a943798 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,354 @@ 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('moduleTypes', (test) => { + suite('with vanilla ts transpilation', 'tsconfig.json'); + suite('with third-party-transpiler', 'tsconfig-swc.json'); + function suite(name: string, tsconfig: string) { + test.suite(name, (test) => { + test('supports CJS webpack.config.ts 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, stdout } = await exec( + `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/${tsconfig} ./module-types/override-to-cjs/test-webpack-config.cjs` + ); + expect(err).toBe(null); + expect(stdout).toBe(``); + }); + test('should allow importing CJS in an otherwise ESM project', async (t) => { + await run('override-to-cjs', tsconfig, 'cjs'); + if (semver.gte(process.version, '14.13.1')) + await run('override-to-cjs', tsconfig, 'mjs'); + }); + test('should allow importing ESM in an otherwise CJS project', async (t) => { + await run('override-to-esm', tsconfig, '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 run('override-to-esm', tsconfig, 'mjs'); + }); + }); + } + async function run(project: string, config: 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}/${config}`, + }, + } + ); + expect(err).toBe(null); + expect(stdout).toBe(`Failures: 0\n`); + } + }); + + 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), + }; + }); + + 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('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!'); + }); + 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 `); + }); + } + ); }); - 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` + 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:' ); - 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 { + }); + }); + 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 b69670a64..a709e4e8a 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,210 +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(''); - }); - - test.suite('moduleTypes', (test) => { - suite('with vanilla ts transpilation', 'tsconfig.json'); - suite('with third-party-transpiler', 'tsconfig-swc.json'); - function suite(name: string, tsconfig: string) { - test.suite(name, (test) => { - test('supports CJS webpack.config.ts 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, stdout } = await exec( - `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/${tsconfig} ./module-types/override-to-cjs/test-webpack-config.cjs` - ); - expect(err).toBe(null); - expect(stdout).toBe(``); - }); - test('should allow importing CJS in an otherwise ESM project', async (t) => { - await run('override-to-cjs', tsconfig, 'cjs'); - if (semver.gte(process.version, '14.13.1')) - await run('override-to-cjs', tsconfig, 'mjs'); - }); - test('should allow importing ESM in an otherwise CJS project', async (t) => { - await run('override-to-esm', tsconfig, '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 run('override-to-esm', tsconfig, 'mjs'); - }); - }); - } - async function run(project: string, config: 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}/${config}`, - }, - } - ); - expect(err).toBe(null); - expect(stdout).toBe(`Failures: 0\n`); - } - }); - } - - 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(opts: { @@ -103,11 +109,12 @@ function createTestInterface(opts: { separator: string | undefined; mustDoSerial: boolean; automaticallyDoSerial: boolean; + automaticallySkip: boolean; beforeEachFunctions: Function[]; }): TestInterface { 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(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(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(opts: { title: string, cb: (test: TestInterface) => void ) { + suiteOrTestDeclared = true; const newApi = createTestInterface({ mustDoSerial, automaticallyDoSerial, + automaticallySkip, separator, titlePrefix: computeTitle(title), beforeEachFunctions, @@ -246,5 +266,9 @@ function createTestInterface(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/relies-upon-cjs-resolution/index.js b/tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js new file mode 100644 index 000000000..b9d3e23cb --- /dev/null +++ b/tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js @@ -0,0 +1 @@ +console.log('Hello world!');