Skip to content

Commit

Permalink
Support tsconfig.json paths in Jest (#698)
Browse files Browse the repository at this point in the history
Per #696
  • Loading branch information
72636c authored Nov 25, 2021
1 parent efd2657 commit 1a40ea5
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 24 deletions.
7 changes: 7 additions & 0 deletions .changeset/mighty-eagles-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"skuba": minor
---

jest: Support `tsconfig.json` paths

Module aliases other than `src` are now supported in `skuba test`. Our Jest preset includes a dynamic `moduleNameMapper` that reads the `paths` compiler option from your `tsconfig.json`.
30 changes: 6 additions & 24 deletions jest-preset.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
const { defaults: tsJestDefaults } = require('ts-jest/presets');
const { defaults } = require('ts-jest/presets');

const TS_JEST_NAME = 'ts-jest';

/**
* Resolved path of the `ts-jest` preset.
*
* This allows Jest to resolve the preset even if it is installed to a nested
* `./node_modules/skuba/node_modules/ts-jest` directory.
*/
const TS_JEST_PATH = require.resolve(TS_JEST_NAME);

// Rewrite `ts-jest` transformations using our resolved `TS_JEST_PATH`.
const tsJestTransform = Object.fromEntries(
Object.entries(tsJestDefaults.transform).map(([key, value]) => [
key,
value === TS_JEST_NAME ? TS_JEST_PATH : value,
]),
);
const { createModuleNameMapper } = require('./jest/moduleNameMapper');
const { transform } = require('./jest/transform');

/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
...tsJestDefaults,
...defaults,

transform: tsJestTransform,
moduleNameMapper: createModuleNameMapper(),
transform,

collectCoverageFrom: [
'**/*.ts',
Expand All @@ -35,10 +21,6 @@ module.exports = {
'!<rootDir>/jest.*.ts',
],
coverageDirectory: 'coverage',
moduleNameMapper: {
'^src$': '<rootDir>/src',
'^src/(.+)$': '<rootDir>/src/$1',
},
testEnvironment: 'node',
testPathIgnorePatterns: [
'/node_modules.*/',
Expand Down
86 changes: 86 additions & 0 deletions jest/moduleNameMapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
const path = require('path');

const { pathsToModuleNameMapper } = require('ts-jest/utils');
const {
sys,
findConfigFile,
readConfigFile,
parseJsonConfigFileContent,
} = require('typescript');

/**
* Set a default `src` module alias for backward compatibility.
*
* TODO: drop this default in skuba v4.
*/
const DEFAULT_PATHS = { src: ['src'], 'src/*': ['src/*'] };

/**
* @returns {unknown}
*/
const getConfigFromDisk = () => {
const filename =
findConfigFile('.', sys.fileExists.bind(this)) || 'tsconfig.json';

return readConfigFile(filename, sys.readFile.bind(this)).config;
};

module.exports.createModuleNameMapper = (getConfig = getConfigFromDisk) => {
try {
const json = getConfig();

const parsedConfig = parseJsonConfigFileContent(json, sys, '.');

const paths = Object.fromEntries(
Object.entries(parsedConfig.options.paths ?? DEFAULT_PATHS).flatMap(
([key, values]) => [
// Pass through the input path entry almost verbatim.
// We trim a trailing slash because TypeScript allows `import 'src'`
// to be resolved by the alias `src/`, but Jest's mapper does not.
[
key.replace(/\/$/, ''),
values.map((value) => value.replace(/\/$/, '')),
],
// Append a variant of the input path entry.
// As TypeScript allows both `import 'src'` and `import 'src/nested'`
// to be resolved by the alias `src/*` (and likewise for plain `src`),
// we need to seed two Jest mappings per path.
...(key.endsWith('/*')
? [
[
// Given a path `src/*`, seed an extra `src`.
key.replace(/\/\*$/, ''),
values.map((value) => value.replace(/\/\*$/, '')),
],
]
: [
[
// Given a path `src`, seed an extra `src/*`.
path.join(key, '*'),
values.map((value) => path.join(value, '*')),
],
]),
],
),
);

const prefix = path.join('<rootDir>', parsedConfig.options.baseUrl || '.');

const moduleNameMapper = pathsToModuleNameMapper(paths, { prefix });

// Normalise away any `..`s that may crop up from `baseUrl` usage.
// For example, a `baseUrl` of `src` and a path of `../cli` will result in
// `<rootDir>/src/../cli`, which can be normalised to `<rootDir>/cli`.
return Object.fromEntries(
Object.entries(moduleNameMapper).map(([key, values]) => [
key,
Array.isArray(values)
? values.map((value) => path.normalize(value))
: path.normalize(values),
]),
);
} catch {
// Bail out here to support zero-config mode.
return pathsToModuleNameMapper(DEFAULT_PATHS, { prefix: '<rootDir>' });
}
};
75 changes: 75 additions & 0 deletions jest/moduleNameMapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createModuleNameMapper } from './moduleNameMapper';

describe('moduleNameMapper', () => {
const act = (paths?: unknown, baseUrl?: string) =>
createModuleNameMapper(() => ({
compilerOptions: {
baseUrl,
paths,
},
}));

it('expands wildcard paths', () =>
expect(act({ 'src/*': ['src/*'], 'lib/wip/*': ['lib/wip/*'] }))
.toMatchInlineSnapshot(`
Object {
"^lib/wip$": "<rootDir>/lib/wip",
"^lib/wip/(.*)$": "<rootDir>/lib/wip/$1",
"^src$": "<rootDir>/src",
"^src/(.*)$": "<rootDir>/src/$1",
}
`));

it('expands non-wildcard paths', () =>
expect(act({ cli: ['cli'], 'src/': ['src/'] })).toMatchInlineSnapshot(`
Object {
"^cli$": "<rootDir>/cli",
"^cli/(.*)$": "<rootDir>/cli/$1",
"^src$": "<rootDir>/src",
"^src/(.*)$": "<rootDir>/src/$1",
}
`));

it('expands duplicate asymmetric paths', () =>
expect(
act({
jquery: ['node_modules/jquery/dist/jquery'],
'jquery/*': ['node_modules/jquery/dist/jquery/*'],
}),
).toMatchInlineSnapshot(`
Object {
"^jquery$": "<rootDir>/node_modules/jquery/dist/jquery",
"^jquery/(.*)$": "<rootDir>/node_modules/jquery/dist/jquery/$1",
}
`));

it('respects a base URL', () =>
expect(act({ cli: ['../cli'], 'app/*': ['app/*'] }, 'src'))
.toMatchInlineSnapshot(`
Object {
"^app$": "<rootDir>/src/app",
"^app/(.*)$": "<rootDir>/src/app/$1",
"^cli$": "<rootDir>/cli",
"^cli/(.*)$": "<rootDir>/cli/$1",
}
`));

it('respects no paths', () =>
expect(act({})).toMatchInlineSnapshot(`Object {}`));

it('falls back on undefined paths', () =>
expect(act(undefined)).toMatchInlineSnapshot(`
Object {
"^src$": "<rootDir>/src",
"^src/(.*)$": "<rootDir>/src/$1",
}
`));

it('falls back on invalid config', () =>
expect(act('INVALID')).toMatchInlineSnapshot(`
Object {
"^src$": "<rootDir>/src",
"^src/(.*)$": "<rootDir>/src/$1",
}
`));
});
19 changes: 19 additions & 0 deletions jest/transform.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const { defaults } = require('ts-jest/presets');

const TS_JEST_NAME = 'ts-jest';

/**
* Resolved path of the `ts-jest` preset.
*
* This allows Jest to resolve the preset even if it is installed to a nested
* `./node_modules/skuba/node_modules/ts-jest` directory.
*/
const TS_JEST_PATH = require.resolve(TS_JEST_NAME);

// Rewrite `ts-jest` transformations using our resolved `TS_JEST_PATH`.
module.exports.transform = Object.fromEntries(
Object.entries(defaults.transform).map(([key, value]) => [
key,
value === TS_JEST_NAME ? TS_JEST_PATH : value,
]),
);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"typings": "lib/index.d.ts",
"files": [
"config/**/*",
"jest/**/*",
"lib*/**/*.d.ts",
"lib*/**/*.js",
"lib*/**/*.js.map",
Expand Down

0 comments on commit 1a40ea5

Please sign in to comment.