diff --git a/.changeset/mighty-eagles-rush.md b/.changeset/mighty-eagles-rush.md new file mode 100644 index 000000000..815945a38 --- /dev/null +++ b/.changeset/mighty-eagles-rush.md @@ -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`. diff --git a/jest-preset.js b/jest-preset.js index 4a5d07511..343e9160b 100644 --- a/jest-preset.js +++ b/jest-preset.js @@ -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', @@ -35,10 +21,6 @@ module.exports = { '!/jest.*.ts', ], coverageDirectory: 'coverage', - moduleNameMapper: { - '^src$': '/src', - '^src/(.+)$': '/src/$1', - }, testEnvironment: 'node', testPathIgnorePatterns: [ '/node_modules.*/', diff --git a/jest/moduleNameMapper.js b/jest/moduleNameMapper.js new file mode 100644 index 000000000..6ddd8c2c5 --- /dev/null +++ b/jest/moduleNameMapper.js @@ -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('', 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 + // `/src/../cli`, which can be normalised to `/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: '' }); + } +}; diff --git a/jest/moduleNameMapper.test.ts b/jest/moduleNameMapper.test.ts new file mode 100644 index 000000000..a19806154 --- /dev/null +++ b/jest/moduleNameMapper.test.ts @@ -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$": "/lib/wip", + "^lib/wip/(.*)$": "/lib/wip/$1", + "^src$": "/src", + "^src/(.*)$": "/src/$1", + } + `)); + + it('expands non-wildcard paths', () => + expect(act({ cli: ['cli'], 'src/': ['src/'] })).toMatchInlineSnapshot(` + Object { + "^cli$": "/cli", + "^cli/(.*)$": "/cli/$1", + "^src$": "/src", + "^src/(.*)$": "/src/$1", + } + `)); + + it('expands duplicate asymmetric paths', () => + expect( + act({ + jquery: ['node_modules/jquery/dist/jquery'], + 'jquery/*': ['node_modules/jquery/dist/jquery/*'], + }), + ).toMatchInlineSnapshot(` + Object { + "^jquery$": "/node_modules/jquery/dist/jquery", + "^jquery/(.*)$": "/node_modules/jquery/dist/jquery/$1", + } + `)); + + it('respects a base URL', () => + expect(act({ cli: ['../cli'], 'app/*': ['app/*'] }, 'src')) + .toMatchInlineSnapshot(` + Object { + "^app$": "/src/app", + "^app/(.*)$": "/src/app/$1", + "^cli$": "/cli", + "^cli/(.*)$": "/cli/$1", + } + `)); + + it('respects no paths', () => + expect(act({})).toMatchInlineSnapshot(`Object {}`)); + + it('falls back on undefined paths', () => + expect(act(undefined)).toMatchInlineSnapshot(` + Object { + "^src$": "/src", + "^src/(.*)$": "/src/$1", + } + `)); + + it('falls back on invalid config', () => + expect(act('INVALID')).toMatchInlineSnapshot(` + Object { + "^src$": "/src", + "^src/(.*)$": "/src/$1", + } + `)); +}); diff --git a/jest/transform.js b/jest/transform.js new file mode 100644 index 000000000..f1787ed82 --- /dev/null +++ b/jest/transform.js @@ -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, + ]), +); diff --git a/package.json b/package.json index 7c138ab37..d5308c007 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typings": "lib/index.d.ts", "files": [ "config/**/*", + "jest/**/*", "lib*/**/*.d.ts", "lib*/**/*.js", "lib*/**/*.js.map",