This repository has been archived by the owner on Mar 15, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: instrument jest tests with transformers
Jest implements its own module system. So the regular node module hooking does not work. Instead, we use the jest's transformer api. Unfortunately, this option is overwritten on each new occurrence. So we have to be careful to still apply the transformers of the user, otherwise we might receive dialects such as react and typescript which we are not able to instrument. Doing so requires to load the user configuration. This represents some work because there are numerous way to configure jest. NB: I tried to use `readConfigs` from the package `jest-config` I could not make it work for what I needed. The main steps of the solution are: - Preload the jest config of the user to access its `transform` value. - Hook to jest command by making `lib/node/transformer-jest.mjs` the only transformer. - This transformer receives the transformers of the user. - It loads them and export an object that can be patched to modify any javascript code on load. - This object is used by `hook-module` when the recorder is `jest`.
- Loading branch information
Showing
26 changed files
with
988 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,2 @@ | ||
test | ||
node | ||
browser |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
117 changes: 117 additions & 0 deletions
117
components/configuration-accessor/default/jest-argv.mjs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import * as Minimist from "minimist"; | ||
import { ExternalAppmapError } from "../../error/index.mjs"; | ||
import { logError, logErrorWhen } from "../../log/index.mjs"; | ||
import { hasOwnProperty, assert, constant } from "../../util/index.mjs"; | ||
import { loadJestConfigAsync } from "./jest-config.mjs"; | ||
import { self_directory } from "../../self/index.mjs"; | ||
import { convertFileUrlToPath } from "../../path/index.mjs"; | ||
import { toAbsoluteUrl, toDirectoryUrl } from "../../url/index.mjs"; | ||
|
||
const { | ||
JSON: { stringify: stringifyJSON, parse: parseJSON }, | ||
Object: { entries: toEntries, fromEntries }, | ||
Array: { isArray }, | ||
} = globalThis; | ||
|
||
const { default: minimist } = Minimist; | ||
|
||
const hook = convertFileUrlToPath( | ||
toAbsoluteUrl("lib/node/transformer-jest.mjs", self_directory), | ||
); | ||
|
||
const extractTransformEntryValue = (value) => { | ||
if (typeof value === "string") { | ||
return { specifier: value, options: {} }; | ||
} else { | ||
assert( | ||
!logErrorWhen( | ||
!isArray(value) || value.length !== 2 || typeof value[0] !== "string", | ||
"Invalid transform field, expected transform field to be either a string or an array of length two whose first element is a string: %j", | ||
value, | ||
), | ||
"Invalid transform field", | ||
ExternalAppmapError, | ||
); | ||
return { specifier: value[0], options: value[1] }; | ||
} | ||
}; | ||
|
||
const compileHookTransformEntry = (root) => { | ||
const replacement = constant(convertFileUrlToPath(root)); | ||
return ([key, value]) => { | ||
const { specifier, options } = extractTransformEntryValue(value); | ||
return [ | ||
key, | ||
{ | ||
specifier: specifier.replace(/<rootDir>\//gu, replacement), | ||
options, | ||
}, | ||
]; | ||
}; | ||
}; | ||
|
||
const hookTransformObject = (transform, root) => ({ | ||
"^": [ | ||
hook, | ||
fromEntries(toEntries(transform).map(compileHookTransformEntry(root))), | ||
], | ||
}); | ||
|
||
export const hookJestArgvAsync = async (argv, base) => { | ||
const options = minimist(argv); | ||
const root = hasOwnProperty(options, "rootDir") | ||
? toDirectoryUrl(toAbsoluteUrl(options.rootDir, base)) | ||
: base; | ||
if (hasOwnProperty(options, "transform")) { | ||
const index = argv.indexOf("--transform"); | ||
assert( | ||
!logErrorWhen( | ||
index !== argv.lastIndexOf("--transform"), | ||
"Jest `--transform` argument should not be duplicate: %j", | ||
argv, | ||
), | ||
ExternalAppmapError, | ||
"Jest --transform argument should not be duplicate", | ||
); | ||
assert( | ||
!logErrorWhen( | ||
index === argv.length - 1, | ||
"Jest `--transform` argument should not be in last postion: %j", | ||
argv, | ||
), | ||
ExternalAppmapError, | ||
"Jest `--transform` argument should not be in last postion", | ||
); | ||
let transform = argv[index + 1]; | ||
try { | ||
transform = parseJSON(transform); | ||
} catch (error) { | ||
logError( | ||
"Jest `--transform` argument should be a json string: %j >> %O", | ||
transform, | ||
error, | ||
); | ||
throw new ExternalAppmapError( | ||
"Jest --transform argument should be a json string", | ||
); | ||
} | ||
return [ | ||
...argv.slice(0, index + 1), | ||
stringifyJSON(hookTransformObject(transform, root)), | ||
...argv.slice(index + 2, argv.length), | ||
]; | ||
} else { | ||
const config = await loadJestConfigAsync(options, { root, base }); | ||
const transform = hasOwnProperty(config, "transform") | ||
? config.transform | ||
: // Default jest transformer. | ||
// cf: https://jestjs.io/docs/code-transformation#defaults | ||
// Unfortunately `require("jest-config").defaults.transform` is undefined | ||
{ "\\.[jt]sx?$": "babel-jest" }; | ||
return [ | ||
...argv, | ||
"--transform", | ||
stringifyJSON(hookTransformObject(transform, root)), | ||
]; | ||
} | ||
}; |
117 changes: 117 additions & 0 deletions
117
components/configuration-accessor/default/jest-argv.test.mjs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import { | ||
mkdir as mkdirAsync, | ||
writeFile as writeFileAsync, | ||
} from "node:fs/promises"; | ||
import { assertDeepEqual, assertReject } from "../../__fixture__.mjs"; | ||
import { getTmpUrl, convertFileUrlToPath } from "../../path/index.mjs"; | ||
import { self_directory } from "../../self/index.mjs"; | ||
import { getUuid } from "../../uuid/random/index.mjs"; | ||
import { toAbsoluteUrl } from "../../url/index.mjs"; | ||
import { hookJestArgvAsync } from "./jest-argv.mjs"; | ||
|
||
const { | ||
URL, | ||
JSON: { stringify: stringifyJSON }, | ||
} = globalThis; | ||
|
||
const hook = convertFileUrlToPath( | ||
toAbsoluteUrl("lib/node/transformer-jest.mjs", self_directory), | ||
); | ||
|
||
await assertReject( | ||
hookJestArgvAsync(["--transform", "invalid-json"], "file:///A:/base/"), | ||
/ExternalAppmapError: Jest --transform argument should be a json string/u, | ||
); | ||
|
||
assertDeepEqual( | ||
await hookJestArgvAsync( | ||
[ | ||
"--key1", | ||
"value1", | ||
"--transform", | ||
`{ "regexp": ["specifier", "options"] }`, | ||
"--key2", | ||
"value2", | ||
], | ||
"file:///A:/base/", | ||
), | ||
[ | ||
"--key1", | ||
"value1", | ||
"--transform", | ||
stringifyJSON({ | ||
"^": [ | ||
hook, | ||
{ | ||
regexp: { | ||
specifier: "specifier", | ||
options: "options", | ||
}, | ||
}, | ||
], | ||
}), | ||
"--key2", | ||
"value2", | ||
], | ||
); | ||
|
||
{ | ||
const dirname = getUuid(); | ||
await mkdirAsync(new URL(dirname, getTmpUrl())); | ||
await writeFileAsync( | ||
new URL(`${dirname}/jest.config.json`, getTmpUrl()), | ||
stringifyJSON({ | ||
transform: { | ||
regexp: "specifier", | ||
}, | ||
}), | ||
"utf8", | ||
); | ||
assertDeepEqual( | ||
await hookJestArgvAsync(["--rootDir", dirname], getTmpUrl()), | ||
[ | ||
"--rootDir", | ||
dirname, | ||
"--transform", | ||
stringifyJSON({ | ||
"^": [ | ||
hook, | ||
{ | ||
regexp: { | ||
specifier: "specifier", | ||
options: {}, | ||
}, | ||
}, | ||
], | ||
}), | ||
], | ||
); | ||
} | ||
|
||
{ | ||
const filename = `${getUuid()}.json`; | ||
await writeFileAsync( | ||
new URL(filename, getTmpUrl()), | ||
stringifyJSON({}), | ||
"utf8", | ||
); | ||
assertDeepEqual( | ||
await hookJestArgvAsync(["--config", filename], getTmpUrl()), | ||
[ | ||
"--config", | ||
filename, | ||
"--transform", | ||
stringifyJSON({ | ||
"^": [ | ||
hook, | ||
{ | ||
"\\.[jt]sx?$": { | ||
specifier: "babel-jest", | ||
options: {}, | ||
}, | ||
}, | ||
], | ||
}), | ||
], | ||
); | ||
} |
103 changes: 103 additions & 0 deletions
103
components/configuration-accessor/default/jest-config.mjs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import { readFile as readFileAsync } from "node:fs/promises"; | ||
import { createRequire } from "node:module"; | ||
import { hasOwnProperty } from "../../util/index.mjs"; | ||
import { logError } from "../../log/index.mjs"; | ||
import { ExternalAppmapError } from "../../error/index.mjs"; | ||
import { self_directory } from "../../self/index.mjs"; | ||
import { convertFileUrlToPath } from "../../path/index.mjs"; | ||
import { getUrlFilename, toAbsoluteUrl } from "../../url/index.mjs"; | ||
|
||
const { | ||
URL, | ||
JSON: { parse: parseJSON }, | ||
} = globalThis; | ||
|
||
// The location of require does not matter because it will only load file urls. | ||
const require = createRequire(self_directory); | ||
|
||
const loadConfigModuleAsync = async (url) => { | ||
if (getUrlFilename(url).endsWith(".mjs")) { | ||
return (await import(new URL(url))).default; | ||
} else { | ||
return require(convertFileUrlToPath(url)); | ||
} | ||
}; | ||
|
||
const loadConfigFileAsync = async (url, strict) => { | ||
try { | ||
if (getUrlFilename(url).endsWith(".json")) { | ||
return parseJSON(await readFileAsync(new URL(url), "utf8")); | ||
} else { | ||
const config = await loadConfigModuleAsync(url); | ||
if (typeof config === "function") { | ||
return await config(); | ||
} else { | ||
return config; | ||
} | ||
} | ||
} catch (error) { | ||
if ( | ||
hasOwnProperty(error, "code") && | ||
(error.code === "ENOENT" || | ||
error.code === "ERR_MODULE_NOT_FOUND" || | ||
error.code === "MODULE_NOT_FOUND") | ||
) { | ||
if (strict) { | ||
logError("Cannot find jest configuration file at %j", url); | ||
throw new ExternalAppmapError("Cannot find jest configuration file"); | ||
} else { | ||
return null; | ||
} | ||
} else { | ||
logError( | ||
"Failed to load jest configuration file at %j >> %O", | ||
url, | ||
error, | ||
); | ||
throw new ExternalAppmapError("Failed to load jest configuration file"); | ||
} | ||
} | ||
}; | ||
|
||
const loadPackageAsync = async (directory) => { | ||
try { | ||
return parseJSON( | ||
await readFileAsync( | ||
new URL(toAbsoluteUrl("package.json", directory)), | ||
"utf8", | ||
), | ||
); | ||
} catch (error) { | ||
if (hasOwnProperty(error, "code") && error.code === "ENOENT") { | ||
return null; | ||
} else { | ||
logError("Could not load package.json from %j >> %O", directory, error); | ||
throw new ExternalAppmapError("Could not load package.json"); | ||
} | ||
} | ||
}; | ||
|
||
export const loadJestConfigAsync = async (options, { root, base }) => { | ||
if (hasOwnProperty(options, "config")) { | ||
return await loadConfigFileAsync(toAbsoluteUrl(options.config, base), true); | ||
} else { | ||
const { jest: maybe_package_config } = { | ||
jest: null, | ||
...(await loadPackageAsync(root)), | ||
}; | ||
if (maybe_package_config !== null) { | ||
return maybe_package_config; | ||
} else { | ||
for (const extension of [".ts", ".js", ".cjs", ".mjs", ".json"]) { | ||
const maybe_config = await loadConfigFileAsync( | ||
toAbsoluteUrl(`jest.config${extension}`, root), | ||
false, | ||
); | ||
if (maybe_config !== null) { | ||
return maybe_config; | ||
} | ||
} | ||
return {}; | ||
} | ||
} | ||
}; |
Oops, something went wrong.