Skip to content

Commit

Permalink
Merge pull request #948 from hoverinc/fix/jest-workaround
Browse files Browse the repository at this point in the history
✨ Add Jest SWC workaround and module mapper helper
  • Loading branch information
jrolfs committed Jan 8, 2024
2 parents b4c1fde + ab6f8cd commit 4b58aae
Show file tree
Hide file tree
Showing 13 changed files with 509 additions and 58 deletions.
3 changes: 2 additions & 1 deletion .babelrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"targets": {"node": "12"},
"modules": "commonjs"
}
]
],
"@babel/preset-typescript"
]
}
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ dist
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*
.pnp.*

.swc/
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"scripts": {
"build": "run-p 'build:*'",
"build:source": "babel --source-maps --out-dir dist --ignore '**/__tests__/**','**/__mocks__/**' --copy-files --no-copy-ignored src",
"build:source": "babel --source-maps --extensions '.ts' --out-dir dist --ignore '**/__tests__/**','**/__mocks__/**' --copy-files --no-copy-ignored src",
"build:types": "tsc -p src/",
"ci-after-success": "node src ci-after-success",
"commit": "node src commit",
Expand Down Expand Up @@ -51,8 +51,8 @@
"@commitlint/config-conventional": "^17.8.1",
"@commitlint/prompt": "^17.8.1",
"@swc-node/jest": "^1.5.6",
"@swc/core": "^1.3.38",
"@swc/helpers": "^0.4.14",
"@swc/core": "^1.3.102",
"@swc/helpers": "^0.5.3",
"@types/jest": "^29.5.4",
"@types/lodash.has": "^4.5.8",
"@types/mkdirp": "^1.0.2",
Expand Down Expand Up @@ -85,10 +85,12 @@
"jest-watch-typeahead": "^2.2.2",
"lint-staged": "^15.1.0",
"lodash.has": "^4.5.2",
"lodash.merge": "^4.6.2",
"mkdirp": "^2.1.3",
"prettier": "^2.8.8",
"read-pkg-up": "^7.0.1",
"rimraf": "^4.1.1",
"swc_mut_cjs_exports": "^0.86.17",
"tslib": "^2.6.2",
"typescript": "^4.9.5",
"which": "^3.0.0",
Expand All @@ -109,7 +111,8 @@
"no-console": "off",
"no-nested-ternary": "off",
"no-useless-catch": "off",
"jest/prefer-snapshot-hint": "off"
"jest/prefer-snapshot-hint": "off",
"import/consistent-type-specifier-style": "off"
}
},
"eslintIgnore": [
Expand All @@ -129,7 +132,9 @@
"@babel/cli": "^7.23.0",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.23.2",
"@babel/preset-typescript": "^7.23.3",
"@types/cross-spawn": "^6.0.4",
"@types/lodash.merge": "^4",
"depcheck": "^1.4.7",
"eslint-config-kentcdodds": "^20.5.0",
"husky": "^8.0.3",
Expand Down
1 change: 1 addition & 0 deletions src/api/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './test/index'
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`pathsToModuleNameMapper should convert tsconfig mapping with given prefix: <rootDir>/ 1`] = `
Object {
"^@foo\\\\-bar/common$": "<rootDir>/../common/dist/library",
"^@pkg/(.*)$": "<rootDir>/packages/$1",
"^api/(.*)$": "<rootDir>/src/api/$1",
"^client$": Array [
"<rootDir>/src/client",
"<rootDir>/src/client/index",
],
"^log$": "<rootDir>/src/utils/log",
"^mocks/(.*)$": "<rootDir>/test/mocks/$1",
"^server$": "<rootDir>/src/server",
"^test/(.*)$": "<rootDir>/test/$1",
"^test/(.*)/mock$": Array [
"<rootDir>/test/mocks/$1",
"<rootDir>/test/__mocks__/$1",
],
"^util/(.*)$": "<rootDir>/src/utils/$1",
}
`;
exports[`pathsToModuleNameMapper should convert tsconfig mapping with given prefix: foo 1`] = `
Object {
"^@foo\\\\-bar/common$": "foo/../common/dist/library",
"^@pkg/(.*)$": "foo/packages/$1",
"^api/(.*)$": "foo/src/api/$1",
"^client$": Array [
"foo/src/client",
"foo/src/client/index",
],
"^log$": "foo/src/utils/log",
"^mocks/(.*)$": "foo/test/mocks/$1",
"^server$": "foo/src/server",
"^test/(.*)$": "foo/test/$1",
"^test/(.*)/mock$": Array [
"foo/test/mocks/$1",
"foo/test/__mocks__/$1",
],
"^util/(.*)$": "foo/src/utils/$1",
}
`;
104 changes: 104 additions & 0 deletions src/api/test/__tests__/paths-to-module-name-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {pathsToModuleNameMapper} from '../paths-to-module-name-mapper'

const tsconfigMap = {
log: ['src/utils/log'],
server: ['src/server'],
client: ['src/client', 'src/client/index'],
'util/*': ['src/utils/*'],
'api/*': ['src/api/*'],
'test/*': ['test/*'],
'mocks/*': ['test/mocks/*'],
'test/*/mock': ['test/mocks/*', 'test/__mocks__/*'],
'@foo-bar/common': ['../common/dist/library'],
'@pkg/*': ['./packages/*'],
}

describe('pathsToModuleNameMapper', () => {
test('should convert tsconfig mapping with no given prefix', () => {
expect(pathsToModuleNameMapper(tsconfigMap)).toMatchInlineSnapshot(`
Object {
"^@foo\\\\-bar/common$": "../common/dist/library",
"^@pkg/(.*)$": "./packages/$1",
"^api/(.*)$": "src/api/$1",
"^client$": Array [
"src/client",
"src/client/index",
],
"^log$": "src/utils/log",
"^mocks/(.*)$": "test/mocks/$1",
"^server$": "src/server",
"^test/(.*)$": "test/$1",
"^test/(.*)/mock$": Array [
"test/mocks/$1",
"test/__mocks__/$1",
],
"^util/(.*)$": "src/utils/$1",
}
`)
})

test('should add `js` extension to resolved config with useESM: true', () => {
expect(pathsToModuleNameMapper(tsconfigMap, {useESM: true})).toEqual({
/**
* Why not using snapshot here?
* Because the snapshot does not keep the property order, which is important for jest.
* A pattern ending with `\\.js` should appear before another pattern without the extension does.
*/
'^log$': 'src/utils/log',
'^server$': 'src/server',
'^client$': ['src/client', 'src/client/index'],
'^util/(.*)\\.js$': 'src/utils/$1',
'^util/(.*)$': 'src/utils/$1',
'^api/(.*)\\.js$': 'src/api/$1',
'^api/(.*)$': 'src/api/$1',
'^test/(.*)\\.js$': 'test/$1',
'^test/(.*)$': 'test/$1',
'^mocks/(.*)\\.js$': 'test/mocks/$1',
'^mocks/(.*)$': 'test/mocks/$1',
'^test/(.*)/mock\\.js$': ['test/mocks/$1', 'test/__mocks__/$1'],
'^test/(.*)/mock$': ['test/mocks/$1', 'test/__mocks__/$1'],
'^@foo\\-bar/common$': '../common/dist/library',
'^@pkg/(.*)\\.js$': './packages/$1',
'^@pkg/(.*)$': './packages/$1',
'^(\\.{1,2}/.*)\\.js$': '$1',
})
})

test.each(['<rootDir>/', 'foo'])(
'should convert tsconfig mapping with given prefix',
prefix => {
expect(pathsToModuleNameMapper(tsconfigMap, {prefix})).toMatchSnapshot(
prefix,
)
},
)

describe('warnings', () => {
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation()
})

afterEach(() => jest.mocked(console.warn).mockRestore())

test('should warn about mapping it cannot handle', () => {
expect(
pathsToModuleNameMapper({
kept: ['src/kept'],
'no-target': [],
'too/*/many/*/stars': ['to/*/many/*/stars'],
}),
).toMatchInlineSnapshot(`
Object {
"^kept$": "src/kept",
}
`)

expect(jest.mocked(console.warn)).toHaveBeenCalledWith(
'Not mapping "no-target" because it has no target.',
)
expect(jest.mocked(console.warn)).toHaveBeenCalledWith(
'Not mapping "too/*/many/*/stars" because it has more than one star (`*`).',
)
})
})
})
1 change: 1 addition & 0 deletions src/api/test/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {pathsToModuleNameMapper} from './paths-to-module-name-mapper'
76 changes: 76 additions & 0 deletions src/api/test/paths-to-module-name-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* NOTE: this was copy pasta'ed from `ts-jest` so that we can support path
* aliases in `tsconfig.json` without necessarily relying on `ts-jest`
*
* @see {@link https://github.com/kulshekhar/ts-jest/blob/dd3523cb7571714f06f1ea2ed1e3cf11970fbfce/src/config/paths-to-module-name-mapper.ts}
*/

import type {Config} from '@jest/types'
import type {CompilerOptions} from 'typescript'

type TsPathMapping = Exclude<CompilerOptions['paths'], undefined>
type JestPathMapping = Config.InitialOptions['moduleNameMapper']

// we don't need to escape all chars, so commented out is the real one
// const escapeRegex = (str: string) => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
const escapeRegex = (str: string) => str.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')

export const pathsToModuleNameMapper = (
mapping: TsPathMapping,
{prefix = '', useESM = false}: {prefix?: string; useESM?: boolean} = {},
): JestPathMapping => {
const jestMap: JestPathMapping = {}
for (const fromPath of Object.keys(mapping)) {
const toPaths = mapping[fromPath]
// check that we have only one target path
if (toPaths.length === 0) {
console.warn(`Not mapping "${fromPath}" because it has no target.`)

continue
}

// split with '*'
const segments = fromPath.split(/\*/g)
if (segments.length === 1) {
const paths = toPaths.map(target => {
const enrichedPrefix =
prefix !== '' && !prefix.endsWith('/') ? `${prefix}/` : prefix

return `${enrichedPrefix}${target}`
})
const cjsPattern = `^${escapeRegex(fromPath)}$`
jestMap[cjsPattern] = paths.length === 1 ? paths[0] : paths
} else if (segments.length === 2) {
const paths = toPaths.map(target => {
const enrichedTarget =
target.startsWith('./') && prefix !== ''
? target.substring(target.indexOf('/') + 1)
: target
const enrichedPrefix =
prefix !== '' && !prefix.endsWith('/') ? `${prefix}/` : prefix

return `${enrichedPrefix}${enrichedTarget.replace(/\*/g, '$1')}`
})
if (useESM) {
const esmPattern = `^${escapeRegex(segments[0])}(.*)${escapeRegex(
segments[1],
)}\\.js$`
jestMap[esmPattern] = paths.length === 1 ? paths[0] : paths
}
const cjsPattern = `^${escapeRegex(segments[0])}(.*)${escapeRegex(
segments[1],
)}$`
jestMap[cjsPattern] = paths.length === 1 ? paths[0] : paths
} else {
console.warn(
`Not mapping "${fromPath}" because it has more than one star (\`*\`).`,
)
}
}

if (useESM) {
jestMap['^(\\.{1,2}/.*)\\.js$'] = '$1'
}

return jestMap
}
2 changes: 2 additions & 0 deletions src/config/__tests__/__snapshots__/eslintrc.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Object {
"@typescript-eslint/no-throw-literal": "off",
"@typescript-eslint/return-await": "off",
"class-methods-use-this": "off",
"import/consistent-type-specifier-style": "off",
"import/no-extraneous-dependencies": Array [
"error",
Object {
Expand Down Expand Up @@ -200,6 +201,7 @@ Object {
"@typescript-eslint/no-throw-literal": "off",
"@typescript-eslint/return-await": "off",
"class-methods-use-this": "off",
"import/consistent-type-specifier-style": "off",
"import/no-extraneous-dependencies": Array [
"error",
Object {
Expand Down
1 change: 1 addition & 0 deletions src/config/helpers/build-eslint.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const buildConfig = ({withReact = false} = {}) => {
rules: {
'class-methods-use-this': 'off',
'import/prefer-default-export': 'off',
'import/consistent-type-specifier-style': 'off',
'import/no-extraneous-dependencies': [
'error',
{
Expand Down
52 changes: 51 additions & 1 deletion src/config/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/** @typedef {import('@jest/types').Config.InitialOptions} JestConfig */
/** @typedef {import('@swc-node/core').Options} SwcNodeOptions */

const {dirname} = require('path')
const merge = require('lodash.merge')
const {
readDefaultTsConfig,
tsCompilerOptionsToSwcConfig,
} = require('@swc-node/register/read-default-tsconfig')
const {ifAnyDep, hasFile, fromRoot, hasDevDep} = require('../utils')

const {
Expand All @@ -17,6 +24,23 @@ const ignores = [
'__mocks__',
]

/**
* Get the path at which `@hover/javascript/jest` is installed in a dependent
* project in order to resolve the Jest preset as sometimes package managers
* nest the preset installation within the `@hover/javascript` installation.
*
* @returns
*/
const getResolvePaths = () => {
try {
const nested = require.resolve('@hover/javascript/jest')

return {paths: [dirname(nested)]}
} catch {
return undefined
}
}

/** @type JestConfig */
const jestConfig = {
roots: [fromRoot('.')],
Expand Down Expand Up @@ -50,7 +74,33 @@ const jestConfig = {
],
),
)
: {'^.+\\.(t|j)sx?$': [require.resolve('@swc-node/jest')]},
: {
'^.+\\.(t|j)sx?$': [
require.resolve('@swc-node/jest', getResolvePaths()),
/** @type {SwcNodeOptions} */ (
merge(tsCompilerOptionsToSwcConfig(readDefaultTsConfig(), ''), {
esModuleInterop: true,
module: 'commonjs',
swc: {
jsc: {
target: 'es2020',
experimental: {
plugins: [[require.resolve('swc_mut_cjs_exports'), {}]],
},
parser: {
syntax: 'typescript',
tsx: true,
decorators: false,
dynamicimport: true,
},
loose: true,
externalHelpers: false,
},
},
})
),
],
},
coveragePathIgnorePatterns: [
...ignores,
'src/(umd|cjs|esm)-entry.js$',
Expand Down

0 comments on commit 4b58aae

Please sign in to comment.