Skip to content
This repository has been archived by the owner on Mar 15, 2024. It is now read-only.

Commit

Permalink
fix: instrument jest tests with transformers
Browse files Browse the repository at this point in the history
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
lachrist committed Jan 20, 2023
1 parent 6772ad8 commit de5e92f
Show file tree
Hide file tree
Showing 26 changed files with 988 additions and 35 deletions.
1 change: 0 additions & 1 deletion components/configuration-accessor/default/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
test
node
browser
2 changes: 2 additions & 0 deletions components/configuration-accessor/default/.ordering
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ escape.mjs
tokenize.mjs
package.mjs
mocha.mjs
jest-config.mjs
jest-argv.mjs
jest.mjs
node.mjs
process.mjs
Expand Down
117 changes: 117 additions & 0 deletions components/configuration-accessor/default/jest-argv.mjs
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 components/configuration-accessor/default/jest-argv.test.mjs
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 components/configuration-accessor/default/jest-config.mjs
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 {};
}
}
};
Loading

0 comments on commit de5e92f

Please sign in to comment.