Skip to content

Commit 4b58aae

Browse files
authored
Merge pull request #948 from hoverinc/fix/jest-workaround
✨ Add Jest SWC workaround and module mapper helper
2 parents b4c1fde + ab6f8cd commit 4b58aae

13 files changed

+509
-58
lines changed

.babelrc.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"targets": {"node": "12"},
77
"modules": "commonjs"
88
}
9-
]
9+
],
10+
"@babel/preset-typescript"
1011
]
1112
}

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ dist
1414
!.yarn/plugins
1515
!.yarn/sdks
1616
!.yarn/versions
17-
.pnp.*
17+
.pnp.*
18+
19+
.swc/

package.json

+9-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
},
1414
"scripts": {
1515
"build": "run-p 'build:*'",
16-
"build:source": "babel --source-maps --out-dir dist --ignore '**/__tests__/**','**/__mocks__/**' --copy-files --no-copy-ignored src",
16+
"build:source": "babel --source-maps --extensions '.ts' --out-dir dist --ignore '**/__tests__/**','**/__mocks__/**' --copy-files --no-copy-ignored src",
1717
"build:types": "tsc -p src/",
1818
"ci-after-success": "node src ci-after-success",
1919
"commit": "node src commit",
@@ -51,8 +51,8 @@
5151
"@commitlint/config-conventional": "^17.8.1",
5252
"@commitlint/prompt": "^17.8.1",
5353
"@swc-node/jest": "^1.5.6",
54-
"@swc/core": "^1.3.38",
55-
"@swc/helpers": "^0.4.14",
54+
"@swc/core": "^1.3.102",
55+
"@swc/helpers": "^0.5.3",
5656
"@types/jest": "^29.5.4",
5757
"@types/lodash.has": "^4.5.8",
5858
"@types/mkdirp": "^1.0.2",
@@ -85,10 +85,12 @@
8585
"jest-watch-typeahead": "^2.2.2",
8686
"lint-staged": "^15.1.0",
8787
"lodash.has": "^4.5.2",
88+
"lodash.merge": "^4.6.2",
8889
"mkdirp": "^2.1.3",
8990
"prettier": "^2.8.8",
9091
"read-pkg-up": "^7.0.1",
9192
"rimraf": "^4.1.1",
93+
"swc_mut_cjs_exports": "^0.86.17",
9294
"tslib": "^2.6.2",
9395
"typescript": "^4.9.5",
9496
"which": "^3.0.0",
@@ -109,7 +111,8 @@
109111
"no-console": "off",
110112
"no-nested-ternary": "off",
111113
"no-useless-catch": "off",
112-
"jest/prefer-snapshot-hint": "off"
114+
"jest/prefer-snapshot-hint": "off",
115+
"import/consistent-type-specifier-style": "off"
113116
}
114117
},
115118
"eslintIgnore": [
@@ -129,7 +132,9 @@
129132
"@babel/cli": "^7.23.0",
130133
"@babel/core": "^7.23.2",
131134
"@babel/preset-env": "^7.23.2",
135+
"@babel/preset-typescript": "^7.23.3",
132136
"@types/cross-spawn": "^6.0.4",
137+
"@types/lodash.merge": "^4",
133138
"depcheck": "^1.4.7",
134139
"eslint-config-kentcdodds": "^20.5.0",
135140
"husky": "^8.0.3",

src/api/test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './test/index'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`pathsToModuleNameMapper should convert tsconfig mapping with given prefix: <rootDir>/ 1`] = `
4+
Object {
5+
"^@foo\\\\-bar/common$": "<rootDir>/../common/dist/library",
6+
"^@pkg/(.*)$": "<rootDir>/packages/$1",
7+
"^api/(.*)$": "<rootDir>/src/api/$1",
8+
"^client$": Array [
9+
"<rootDir>/src/client",
10+
"<rootDir>/src/client/index",
11+
],
12+
"^log$": "<rootDir>/src/utils/log",
13+
"^mocks/(.*)$": "<rootDir>/test/mocks/$1",
14+
"^server$": "<rootDir>/src/server",
15+
"^test/(.*)$": "<rootDir>/test/$1",
16+
"^test/(.*)/mock$": Array [
17+
"<rootDir>/test/mocks/$1",
18+
"<rootDir>/test/__mocks__/$1",
19+
],
20+
"^util/(.*)$": "<rootDir>/src/utils/$1",
21+
}
22+
`;
23+
24+
exports[`pathsToModuleNameMapper should convert tsconfig mapping with given prefix: foo 1`] = `
25+
Object {
26+
"^@foo\\\\-bar/common$": "foo/../common/dist/library",
27+
"^@pkg/(.*)$": "foo/packages/$1",
28+
"^api/(.*)$": "foo/src/api/$1",
29+
"^client$": Array [
30+
"foo/src/client",
31+
"foo/src/client/index",
32+
],
33+
"^log$": "foo/src/utils/log",
34+
"^mocks/(.*)$": "foo/test/mocks/$1",
35+
"^server$": "foo/src/server",
36+
"^test/(.*)$": "foo/test/$1",
37+
"^test/(.*)/mock$": Array [
38+
"foo/test/mocks/$1",
39+
"foo/test/__mocks__/$1",
40+
],
41+
"^util/(.*)$": "foo/src/utils/$1",
42+
}
43+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {pathsToModuleNameMapper} from '../paths-to-module-name-mapper'
2+
3+
const tsconfigMap = {
4+
log: ['src/utils/log'],
5+
server: ['src/server'],
6+
client: ['src/client', 'src/client/index'],
7+
'util/*': ['src/utils/*'],
8+
'api/*': ['src/api/*'],
9+
'test/*': ['test/*'],
10+
'mocks/*': ['test/mocks/*'],
11+
'test/*/mock': ['test/mocks/*', 'test/__mocks__/*'],
12+
'@foo-bar/common': ['../common/dist/library'],
13+
'@pkg/*': ['./packages/*'],
14+
}
15+
16+
describe('pathsToModuleNameMapper', () => {
17+
test('should convert tsconfig mapping with no given prefix', () => {
18+
expect(pathsToModuleNameMapper(tsconfigMap)).toMatchInlineSnapshot(`
19+
Object {
20+
"^@foo\\\\-bar/common$": "../common/dist/library",
21+
"^@pkg/(.*)$": "./packages/$1",
22+
"^api/(.*)$": "src/api/$1",
23+
"^client$": Array [
24+
"src/client",
25+
"src/client/index",
26+
],
27+
"^log$": "src/utils/log",
28+
"^mocks/(.*)$": "test/mocks/$1",
29+
"^server$": "src/server",
30+
"^test/(.*)$": "test/$1",
31+
"^test/(.*)/mock$": Array [
32+
"test/mocks/$1",
33+
"test/__mocks__/$1",
34+
],
35+
"^util/(.*)$": "src/utils/$1",
36+
}
37+
`)
38+
})
39+
40+
test('should add `js` extension to resolved config with useESM: true', () => {
41+
expect(pathsToModuleNameMapper(tsconfigMap, {useESM: true})).toEqual({
42+
/**
43+
* Why not using snapshot here?
44+
* Because the snapshot does not keep the property order, which is important for jest.
45+
* A pattern ending with `\\.js` should appear before another pattern without the extension does.
46+
*/
47+
'^log$': 'src/utils/log',
48+
'^server$': 'src/server',
49+
'^client$': ['src/client', 'src/client/index'],
50+
'^util/(.*)\\.js$': 'src/utils/$1',
51+
'^util/(.*)$': 'src/utils/$1',
52+
'^api/(.*)\\.js$': 'src/api/$1',
53+
'^api/(.*)$': 'src/api/$1',
54+
'^test/(.*)\\.js$': 'test/$1',
55+
'^test/(.*)$': 'test/$1',
56+
'^mocks/(.*)\\.js$': 'test/mocks/$1',
57+
'^mocks/(.*)$': 'test/mocks/$1',
58+
'^test/(.*)/mock\\.js$': ['test/mocks/$1', 'test/__mocks__/$1'],
59+
'^test/(.*)/mock$': ['test/mocks/$1', 'test/__mocks__/$1'],
60+
'^@foo\\-bar/common$': '../common/dist/library',
61+
'^@pkg/(.*)\\.js$': './packages/$1',
62+
'^@pkg/(.*)$': './packages/$1',
63+
'^(\\.{1,2}/.*)\\.js$': '$1',
64+
})
65+
})
66+
67+
test.each(['<rootDir>/', 'foo'])(
68+
'should convert tsconfig mapping with given prefix',
69+
prefix => {
70+
expect(pathsToModuleNameMapper(tsconfigMap, {prefix})).toMatchSnapshot(
71+
prefix,
72+
)
73+
},
74+
)
75+
76+
describe('warnings', () => {
77+
beforeEach(() => {
78+
jest.spyOn(console, 'warn').mockImplementation()
79+
})
80+
81+
afterEach(() => jest.mocked(console.warn).mockRestore())
82+
83+
test('should warn about mapping it cannot handle', () => {
84+
expect(
85+
pathsToModuleNameMapper({
86+
kept: ['src/kept'],
87+
'no-target': [],
88+
'too/*/many/*/stars': ['to/*/many/*/stars'],
89+
}),
90+
).toMatchInlineSnapshot(`
91+
Object {
92+
"^kept$": "src/kept",
93+
}
94+
`)
95+
96+
expect(jest.mocked(console.warn)).toHaveBeenCalledWith(
97+
'Not mapping "no-target" because it has no target.',
98+
)
99+
expect(jest.mocked(console.warn)).toHaveBeenCalledWith(
100+
'Not mapping "too/*/many/*/stars" because it has more than one star (`*`).',
101+
)
102+
})
103+
})
104+
})

src/api/test/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {pathsToModuleNameMapper} from './paths-to-module-name-mapper'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* NOTE: this was copy pasta'ed from `ts-jest` so that we can support path
3+
* aliases in `tsconfig.json` without necessarily relying on `ts-jest`
4+
*
5+
* @see {@link https://github.com/kulshekhar/ts-jest/blob/dd3523cb7571714f06f1ea2ed1e3cf11970fbfce/src/config/paths-to-module-name-mapper.ts}
6+
*/
7+
8+
import type {Config} from '@jest/types'
9+
import type {CompilerOptions} from 'typescript'
10+
11+
type TsPathMapping = Exclude<CompilerOptions['paths'], undefined>
12+
type JestPathMapping = Config.InitialOptions['moduleNameMapper']
13+
14+
// we don't need to escape all chars, so commented out is the real one
15+
// const escapeRegex = (str: string) => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
16+
const escapeRegex = (str: string) => str.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')
17+
18+
export const pathsToModuleNameMapper = (
19+
mapping: TsPathMapping,
20+
{prefix = '', useESM = false}: {prefix?: string; useESM?: boolean} = {},
21+
): JestPathMapping => {
22+
const jestMap: JestPathMapping = {}
23+
for (const fromPath of Object.keys(mapping)) {
24+
const toPaths = mapping[fromPath]
25+
// check that we have only one target path
26+
if (toPaths.length === 0) {
27+
console.warn(`Not mapping "${fromPath}" because it has no target.`)
28+
29+
continue
30+
}
31+
32+
// split with '*'
33+
const segments = fromPath.split(/\*/g)
34+
if (segments.length === 1) {
35+
const paths = toPaths.map(target => {
36+
const enrichedPrefix =
37+
prefix !== '' && !prefix.endsWith('/') ? `${prefix}/` : prefix
38+
39+
return `${enrichedPrefix}${target}`
40+
})
41+
const cjsPattern = `^${escapeRegex(fromPath)}$`
42+
jestMap[cjsPattern] = paths.length === 1 ? paths[0] : paths
43+
} else if (segments.length === 2) {
44+
const paths = toPaths.map(target => {
45+
const enrichedTarget =
46+
target.startsWith('./') && prefix !== ''
47+
? target.substring(target.indexOf('/') + 1)
48+
: target
49+
const enrichedPrefix =
50+
prefix !== '' && !prefix.endsWith('/') ? `${prefix}/` : prefix
51+
52+
return `${enrichedPrefix}${enrichedTarget.replace(/\*/g, '$1')}`
53+
})
54+
if (useESM) {
55+
const esmPattern = `^${escapeRegex(segments[0])}(.*)${escapeRegex(
56+
segments[1],
57+
)}\\.js$`
58+
jestMap[esmPattern] = paths.length === 1 ? paths[0] : paths
59+
}
60+
const cjsPattern = `^${escapeRegex(segments[0])}(.*)${escapeRegex(
61+
segments[1],
62+
)}$`
63+
jestMap[cjsPattern] = paths.length === 1 ? paths[0] : paths
64+
} else {
65+
console.warn(
66+
`Not mapping "${fromPath}" because it has more than one star (\`*\`).`,
67+
)
68+
}
69+
}
70+
71+
if (useESM) {
72+
jestMap['^(\\.{1,2}/.*)\\.js$'] = '$1'
73+
}
74+
75+
return jestMap
76+
}

src/config/__tests__/__snapshots__/eslintrc.js.snap

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Object {
6767
"@typescript-eslint/no-throw-literal": "off",
6868
"@typescript-eslint/return-await": "off",
6969
"class-methods-use-this": "off",
70+
"import/consistent-type-specifier-style": "off",
7071
"import/no-extraneous-dependencies": Array [
7172
"error",
7273
Object {
@@ -200,6 +201,7 @@ Object {
200201
"@typescript-eslint/no-throw-literal": "off",
201202
"@typescript-eslint/return-await": "off",
202203
"class-methods-use-this": "off",
204+
"import/consistent-type-specifier-style": "off",
203205
"import/no-extraneous-dependencies": Array [
204206
"error",
205207
Object {

src/config/helpers/build-eslint.js

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const buildConfig = ({withReact = false} = {}) => {
5353
rules: {
5454
'class-methods-use-this': 'off',
5555
'import/prefer-default-export': 'off',
56+
'import/consistent-type-specifier-style': 'off',
5657
'import/no-extraneous-dependencies': [
5758
'error',
5859
{

src/config/jest.config.js

+51-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
/** @typedef {import('@jest/types').Config.InitialOptions} JestConfig */
2+
/** @typedef {import('@swc-node/core').Options} SwcNodeOptions */
23

4+
const {dirname} = require('path')
5+
const merge = require('lodash.merge')
6+
const {
7+
readDefaultTsConfig,
8+
tsCompilerOptionsToSwcConfig,
9+
} = require('@swc-node/register/read-default-tsconfig')
310
const {ifAnyDep, hasFile, fromRoot, hasDevDep} = require('../utils')
411

512
const {
@@ -17,6 +24,23 @@ const ignores = [
1724
'__mocks__',
1825
]
1926

27+
/**
28+
* Get the path at which `@hover/javascript/jest` is installed in a dependent
29+
* project in order to resolve the Jest preset as sometimes package managers
30+
* nest the preset installation within the `@hover/javascript` installation.
31+
*
32+
* @returns
33+
*/
34+
const getResolvePaths = () => {
35+
try {
36+
const nested = require.resolve('@hover/javascript/jest')
37+
38+
return {paths: [dirname(nested)]}
39+
} catch {
40+
return undefined
41+
}
42+
}
43+
2044
/** @type JestConfig */
2145
const jestConfig = {
2246
roots: [fromRoot('.')],
@@ -50,7 +74,33 @@ const jestConfig = {
5074
],
5175
),
5276
)
53-
: {'^.+\\.(t|j)sx?$': [require.resolve('@swc-node/jest')]},
77+
: {
78+
'^.+\\.(t|j)sx?$': [
79+
require.resolve('@swc-node/jest', getResolvePaths()),
80+
/** @type {SwcNodeOptions} */ (
81+
merge(tsCompilerOptionsToSwcConfig(readDefaultTsConfig(), ''), {
82+
esModuleInterop: true,
83+
module: 'commonjs',
84+
swc: {
85+
jsc: {
86+
target: 'es2020',
87+
experimental: {
88+
plugins: [[require.resolve('swc_mut_cjs_exports'), {}]],
89+
},
90+
parser: {
91+
syntax: 'typescript',
92+
tsx: true,
93+
decorators: false,
94+
dynamicimport: true,
95+
},
96+
loose: true,
97+
externalHelpers: false,
98+
},
99+
},
100+
})
101+
),
102+
],
103+
},
54104
coveragePathIgnorePatterns: [
55105
...ignores,
56106
'src/(umd|cjs|esm)-entry.js$',

0 commit comments

Comments
 (0)