Skip to content

✨ Add Jest SWC workaround and module mapper helper #948

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

Merged
merged 3 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading