diff --git a/README.md b/README.md index 40a40947f..cb2fb9981 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ To test your version of `env` for compatibility with `-S`: ## node flags and other tools -You can register ts-node without using our CLI: `node -r ts-node/register` and `node --loader ts-node/esm` +You can register ts-node without using our CLI: `node -r ts-node/register`, `node --loader ts-node/esm`, or `node --import ts-node/import` in node 20.6 and above. In many cases, setting [`NODE_OPTIONS`](https://nodejs.org/api/cli.html#cli_node_options_options) will enable `ts-node` within other node tools, child processes, and worker threads. This can be combined with other node flags. diff --git a/ava.config.cjs b/ava.config.cjs index 5532195d4..9879cd1d8 100644 --- a/ava.config.cjs +++ b/ava.config.cjs @@ -16,7 +16,7 @@ module.exports = { CONCURRENT_TESTS: '4' }, require: ['./src/test/remove-env-var-force-color.js'], - nodeArguments: ['--loader', './src/test/test-loader.mjs', '--no-warnings'], + nodeArguments: ['--loader', './src/test/test-loader/loader.mjs', '--no-warnings'], timeout: '300s', concurrency: 4, // We do chdir -- maybe other things -- that you can't do in worker_threads. diff --git a/child-loader.mjs b/child-loader.mjs index 6ef592ac5..4c5c22393 100644 --- a/child-loader.mjs +++ b/child-loader.mjs @@ -1,8 +1,11 @@ -import { fileURLToPath } from 'url'; import { createRequire } from 'module'; +import { fileURLToPath } from 'url'; + const require = createRequire(fileURLToPath(import.meta.url)); // TODO why use require() here? I think we can just `import` /** @type {import('./dist/child-loader')} */ const childLoader = require('./dist/child/child-loader'); -export const { resolve, load, getFormat, transformSource } = childLoader; +export const { resolve, load, getFormat, transformSource, bindFromLoaderThread } = childLoader; + +bindFromLoaderThread(import.meta.url); diff --git a/esm.mjs b/esm.mjs index c09e49095..07ed7008a 100644 --- a/esm.mjs +++ b/esm.mjs @@ -4,4 +4,4 @@ const require = createRequire(fileURLToPath(import.meta.url)); /** @type {import('./dist/esm')} */ const esm = require('./dist/esm'); -export const { resolve, load, getFormat, transformSource } = esm.registerAndCreateEsmHooks(); +export const { initialize, resolve, load, getFormat, transformSource, globalPreload } = esm.registerAndCreateEsmHooks(); diff --git a/package.json b/package.json index c55deb400..be71f93a4 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "homepage": "https://typestrong.org/ts-node", "devDependencies": { "@TypeStrong/fs-fixture-builder": "https://github.com/Typestrong/fs-fixture-builder.git#3099e53621daf99db971af29c96145dc115693cd", - "@cspotcode/ava-lib": "https://github.com/cspotcode/ava-lib#bbbed83f393342b51dc6caf2ddf775a3e89371d8", + "@cspotcode/ava-lib": "https://github.com/cspotcode/ava-lib#805aab17b2b89c388596b6dc2b4eece403c5fb87", "@cspotcode/expect-stream": "https://github.com/cspotcode/node-expect-stream#4e425ff1eef240003af8716291e80fbaf3e3ae8f", "@microsoft/api-extractor": "^7.19.4", "@swc/core": "1.3.32", diff --git a/src/bin.ts b/src/bin.ts index cbe34caf8..5015d6a74 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -71,6 +71,7 @@ export interface BootstrapState { parseArgvResult: ReturnType; phase2Result?: ReturnType; phase3Result?: ReturnType; + isLoaderThread?: boolean; } /** @internal */ @@ -441,7 +442,7 @@ function getEntryPointInfo(state: BootstrapState) { } function phase4(payload: BootstrapState) { - const { isInChildProcess, tsNodeScript } = payload; + const { isInChildProcess, tsNodeScript, isLoaderThread } = payload; const { version, showConfig, restArgs, code, print, argv } = payload.parseArgvResult; const { cwd } = payload.phase2Result!; const { preloadedConfig } = payload.phase3Result!; @@ -522,8 +523,12 @@ function phase4(payload: BootstrapState) { if (replStuff) replStuff.state.path = join(cwd, REPL_FILENAME(service.ts.version)); - if (isInChildProcess) + if (isInChildProcess) { (require('./child/child-loader') as typeof import('./child/child-loader')).lateBindHooks(createEsmHooks(service)); + // we should not do anything else at this point in the loader thread, + // let the entrypoint run the actual program. + if (isLoaderThread) return; + } // Bind REPL service to ts-node compiler service (chicken-and-egg problem) replStuff?.repl.setService(service); diff --git a/src/child/child-loader.ts b/src/child/child-loader.ts index 8d07c32cf..a68d1e3f0 100644 --- a/src/child/child-loader.ts +++ b/src/child/child-loader.ts @@ -1,5 +1,24 @@ import type { NodeLoaderHooksAPI1, NodeLoaderHooksAPI2 } from '..'; import { filterHooksByAPIVersion } from '../esm'; +import { URL } from 'url'; +import { bootstrap } from '../bin'; +import { versionGteLt } from '../util'; +import { argPrefix, decompress } from './argv-payload'; + +// On node v20, we cannot lateBind the hooks from outside the loader thread +// so it has to be done in the loader thread. +export function bindFromLoaderThread(loaderURL: string) { + // If we aren't in a loader thread, then skip this step. + if (!versionGteLt(process.versions.node, '20.0.0')) return; + + const url = new URL(loaderURL); + const base64Payload = url.searchParams.get(argPrefix); + if (!base64Payload) throw new Error('unexpected loader url'); + const state = decompress(base64Payload); + state.isInChildProcess = true; + state.isLoaderThread = true; + bootstrap(state); +} let hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2; diff --git a/src/child/spawn-child.ts b/src/child/spawn-child.ts index 2859a61d9..1c270e7fe 100644 --- a/src/child/spawn-child.ts +++ b/src/child/spawn-child.ts @@ -10,6 +10,10 @@ import { argPrefix, compress } from './argv-payload'; * the child process. */ export function callInChild(state: BootstrapState) { + const loaderURL = pathToFileURL(require.resolve('../../child-loader.mjs')); + const compressedState = compress(state); + loaderURL.searchParams.set(argPrefix, compressedState); + const child = spawn( process.execPath, [ @@ -17,9 +21,9 @@ export function callInChild(state: BootstrapState) { require.resolve('./child-require.js'), '--loader', // Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/` - pathToFileURL(require.resolve('../../child-loader.mjs')).toString(), + loaderURL.toString(), require.resolve('./child-entrypoint.js'), - `${argPrefix}${compress(state)}`, + `${argPrefix}${compressedState}`, ...state.parseArgvResult.restArgs, ], { diff --git a/src/esm.ts b/src/esm.ts index 9ca6f0dec..8de6fb075 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -1,9 +1,10 @@ -import { register, RegisterOptions, Service } from './index'; +import { register, RegisterOptions, Service, type TSError } from './index'; import { parse as parseUrl, format as formatUrl, UrlWithStringQuery, fileURLToPath, pathToFileURL } from 'url'; import { extname, resolve as pathResolve } from 'path'; import * as assert from 'assert'; import { normalizeSlashes, versionGteLt } from './util'; import { createRequire } from 'module'; +import type { MessagePort } from 'worker_threads'; // Note: On Windows, URLs look like this: file:///D:/dev/@TypeStrong/ts-node-examples/foo.ts @@ -43,6 +44,7 @@ export namespace NodeLoaderHooksAPI1 { export interface NodeLoaderHooksAPI2 { resolve: NodeLoaderHooksAPI2.ResolveHook; load: NodeLoaderHooksAPI2.LoadHook; + globalPreload?: NodeLoaderHooksAPI2.GlobalPreloadHook; } export namespace NodeLoaderHooksAPI2 { export type ResolveHook = ( @@ -74,6 +76,18 @@ export namespace NodeLoaderHooksAPI2 { export interface NodeImportAssertions { type?: 'json'; } + export type GlobalPreloadHook = (context?: { port: MessagePort }) => string; +} + +export interface NodeLoaderHooksAPI3 { + resolve: NodeLoaderHooksAPI2.ResolveHook; + load: NodeLoaderHooksAPI2.LoadHook; + initialize?: NodeLoaderHooksAPI3.InitializeHook; +} +export namespace NodeLoaderHooksAPI3 { + // technically this can be anything that can be passed through a postMessage channel, + // but defined here based on how ts-node uses it. + export type InitializeHook = (data: any) => void | Promise; } export type NodeLoaderHooksFormat = 'builtin' | 'commonjs' | 'dynamic' | 'json' | 'module' | 'wasm'; @@ -84,17 +98,24 @@ export interface NodeImportAssertions { } // The hooks API changed in node version X so we need to check for backwards compatibility. -const newHooksAPI = versionGteLt(process.versions.node, '16.12.0'); +const hooksAPIVersion = versionGteLt(process.versions.node, '21.0.0') + ? 3 + : versionGteLt(process.versions.node, '16.12.0') + ? 2 + : 1; /** @internal */ export function filterHooksByAPIVersion( - hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2 -): NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 { - const { getFormat, load, resolve, transformSource } = hooks; + hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2 & NodeLoaderHooksAPI3 +): NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 | NodeLoaderHooksAPI3 { + const { getFormat, load, resolve, transformSource, globalPreload, initialize } = hooks; // Explicit return type to avoid TS's non-ideal inferred type - const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI - ? { resolve, load, getFormat: undefined, transformSource: undefined } - : { resolve, getFormat, transformSource, load: undefined }; + const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 | NodeLoaderHooksAPI3 = + hooksAPIVersion === 3 + ? { resolve, load, initialize, globalPreload: undefined, transformSource: undefined, getFormat: undefined } + : hooksAPIVersion === 2 + ? { resolve, load, globalPreload, initialize: undefined, getFormat: undefined, transformSource: undefined } + : { resolve, getFormat, transformSource, initialize: undefined, globalPreload: undefined, load: undefined }; return hooksAPI; } @@ -111,14 +132,44 @@ export function createEsmHooks(tsNodeService: Service) { const nodeResolveImplementation = tsNodeService.getNodeEsmResolver(); const nodeGetFormatImplementation = tsNodeService.getNodeEsmGetFormat(); const extensions = tsNodeService.extensions; + const useLoaderThread = versionGteLt(process.versions.node, '20.0.0'); const hooksAPI = filterHooksByAPIVersion({ resolve, load, getFormat, transformSource, + globalPreload: useLoaderThread ? globalPreload : undefined, + initialize: undefined, }); + function globalPreload({ port }: { port?: MessagePort } = {}) { + // The loader thread doesn't get process.stderr.isTTY properly, + // so this signal lets us infer it based on the state of the main + // thread, but only relevant if options.pretty is unset. + let stderrTTYSignal: string; + if (port && tsNodeService.options.pretty === undefined) { + port.on('message', (data: { stderrIsTTY?: boolean }) => { + if (data.stderrIsTTY) { + tsNodeService.setPrettyErrors(true); + } + }); + stderrTTYSignal = ` + port.postMessage({ + stderrIsTTY: !!process.stderr.isTTY + }); + `; + } else { + stderrTTYSignal = ''; + } + return ` + const { createRequire } = getBuiltin('module'); + const require = createRequire(${JSON.stringify(__filename)}); + ${stderrTTYSignal} + require('./index').register(); + `; + } + function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) { // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo` const { protocol } = parsed; @@ -211,7 +262,7 @@ export function createEsmHooks(tsNodeService: Service) { format: NodeLoaderHooksFormat; source: string | Buffer | undefined; }> { - return addShortCircuitFlag(async () => { + return await addShortCircuitFlag(async () => { // If we get a format hint from resolve() on the context then use it // otherwise call the old getFormat() hook using node's old built-in defaultGetFormat() that ships with ts-node const format = @@ -239,8 +290,23 @@ export function createEsmHooks(tsNodeService: Service) { }); // Call the old hook - const { source: transformedSource } = await transformSource(rawSource, { url, format }, defaultTransformSource); - source = transformedSource; + try { + const { source: transformedSource } = await transformSource( + rawSource, + { url, format }, + defaultTransformSource + ); + source = transformedSource; + } catch (er) { + // throw an error that can make it through the loader thread + // comms channel intact. + const tsErr = er as TSError; + const err = new Error(tsErr.message.trimEnd()); + const { diagnosticCodes } = tsErr; + Object.assign(err, { diagnosticCodes }); + Error.captureStackTrace(err, load); + throw err; + } } return { format, source }; @@ -348,11 +414,12 @@ export function createEsmHooks(tsNodeService: Service) { } async function addShortCircuitFlag(fn: () => Promise) { - const ret = await fn(); - // Not sure if this is necessary; being lazy. Can revisit in the future. - if (ret == null) return ret; - return { - ...ret, - shortCircuit: true, - }; + return fn().then((ret) => { + // Not sure if this is necessary; being lazy. Can revisit in the future. + if (ret == null) return ret; + return { + ...ret, + shortCircuit: true, + }; + }); } diff --git a/src/index.ts b/src/index.ts index c7f320530..14c1490a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -480,6 +480,7 @@ export interface Service { ignored(fileName: string): boolean; compile(code: string, fileName: string, lineOffset?: number): string; getTypeInfo(code: string, fileName: string, position: number): TypeInfo; + setPrettyErrors(pretty: boolean): void; /** @internal */ configFilePath: string | undefined; /** @internal */ @@ -690,7 +691,13 @@ export function createFromPreloadedConfig(foundConfigResult: ReturnType string; - const formatDiagnostics = shouldHavePrettyErrors - ? ts.formatDiagnosticsWithColorAndContext || ts.formatDiagnostics - : ts.formatDiagnostics; + function setPrettyErrors(pretty: boolean) { + shouldHavePrettyErrors = pretty; + formatDiagnostics = shouldHavePrettyErrors + ? ts.formatDiagnosticsWithColorAndContext || ts.formatDiagnostics + : ts.formatDiagnostics; + } + setPrettyErrors(options.pretty !== undefined ? options.pretty : !!process.stderr.isTTY); function createTSError(diagnostics: ReadonlyArray<_ts.Diagnostic>) { const diagnosticText = formatDiagnostics(diagnostics, diagnosticHost); @@ -1282,6 +1294,7 @@ export function createFromPreloadedConfig(foundConfigResult: ReturnType { cwd: join(TEST_DIR, './esm'), }); expect(r.err).not.toBe(null); - const expectedModuleUrl = pathToFileURL(join(TEST_DIR, './esm/throw error.ts')).toString(); - expect(r.err!.message).toMatch( - [ - `${expectedModuleUrl}:100`, - " bar() { throw new Error('this is a demo'); }", - ' ^', - 'Error: this is a demo', - ` at Foo.bar (${expectedModuleUrl}:100:17)`, - ].join('\n') + // on node 20, this will be a path. prior versions, a file: url + // on windows in node 20, it's a quasi-url like d:/path/to/throw%20error.ts + expect(r.err!.message.replace(/\r\n/g, '\n')).toMatch( + /[\\\/]throw( |%20)error\.ts:100\n bar\(\) \{ throw new Error\('this is a demo'\); \}/ ); + // the ^ and number of line-breaks is platform-specific + // also, node 20 puts the type in here when source mapping it, so it + // shows as Foo.Foo.bar + expect(r.err!.message.replace(/\r\n/g, '\n')).toMatch(/^ at (Foo\.){1,2}bar \(/m); + expect(r.err!.message.replace(/\r\n/g, '\n')).toMatch(/^ at (Foo\.){1,2}bar ([^\n\)]+[\\\/]throw( |%20)error\.ts:100:17)/m); }); test.suite('supports experimental-specifier-resolution=node', (test) => { @@ -94,8 +95,10 @@ test.suite('esm', (test) => { cwd: join(TEST_DIR, './esm-import-http-url'), }); expect(r.err).not.toBe(null); - // expect error from node's default resolver - expect(r.stderr).toMatch(/Error \[ERR_UNSUPPORTED_ESM_URL_SCHEME\]:.*(?:\n.*){0,2}\n *at defaultResolve/); + // expect error from node's default resolver, has a few different names in different node versions + expect(r.stderr.replace(/\r\n/g, '\n')).toMatch( + /Error \[ERR_UNSUPPORTED_ESM_URL_SCHEME\]:.*(?:\n.*){0,10}\n *at (default|next)(Load|Resolve)/ + ); }); test('should bypass import cache when changing search params', async () => { @@ -103,7 +106,7 @@ test.suite('esm', (test) => { cwd: join(TEST_DIR, './esm-import-cache'), }); expect(r.err).toBe(null); - expect(r.stdout).toBe('log1\nlog2\nlog2\n'); + expect(r.stdout.replace(/\r\n/g, '\n')).toBe('log1\nlog2\nlog2\n'); }); test('should support transpile only mode via dedicated loader entrypoint', async () => { diff --git a/src/test/helpers/ctx-ts-node.ts b/src/test/helpers/ctx-ts-node.ts index 7097f117f..c38b2af3b 100644 --- a/src/test/helpers/ctx-ts-node.ts +++ b/src/test/helpers/ctx-ts-node.ts @@ -11,8 +11,10 @@ import { testsDirRequire, tsNodeTypes } from './misc'; /** Pass to `test.context()` to get access to the ts-node API under test */ export async function ctxTsNode() { await installTsNode(); + const tsNodeSpecifier = testsDirRequire.resolve('ts-node'); const tsNodeUnderTest: typeof tsNodeTypes = testsDirRequire('ts-node'); return { + tsNodeSpecifier, tsNodeUnderTest, }; } diff --git a/src/test/helpers/reset-node-environment.ts b/src/test/helpers/reset-node-environment.ts index 5c8140406..f6442f9c2 100644 --- a/src/test/helpers/reset-node-environment.ts +++ b/src/test/helpers/reset-node-environment.ts @@ -1,4 +1,5 @@ import { has, mapValues, sortBy } from 'lodash'; +import * as testLoader from '../test-loader/client'; // Reset node environment // Useful because ts-node installation necessarily must mutate the node environment. @@ -44,7 +45,7 @@ export function resetNodeEnvironment() { resetObject(global, defaultGlobal, ['__coverage__']); // Reset our ESM hooks - process.__test_setloader__?.(undefined); + testLoader.clearLoader(); } function captureObjectState(object: any, avoidGetters: string[] = []) { diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 884808a77..f23c662ac 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -181,13 +181,8 @@ test.suite('ts-node', (test) => { throw new Error('Command was expected to fail, but it succeeded.'); } - expect(r.err.message).toMatch( - [ - `${join(TEST_DIR, 'throw error.ts')}:100`, - " bar() { throw new Error('this is a demo'); }", - ' ^', - 'Error: this is a demo', - ].join('\n') + expect(r.err.message.replace(/\r\n/g, '\n')).toMatch( + /throw( |%20)error\.ts:100\n bar\(\) \{ throw new Error\('this is a demo'\); \}/ ); }); @@ -197,13 +192,8 @@ test.suite('ts-node', (test) => { throw new Error('Command was expected to fail, but it succeeded.'); } - expect(r.err.message).toMatch( - [ - `${join(TEST_DIR, 'throw error.ts')}:100`, - " bar() { throw new Error('this is a demo'); }", - ' ^', - 'Error: this is a demo', - ].join('\n') + expect(r.err.message.replace(/\r\n/g, '\n')).toMatch( + /throw( |%20)error\.ts:100\n bar\(\) \{ throw new Error\('this is a demo'\); \}/ ); }); @@ -213,12 +203,8 @@ test.suite('ts-node', (test) => { throw new Error('Command was expected to fail, but it succeeded.'); } - expect(r.err.message).toMatch( - [ - `${join(TEST_DIR, 'throw error.ts')}:100`, - " bar() { throw new Error('this is a demo'); }", - ' ^', - ].join('\n') + expect(r.err.message.replace(/\r\n/g, '\n')).toMatch( + /throw( |%20)error\.ts:100\n bar\(\) \{ throw new Error\('this is a demo'\); \}/ ); }); @@ -308,26 +294,16 @@ test.suite('ts-node', (test) => { test('should use source maps with react tsx', async () => { const r = await exec(`${CMD_TS_NODE_WITH_PROJECT_FLAG} "throw error react tsx.tsx"`); expect(r.err).not.toBe(null); - expect(r.err!.message).toMatch( - [ - `${join(TEST_DIR, './throw error react tsx.tsx')}:100`, - " bar() { throw new Error('this is a demo'); }", - ' ^', - 'Error: this is a demo', - ].join('\n') + expect(r.err!.message.replace(/\r\n/g, '\n')).toMatch( + /throw( |%20)error( |%20)react( |%20)tsx\.tsx:100\n bar\(\) \{ throw new Error\('this is a demo'\); \}/ ); }); test('should use source maps with react tsx in --transpile-only mode', async () => { const r = await exec(`${CMD_TS_NODE_WITH_PROJECT_FLAG} --transpile-only "throw error react tsx.tsx"`); expect(r.err).not.toBe(null); - expect(r.err!.message).toMatch( - [ - `${join(TEST_DIR, './throw error react tsx.tsx')}:100`, - " bar() { throw new Error('this is a demo'); }", - ' ^', - 'Error: this is a demo', - ].join('\n') + expect(r.err!.message.replace(/\r\n/g, '\n')).toMatch( + /throw( |%20)error( |%20)react( |%20)tsx\.tsx:100\n bar\(\) \{ throw new Error\('this is a demo'\); \}/ ); }); @@ -440,8 +416,7 @@ test.suite('ts-node', (test) => { expect(r.err).not.toBe(null); expect(r.stderr.replace(/\r\n/g, '\n')).toMatch( 'TSError: ⨯ Unable to compile TypeScript:\n' + - "maxnodemodulesjsdepth/other.ts(4,7): error TS2322: Type 'string' is not assignable to type 'boolean'.\n" + - '\n' + "maxnodemodulesjsdepth/other.ts(4,7): error TS2322: Type 'string' is not assignable to type 'boolean'." ); }); @@ -450,8 +425,7 @@ test.suite('ts-node', (test) => { expect(r.err).not.toBe(null); expect(r.stderr.replace(/\r\n/g, '\n')).toMatch( 'TSError: ⨯ Unable to compile TypeScript:\n' + - "maxnodemodulesjsdepth-scoped/other.ts(7,7): error TS2322: Type 'string' is not assignable to type 'boolean'.\n" + - '\n' + "maxnodemodulesjsdepth-scoped/other.ts(7,7): error TS2322: Type 'string' is not assignable to type 'boolean'." ); }); }); diff --git a/src/test/register.spec.ts b/src/test/register.spec.ts index 9c9f512c2..2cae6de32 100644 --- a/src/test/register.spec.ts +++ b/src/test/register.spec.ts @@ -107,9 +107,9 @@ test.suite('register(create(options))', (test) => { try { require('../../tests/throw error'); } catch (error: any) { - exp(error.stack).toMatch( - ['Error: this is a demo', ` at Foo.bar (${join(TEST_DIR, './throw error.ts')}:100:17)`].join('\n') - ); + // on windows in node 20, this is printed as a quasi-url, like + // d:/path/to/throw%20error.ts + exp(error.stack).toMatch(/Error: this is a demo\n at Foo\.bar \([^)]+[\\\/]throw( |%20)error\.ts:100:17\)\n/); } }); diff --git a/src/test/repl/repl-environment.spec.ts b/src/test/repl/repl-environment.spec.ts index 0d8f2470b..f74117377 100644 --- a/src/test/repl/repl-environment.spec.ts +++ b/src/test/repl/repl-environment.spec.ts @@ -160,7 +160,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => modulePaths, exportsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[stdin].ts`)}:1:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[stdin].ts`)}:`), moduleAccessorsTest: null, argv: [tsNodeExe], }, @@ -189,7 +189,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => // Note: vanilla node REPL does not set exports exportsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, replFile)}:4:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, replFile)}:`), moduleAccessorsTest: true, argv: [tsNodeExe], }, @@ -237,7 +237,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => modulePaths: [...modulePaths], exportsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:1:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:`), moduleAccessorsTest: true, argv: [tsNodeExe], }, @@ -262,7 +262,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => modulePaths, exportsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:1:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:`), moduleAccessorsTest: true, argv: [tsNodeExe, './repl/script.js'], }, @@ -287,7 +287,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => modulePaths, exportsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:1:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:`), moduleAccessorsTest: true, argv: [tsNodeExe, './does-not-exist.js'], }, @@ -312,7 +312,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => modulePaths, exportsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:1:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, `[eval].ts`)}:`), moduleAccessorsTest: true, argv: [tsNodeExe], }, @@ -404,7 +404,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => // moduleAccessorsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, replFile)}:1:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, replFile)}:`), }, }); } @@ -433,7 +433,7 @@ test.suite('[eval], , and [stdin] execute with correct globals', (test) => // Note: vanilla node REPL does not set exports exportsTest: true, // Note: vanilla node uses different name. See #1360 - stackTest: expect.stringContaining(` at ${join(TEST_DIR, replFile)}:1:`), + stackTest: expect.stringContaining(` at ${join(TEST_DIR, replFile)}:`), moduleAccessorsTest: true, }, }); diff --git a/src/test/repl/repl.spec.ts b/src/test/repl/repl.spec.ts index 99612ac40..31e30e4a6 100644 --- a/src/test/repl/repl.spec.ts +++ b/src/test/repl/repl.spec.ts @@ -222,8 +222,8 @@ test.suite('top level await', ({ contextEach }) => { }); test('should pass upstream test cases', async (t) => { - const { tsNodeUnderTest } = t.context; - await upstreamTopLevelAwaitTests({ TEST_DIR, tsNodeUnderTest }); + const { tsNodeUnderTest, tsNodeSpecifier } = t.context; + await upstreamTopLevelAwaitTests({ TEST_DIR, tsNodeUnderTest, tsNodeSpecifier }); }); }); diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index 15d788c46..be5fbac58 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -13,9 +13,10 @@ import * as semver from 'semver'; import { padStart } from 'lodash'; import _ = require('lodash'); import { pathToFileURL } from 'url'; -import type { RegisterOptions } from '..'; +import type { CreateOptions, RegisterOptions } from '..'; import * as fs from 'fs'; import * as Path from 'path'; +import * as testLoader from './test-loader/client'; /* * Each test case is a separate TS project, with a different permutation of @@ -660,10 +661,11 @@ async function execute(t: T, p: FsProject, entrypoints: Entrypoint[]) { // Install ts-node and try to import all the index-* files // - const service = t.context.tsNodeUnderTest.register({ + const options: CreateOptions = { projectSearchDir: p.cwd, - }); - process.__test_setloader__(t.context.tsNodeUnderTest.createEsmHooks(service)); + }; + t.context.tsNodeUnderTest.register(options); + await testLoader.setLoader(t.context.tsNodeSpecifier, options); for (const entrypoint of entrypoints) { t.log(`Importing ${join(p.cwd, entrypoint)}`); diff --git a/src/test/sourcemaps.spec.ts b/src/test/sourcemaps.spec.ts index 4aae34757..e51ebed04 100644 --- a/src/test/sourcemaps.spec.ts +++ b/src/test/sourcemaps.spec.ts @@ -1,4 +1,5 @@ import * as expect from 'expect'; +import { versionGteLt } from '../util'; import { createExec, createExecTester, CMD_TS_NODE_WITH_PROJECT_FLAG, ctxTsNode, TEST_DIR } from './helpers'; import { context } from './testlib'; const test = context(ctxTsNode); @@ -10,17 +11,23 @@ const exec = createExecTester({ }), }); -test('Redirects source-map-support to @cspotcode/source-map-support so that third-party libraries get correct source-mapped locations', async () => { - const r = await exec({ - flags: `./legacy-source-map-support-interop/index.ts`, +const useBuiltInSourceMaps = versionGteLt(process.versions.node, '20.0.0'); + +if (useBuiltInSourceMaps) { + test.skip('Skip source-map-support redirection on node 20', () => {}); +} else { + test('Redirects source-map-support to @cspotcode/source-map-support so that third-party libraries get correct source-mapped locations', async () => { + const r = await exec({ + flags: `./legacy-source-map-support-interop/index.ts`, + }); + expect(r.err).toBeNull(); + expect(r.stdout.split('\n')).toMatchObject([ + expect.stringContaining('.ts:2 '), + 'true', + 'true', + expect.stringContaining('.ts:100:'), + expect.stringContaining('.ts:101 '), + '', + ]); }); - expect(r.err).toBeNull(); - expect(r.stdout.split('\n')).toMatchObject([ - expect.stringContaining('.ts:2 '), - 'true', - 'true', - expect.stringContaining('.ts:100:'), - expect.stringContaining('.ts:101 '), - '', - ]); -}); +} diff --git a/src/test/test-loader.d.ts b/src/test/test-loader.d.ts deleted file mode 100644 index 1d83a9976..000000000 --- a/src/test/test-loader.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export {}; -declare global { - namespace NodeJS { - interface Process { - __test_setloader__(hooks: any): void; - } - } -} diff --git a/src/test/test-loader.mjs b/src/test/test-loader.mjs deleted file mode 100644 index 7f5e2b71a..000000000 --- a/src/test/test-loader.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Grant ourselves the ability to install ESM loader behaviors in-process during tests -import semver from 'semver'; - -const newHooksAPI = semver.gte(process.versions.node, '16.12.0'); - -let hooks = undefined; -process.__test_setloader__ = function (_hooks) { - hooks = _hooks; -}; -function createHook(name) { - return function (a, b, c) { - const target = (hooks && hooks[name]) || c; - return target(...arguments); - }; -} -export const resolve = createHook('resolve'); -export const load = newHooksAPI ? createHook('load') : null; -export const getFormat = !newHooksAPI ? createHook('getFormat') : null; -export const transformSource = !newHooksAPI ? createHook('transformSource') : null; diff --git a/src/test/test-loader/README.md b/src/test/test-loader/README.md new file mode 100644 index 000000000..36363f3c0 --- /dev/null +++ b/src/test/test-loader/README.md @@ -0,0 +1,11 @@ +An injectable --loader used by tests. It allows tests to setup and teardown +different loader behaviors in-process. + +By default, it does nothing, as if we were not using a loader at all. But it +responds to specially-crafted `import`s, installing or uninstalling a loader at +runtime. + +See also `ava.config.cjs` + +The loader is implemented in `loader.mjs`, and functions to send it commands are +in `client.ts`. diff --git a/src/test/test-loader/client.ts b/src/test/test-loader/client.ts new file mode 100644 index 000000000..bca06384a --- /dev/null +++ b/src/test/test-loader/client.ts @@ -0,0 +1,26 @@ +import { URL } from 'url'; +import type { CreateOptions } from '../..'; + +// Duplicated in loader.mjs +const protocol = 'testloader://'; +const clearLoaderCmd = 'clearLoader'; +const setLoaderCmd = 'setLoader'; + +// Avoid ts compiler transforming import() into require(). +const doImport = new Function('specifier', 'return import(specifier)'); +let cacheBust = 0; +async function call(url: URL) { + url.searchParams.set('cacheBust', `${cacheBust++}`); + await doImport(url.toString()); +} + +export async function clearLoader() { + await call(new URL(`${protocol}${clearLoaderCmd}`)); +} + +export async function setLoader(specifier: string, options: CreateOptions | undefined) { + const url = new URL(`${protocol}${setLoaderCmd}`); + url.searchParams.append('specifier', specifier); + url.searchParams.append('options', JSON.stringify(options)); + await call(url); +} diff --git a/src/test/test-loader/loader.mjs b/src/test/test-loader/loader.mjs new file mode 100644 index 000000000..b2d39329d --- /dev/null +++ b/src/test/test-loader/loader.mjs @@ -0,0 +1,69 @@ +import semver from 'semver'; +import { URL } from 'url'; +import { createRequire } from 'module'; + +export const protocol = 'testloader://'; + +const newHooksAPI = semver.gte(process.versions.node, '16.12.0'); + +let hooks = undefined; + +const require = createRequire(import.meta.url); + +// +// Commands +// + +export const clearLoaderCmd = 'clearLoader'; +function clearLoader() { + hooks = undefined; +} + +export const setLoaderCmd = 'setLoader'; +function setLoader(specifier, options) { + const tsNode = require(specifier); + const service = tsNode.create(options); + hooks = tsNode.createEsmHooks(service); +} + +// +// Loader hooks +// + +function createHook(name) { + return function (a, b, c) { + const target = (hooks && hooks[name]) || c; + return target(...arguments); + }; +} + +export const empty = `${protocol}empty`; +const resolveEmpty = { url: empty, shortCircuit: true }; + +const _resolve = createHook('resolve'); +export function resolve(specifier, ...rest) { + if (specifier.startsWith(protocol)) { + const url = new URL(specifier); + switch (url.host) { + case setLoaderCmd: + const specifier = url.searchParams.get('specifier'); + const options = JSON.parse(url.searchParams.get('options')); + setLoader(specifier, options); + return resolveEmpty; + case clearLoaderCmd: + clearLoader(); + return resolveEmpty; + } + } + return _resolve(specifier, ...rest); +} + +const _loadHook = newHooksAPI ? createHook('load') : null; +function _load(url, ...rest) { + if (url === empty) return { format: 'module', source: '', shortCircuit: true }; + return _loadHook(url, ...rest); +} +export const load = newHooksAPI ? _load : null; + +export const getFormat = !newHooksAPI ? createHook('getFormat') : null; +export const transformSource = !newHooksAPI ? createHook('transformSource') : null; diff --git a/src/test/transpilers.spec.ts b/src/test/transpilers.spec.ts index d11c368bf..185d92ce8 100644 --- a/src/test/transpilers.spec.ts +++ b/src/test/transpilers.spec.ts @@ -120,23 +120,4 @@ test.suite('swc', (test) => { ); }); }); - - test.suite('preserves import assertions for json imports', (test) => { - test.if(tsSupportsImportAssertions); - test( - 'basic json import', - compileMacro, - { module: 'esnext' }, - outdent` - import document from './document.json' assert {type: 'json'}; - document; - `, - outdent` - import document from './document.json' assert { - type: 'json' - }; - document; - ` - ); - }); }); diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index 16868cad2..9b438d59b 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -235,9 +235,6 @@ export function createSwcOptions( }, }, keepClassNames, - experimental: { - keepImportAssertions: true, - }, }, }; diff --git a/tests/esm-custom-loader/loader.mjs b/tests/esm-custom-loader/loader.mjs index 74b82b02c..4da5b970c 100755 --- a/tests/esm-custom-loader/loader.mjs +++ b/tests/esm-custom-loader/loader.mjs @@ -11,4 +11,4 @@ const tsNodeInstance = register({ }, }); -export const { resolve, getFormat, transformSource, load } = createEsmHooks(tsNodeInstance); +export const { globalPreload, resolve, getFormat, transformSource, load } = createEsmHooks(tsNodeInstance); diff --git a/yarn.lock b/yarn.lock index 035ecbe33..a6ac91200 100644 --- a/yarn.lock +++ b/yarn.lock @@ -241,16 +241,17 @@ __metadata: languageName: node linkType: hard -"@cspotcode/ava-lib@https://github.com/cspotcode/ava-lib#bbbed83f393342b51dc6caf2ddf775a3e89371d8": +"@cspotcode/ava-lib@https://github.com/cspotcode/ava-lib#805aab17b2b89c388596b6dc2b4eece403c5fb87": version: 0.0.1 - resolution: "@cspotcode/ava-lib@https://github.com/cspotcode/ava-lib.git#commit=bbbed83f393342b51dc6caf2ddf775a3e89371d8" + resolution: "@cspotcode/ava-lib@https://github.com/cspotcode/ava-lib.git#commit=805aab17b2b89c388596b6dc2b4eece403c5fb87" dependencies: "@types/node": "*" + chalk: 4.1.2 throat: ^6.0.1 peerDependencies: ava: "*" expect: "*" - checksum: 3ca30bbfe81abb537e1e96addd272b34daf19ecba56f13a5785115bc3433dc5309e733ab8440384531b7a74b88f58eb11c9151e62b75c5c219eccc7dd8b058ec + checksum: 3b453c2f1dd64eeb531a8b6da4cd4d9b24364b31b6692aca5d646d40479e46db56acc4141d93871732f33ab86e91b4c6a08674d1bcf49fc8c8ff8dab9f005742 languageName: node linkType: hard @@ -1257,6 +1258,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:4.1.2, chalk@npm:^4.0.0": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc + languageName: node + linkType: hard + "chalk@npm:^2.0.0": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -1268,16 +1279,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": - version: 4.1.2 - resolution: "chalk@npm:4.1.2" - dependencies: - ansi-styles: ^4.1.0 - supports-color: ^7.1.0 - checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc - languageName: node - linkType: hard - "chalk@npm:^5.2.0": version: 5.2.0 resolution: "chalk@npm:5.2.0" @@ -3810,7 +3811,7 @@ __metadata: resolution: "ts-node@workspace:." dependencies: "@TypeStrong/fs-fixture-builder": "https://github.com/Typestrong/fs-fixture-builder.git#3099e53621daf99db971af29c96145dc115693cd" - "@cspotcode/ava-lib": "https://github.com/cspotcode/ava-lib#bbbed83f393342b51dc6caf2ddf775a3e89371d8" + "@cspotcode/ava-lib": "https://github.com/cspotcode/ava-lib#805aab17b2b89c388596b6dc2b4eece403c5fb87" "@cspotcode/expect-stream": "https://github.com/cspotcode/node-expect-stream#4e425ff1eef240003af8716291e80fbaf3e3ae8f" "@cspotcode/source-map-support": ^0.8.0 "@microsoft/api-extractor": ^7.19.4