Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow composing register(create()); refactor tests #1474

Merged
merged 12 commits into from Oct 10, 2021
16 changes: 16 additions & 0 deletions .vscode/launch.json
@@ -0,0 +1,16 @@
{
"configurations": [
{
"name": "Debug AVA test file",
"type": "node",
"request": "launch",
"preLaunchTask": "npm: pre-debug",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ava",
"program": "${file}",
"outputCapture": "std",
"skipFiles": [
"<node_internals>/**/*.js"
],
}
],
}
13 changes: 13 additions & 0 deletions .vscode/tasks.json
@@ -0,0 +1,13 @@
{
"tasks": [
{
"type": "npm",
"script": "pre-debug",
"problemMatcher": [
"$tsc"
],
"label": "npm: pre-debug",
"detail": "npm run build-tsc && npm run build-pack"
}
]
}
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -67,6 +67,7 @@
"test-cov": "nyc ava",
"test": "npm run build && npm run lint && npm run test-cov --",
"test-local": "npm run lint-fix && npm run build-tsc && npm run build-pack && npm run test-spec --",
"pre-debug": "npm run build-tsc && npm run build-pack",
"coverage-report": "nyc report --reporter=lcov",
"prepare": "npm run clean && npm run build-nopack",
"api-extractor": "api-extractor run --local --verbose"
Expand Down
90 changes: 57 additions & 33 deletions src/index.ts
Expand Up @@ -427,10 +427,14 @@ export class TSError extends BaseError {
}
}

const TS_NODE_SERVICE_BRAND = Symbol('TS_NODE_SERVICE_BRAND');

/**
* Primary ts-node service, which wraps the TypeScript API and can compile TypeScript to JavaScript
*/
export interface Service {
/** @internal */
[TS_NODE_SERVICE_BRAND]: true;
ts: TSCommon;
config: _ts.ParsedCommandLine;
options: RegisterOptions;
Expand All @@ -446,6 +450,8 @@ export interface Service {
readonly shouldReplAwait: boolean;
/** @internal */
addDiagnosticFilter(filter: DiagnosticFilter): void;
/** @internal */
installSourceMapSupport(): void;
}

/**
Expand Down Expand Up @@ -477,12 +483,25 @@ export function getExtensions(config: _ts.ParsedCommandLine) {
return { tsExtensions, jsExtensions };
}

/**
* Create a new TypeScript compiler instance and register it onto node.js
*/
export function register(opts?: RegisterOptions): Service;
/**
* Register TypeScript compiler instance onto node.js
*/
export function register(opts: RegisterOptions = {}): Service {
export function register(service: Service): Service;
export function register(
serviceOrOpts: Service | RegisterOptions | undefined
): Service {
// Is this a Service or a RegisterOptions?
let service = serviceOrOpts as Service;
if (!(serviceOrOpts as Service)?.[TS_NODE_SERVICE_BRAND]) {
// Not a service; is options
service = create((serviceOrOpts ?? {}) as RegisterOptions);
}

const originalJsHandler = require.extensions['.js'];
const service = create(opts);
const { tsExtensions, jsExtensions } = getExtensions(service.config);
const extensions = [...tsExtensions, ...jsExtensions];

Expand Down Expand Up @@ -660,38 +679,41 @@ export function create(rawOptions: CreateOptions = {}): Service {
}

// Install source map support and read from memory cache.
sourceMapSupport.install({
environment: 'node',
retrieveFile(pathOrUrl: string) {
let path = pathOrUrl;
// If it's a file URL, convert to local path
// Note: fileURLToPath does not exist on early node v10
// I could not find a way to handle non-URLs except to swallow an error
if (options.experimentalEsmLoader && path.startsWith('file://')) {
try {
path = fileURLToPath(path);
} catch (e) {
/* swallow error */
installSourceMapSupport();
function installSourceMapSupport() {
sourceMapSupport.install({
environment: 'node',
retrieveFile(pathOrUrl: string) {
let path = pathOrUrl;
// If it's a file URL, convert to local path
// Note: fileURLToPath does not exist on early node v10
// I could not find a way to handle non-URLs except to swallow an error
if (options.experimentalEsmLoader && path.startsWith('file://')) {
try {
path = fileURLToPath(path);
} catch (e) {
/* swallow error */
}
}
}
path = normalizeSlashes(path);
return outputCache.get(path)?.content || '';
},
redirectConflictingLibrary: true,
onConflictingLibraryRedirect(
request,
parent,
isMain,
options,
redirectedRequest
) {
debug(
`Redirected an attempt to require source-map-support to instead receive @cspotcode/source-map-support. "${
(parent as NodeJS.Module).filename
}" attempted to require or resolve "${request}" and was redirected to "${redirectedRequest}".`
);
},
});
path = normalizeSlashes(path);
return outputCache.get(path)?.content || '';
},
redirectConflictingLibrary: true,
onConflictingLibraryRedirect(
request,
parent,
isMain,
options,
redirectedRequest
) {
debug(
`Redirected an attempt to require source-map-support to instead receive @cspotcode/source-map-support. "${
(parent as NodeJS.Module).filename
}" attempted to require or resolve "${request}" and was redirected to "${redirectedRequest}".`
);
},
});
}

const shouldHavePrettyErrors =
options.pretty === undefined ? process.stdout.isTTY : options.pretty;
Expand Down Expand Up @@ -1239,6 +1261,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
}

return {
[TS_NODE_SERVICE_BRAND]: true,
ts,
config,
compile,
Expand All @@ -1250,6 +1273,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
moduleTypeClassifier,
shouldReplAwait,
addDiagnosticFilter,
installSourceMapSupport,
};
}

Expand Down
66 changes: 63 additions & 3 deletions src/test/helpers.ts
Expand Up @@ -12,9 +12,9 @@ import type { Readable } from 'stream';
*/
import type * as tsNodeTypes from '../index';
import type _createRequire from 'create-require';
import { once } from 'lodash';
import { has, once } from 'lodash';
import semver = require('semver');
import { isConstructSignatureDeclaration } from 'typescript';
import * as expect from 'expect';
const createRequire: typeof _createRequire = require('create-require');
export { tsNodeTypes };

Expand Down Expand Up @@ -45,7 +45,7 @@ export const xfs = new NodeFS(fs);
/** Pass to `test.context()` to get access to the ts-node API under test */
export const contextTsNodeUnderTest = once(async () => {
await installTsNode();
const tsNodeUnderTest = testsDirRequire('ts-node');
const tsNodeUnderTest: typeof tsNodeTypes = testsDirRequire('ts-node');
return {
tsNodeUnderTest,
};
Expand Down Expand Up @@ -155,3 +155,63 @@ export function getStream(stream: Readable, waitForPattern?: string | RegExp) {
combinedString = combinedBuffer.toString('utf8');
}
}

const defaultRequireExtensions = captureObjectState(require.extensions);
const defaultProcess = captureObjectState(process);
const defaultModule = captureObjectState(require('module'));
const defaultError = captureObjectState(Error);
const defaultGlobal = captureObjectState(global);

/**
* Undo all of ts-node & co's installed hooks, resetting the node environment to default
* so we can run multiple test cases which `.register()` ts-node.
*
* Must also play nice with `nyc`'s environmental mutations.
*/
export function resetNodeEnvironment() {
// We must uninstall so that it resets its internal state; otherwise it won't know it needs to reinstall in the next test.
require('@cspotcode/source-map-support').uninstall();

// Modified by ts-node hooks
resetObject(require.extensions, defaultRequireExtensions);

// ts-node attaches a property when it registers an instance
// source-map-support monkey-patches the emit function
resetObject(process, defaultProcess);

// source-map-support swaps out the prepareStackTrace function
resetObject(Error, defaultError);

// _resolveFilename is modified by tsconfig-paths, future versions of source-map-support, and maybe future versions of ts-node
resetObject(require('module'), defaultModule);

// May be modified by REPL tests, since the REPL sets globals.
resetObject(global, defaultGlobal);
}

function captureObjectState(object: any) {
return {
descriptors: Object.getOwnPropertyDescriptors(object),
values: { ...object },
};
}
// Redefine all property descriptors and delete any new properties
function resetObject(
object: any,
state: ReturnType<typeof captureObjectState>
) {
const currentDescriptors = Object.getOwnPropertyDescriptors(object);
for (const key of Object.keys(currentDescriptors)) {
if (!has(state.descriptors, key)) {
delete object[key];
}
}
// Trigger nyc's setter functions
for (const [key, value] of Object.entries(state.values)) {
try {
object[key] = value;
} catch {}
}
// Reset descriptors
Object.defineProperties(object, state.descriptors);
}