Skip to content

Commit 16356cb

Browse files
authored
fix(tests): Add const-to-var ts-jest transformer (#5022)
This does, during tests, what the `const`-to-`var` plugin in #4993 does for our built code. Though for tests bundle size isn't a concern, the first point raised there (of our shadowing of `global` being an issue now that we're ES6ified and living in a land of `const`s) stands, though in this case it seems to only affect Node 8. Since `ts-jest` doesn't use our built code, but instead compiles our TS on the fly, the existing plugin is of no help. Hence this transformer, which `ts-jest `will apply after it has transpiled our code to JS. It works by providing a visitor function which `ts-jest` passes on to TS, which then lets it loose on the AST. In our case (as you'd expect) our visitor stops on every `const` variable declaration node and replaces it with an equivalent `var` declaration node, and ignores all other nodes. Since it's only needed for node 8, we leave transpilation of the transformer itself and injection into our jest config until runtime on the Node 8 CI machine. As part of this work, the script which runs tests in CI (whose main job is to ensure compatibility with older versions of Node, and to which the aforementioned transpilation and injection were added) has been reorganized into functions, for (hopefully) better readability.
1 parent 1130c1c commit 16356cb

File tree

3 files changed

+199
-52
lines changed

3 files changed

+199
-52
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ scratch/
1212
*.pyc
1313
*.tsbuildinfo
1414
scenarios/*/dist/
15+
# transpiled transformers
16+
jest/transformers/*.js
1517

1618
# logs
1719
yarn-error.log

jest/transformers/constReplacer.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* This is a transformer which `ts-jest` applies during the compilation process, which switches all of the `const`s out
3+
* for `var`s. Unlike in our package builds, where we do the same substiution for bundle size reasons, here we do it
4+
* because otherwise `const global = getGlobalObject()` throws an error about redifining `global`. (This didn't used to
5+
* be a problem because our down-compilation did the `const`-`var` substitution for us, but now that we're ES6-only, we
6+
* have to do it ourselves.)
7+
*
8+
* Note: If you ever have to change this, and are testing it locally in the process, be sure to call
9+
* `yarn jest --clearCache`
10+
* before each test run, as transformation results are cached between runs.
11+
*/
12+
13+
import {
14+
createVariableDeclarationList,
15+
getCombinedNodeFlags,
16+
isVariableDeclarationList,
17+
Node,
18+
NodeFlags,
19+
SourceFile,
20+
TransformationContext,
21+
Transformer,
22+
TransformerFactory,
23+
visitEachChild,
24+
visitNode,
25+
VisitResult,
26+
} from 'typescript';
27+
28+
// These can be anything - they're just used to construct a cache key for the transformer returned by the factory below.
29+
// This really only matters when you're testing the transformer itself, as changing these values gives you a quick way
30+
// to invalidate the cache and ensure that changes you've made to the code here are immediately picked up on and used.
31+
export const name = 'const-to-var';
32+
export const version = '1.0';
33+
34+
/**
35+
* Check whether the given AST node represents a `const` token.
36+
*
37+
* This function comes from the TS compiler, and is copied here to get around the fact that it's not exported by the
38+
* `typescript` package.
39+
*
40+
* @param node The node to check
41+
* @returns A boolean indicating if the node is a `const` token.
42+
*/
43+
function isVarConst(node: Node): boolean {
44+
// eslint-disable-next-line no-bitwise
45+
return !!(getCombinedNodeFlags(node) & NodeFlags.Const);
46+
}
47+
48+
/**
49+
* Return a set of nested factory functions, which ultimately creates an AST-node visitor function, which can modify
50+
* each visited node as it sees fit, and uses it to walk the AST, returning the results.
51+
*
52+
* In our case, we're modifying all `const` variable declarations to use `var` instead.
53+
*/
54+
export function factory(): TransformerFactory<SourceFile> {
55+
// Create the transformer
56+
function transformerFactory(context: TransformationContext): Transformer<SourceFile> {
57+
// Create a visitor function and use it to walk the AST
58+
function transformer(sourceFile: SourceFile): SourceFile {
59+
// This visitor function can either return a node, in which case the subtree rooted at the returned node is
60+
// substituted for the subtree rooted at the visited node, or can use the recursive `visitEachChild` function
61+
// provided by TS to continue traversing the tree.
62+
function visitor(node: Node): VisitResult<Node> {
63+
// If we've found a `const` declaration, return a `var` declaration in its place
64+
if (isVariableDeclarationList(node) && isVarConst(node)) {
65+
// A declaration list with a `None` flag defaults to using `var`
66+
return createVariableDeclarationList(node.declarations, NodeFlags.None);
67+
}
68+
69+
// This wasn't a node we're interested in, so keep walking down the tree.
70+
return visitEachChild(node, visitor, context);
71+
}
72+
73+
// Having defined our visitor, pass it to the TS-provided `visitNode` function, which will use it to walk the AST,
74+
// and return the results of that walk.
75+
return visitNode(sourceFile, visitor);
76+
}
77+
78+
// Back in the transformer factory, return the transformer we just created
79+
return transformer;
80+
}
81+
82+
// Finally, we're back in `factory`, and can return the whole nested system
83+
return transformerFactory;
84+
}

scripts/test.ts

+113-52
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,127 @@
1-
import { spawnSync } from 'child_process';
2-
import { join } from 'path';
1+
import * as childProcess from 'child_process';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
34

4-
function run(cmd: string, cwd: string = '') {
5-
const result = spawnSync(cmd, { shell: true, stdio: 'inherit', cwd: join(__dirname, '..', cwd || '') });
5+
const CURRENT_NODE_VERSION = process.version.replace('v', '').split('.')[0];
66

7-
if (result.status !== 0) {
8-
process.exit(result.status || undefined);
9-
}
10-
}
7+
// We run ember tests in their own job.
8+
const DEFAULT_SKIP_TESTS_PACKAGES = ['@sentry/ember'];
9+
// These packages don't support Node 8 for syntax or dependency reasons.
10+
const NODE_8_SKIP_TESTS_PACKAGES = [
11+
...DEFAULT_SKIP_TESTS_PACKAGES,
12+
'@sentry-internal/eslint-plugin-sdk',
13+
'@sentry/react',
14+
'@sentry/wasm',
15+
'@sentry/gatsby',
16+
'@sentry/serverless',
17+
'@sentry/nextjs',
18+
'@sentry/angular',
19+
];
1120

12-
const nodeMajorVersion = parseInt(process.version.split('.')[0].replace('v', ''), 10);
21+
// We have to downgrade some of our dependencies in order to run tests in Node 8 and 10.
22+
const NODE_8_LEGACY_DEPENDENCIES = [
23+
24+
25+
26+
27+
28+
];
29+
const NODE_10_LEGACY_DEPENDENCIES = ['[email protected]'];
1330

14-
// Ember tests require dependency changes for each set of tests, making them quite slow. To compensate for this, in CI
15-
// we run them in a separate, parallel job.
16-
let ignorePackages = ['@sentry/ember'];
31+
/**
32+
* Run the given shell command, piping the shell process's `stdin`, `stdout`, and `stderr` to that of the current
33+
* process. Returns contents of `stdout`.
34+
*/
35+
function run(cmd: string, options?: childProcess.ExecSyncOptions) {
36+
return childProcess.execSync(cmd, { stdio: 'inherit', ...options });
37+
}
1738

18-
// install legacy versions of third-party packages whose current versions don't support node 8 or 10, and skip testing
19-
// our own packages which don't support node 8 for various syntax or dependency reasons
20-
if (nodeMajorVersion <= 10) {
21-
let legacyDependencies;
39+
/**
40+
* Install the given legacy dependencies, for compatibility with tests run in older versions of Node.
41+
*/
42+
function installLegacyDeps(legacyDeps: string[] = []): void {
43+
// Ignoring engines and scripts lets us get away with having incompatible things installed for SDK packages we're not
44+
// testing in the current node version, and ignoring the root check lets us install things at the repo root.
45+
run(`yarn add --dev --ignore-engines --ignore-scripts --ignore-workspace-root-check ${legacyDeps.join(' ')}`);
46+
}
2247

23-
if (nodeMajorVersion === 8) {
24-
legacyDependencies = [
25-
26-
27-
28-
29-
30-
];
48+
/**
49+
* Add a tranformer to our jest config, to do the same `const`-to-`var` replacement as our rollup plugin does.
50+
*
51+
* This is needed because Node 8 doesn't like the way we shadow `global` (`const global = getGlobalObject()`). Changing
52+
* it to a `var` solves this by making it redeclarable.
53+
*
54+
*/
55+
function addTransformer(): void {
56+
// Though newer `ts-jest` versions support transformers written in TS, the legacy version does not.
57+
run('yarn tsc --skipLibCheck jest/transformers/constReplacer.ts');
3158

32-
ignorePackages = [
33-
...ignorePackages,
34-
'@sentry-internal/eslint-plugin-sdk',
35-
'@sentry/react',
36-
'@sentry/wasm',
37-
'@sentry/gatsby',
38-
'@sentry/serverless',
39-
'@sentry/nextjs',
40-
'@sentry/angular',
41-
];
59+
// Loading the existing Jest config will error out unless the config file has an accompanying types file, so we have
60+
// to create that before we can load it.
61+
run('yarn tsc --allowJs --skipLibCheck --declaration --emitDeclarationOnly jest/jest.config.js');
62+
// eslint-disable-next-line @typescript-eslint/no-var-requires
63+
const jestConfig = require('../jest/jest.config.js');
4264

43-
// This is a hack, to deal the fact that the browser-based tests fail under Node 8, because of a conflict buried
44-
// somewhere in the interaction between our current overall set of dependencies and the older versions of a small
45-
// subset we're about to install below. Since they're browser-based, these tests are never going to be running in a
46-
// node 8 environment in any case, so it's fine to skip them here. (In the long run, we should only run such tests
47-
// against a single version of node, but in the short run, this at least allows us to not be blocked by the
48-
// failures.)
49-
run('rm -rf packages/tracing/test/browser');
50-
}
51-
// Node 10
52-
else {
53-
legacyDependencies = ['[email protected]'];
54-
}
65+
// Inject the transformer
66+
jestConfig.globals['ts-jest'].astTransformers = ['<rootDir>/../../jest/transformers/constReplacer.js'];
5567

56-
const legacyDepStr = legacyDependencies.join(' ');
68+
// When we required the jest config file above, all expressions it contained were evaluated. Specifically, the
69+
// `rootDir: process.cwd()`
70+
// entry was replaced with
71+
// `rootDir: "<hard-coded string result of running `process.cwd()` in the current process>"`,
72+
// Though it's a little brute-force-y, the easiest way to fix this is to just stringify the code and perform the
73+
// substitution in reverse.
74+
const stringifiedConfig = JSON.stringify(jestConfig, null, 2).replace(
75+
`"rootDir": "${process.cwd()}"`,
76+
'rootDir: process.cwd()',
77+
);
5778

58-
// ignoring engines and scripts lets us get away with having incompatible things installed for packages we're not testing
59-
run(`yarn add --dev --ignore-engines --ignore-scripts --ignore-workspace-root-check ${legacyDepStr}`);
79+
// Now we just have to convert it back to a module and write it to disk
80+
const code = `module.exports = ${stringifiedConfig}`;
81+
fs.writeFileSync(path.resolve('jest/jest.config.js'), code);
6082
}
6183

62-
const ignoreFlags = ignorePackages.map(dep => `--ignore="${dep}"`).join(' ');
84+
/**
85+
* Skip tests which don't apply to Node and therefore don't need to run in older Node versions.
86+
*
87+
* TODO We're foreced to skip these tests for compatibility reasons (right now this function only gets called in Node
88+
* 8), but we could be skipping a lot more tests in Node 8-14 - anything where compatibility with different Node
89+
* versions is irrelevant - and only running them in Node 16.
90+
*/
91+
function skipNonNodeTests(): void {
92+
run('rm -rf packages/tracing/test/browser');
93+
}
6394

64-
run(`yarn test ${ignoreFlags}`);
95+
/**
96+
* Run tests, ignoring the given packages
97+
*/
98+
function runWithIgnores(skipPackages: string[] = []): void {
99+
const ignoreFlags = skipPackages.map(dep => `--ignore="${dep}"`).join(' ');
100+
run(`yarn test ${ignoreFlags}`);
101+
}
102+
103+
/**
104+
* Run the tests, accounting for compatibility problems in older versions of Node.
105+
*/
106+
function runTests(): void {
107+
if (CURRENT_NODE_VERSION === '8') {
108+
installLegacyDeps(NODE_8_LEGACY_DEPENDENCIES);
109+
// Inject a `const`-to-`var` transformer, in order to stop Node 8 from complaining when we shadow `global`
110+
addTransformer();
111+
// TODO Right now, this just skips incompatible tests, but it could be skipping more (hence the aspirational name),
112+
// and not just in Node 8. See `skipNonNodeTests`'s docstring.
113+
skipNonNodeTests();
114+
runWithIgnores(NODE_8_SKIP_TESTS_PACKAGES);
115+
}
116+
//
117+
else if (CURRENT_NODE_VERSION === '10') {
118+
installLegacyDeps(NODE_10_LEGACY_DEPENDENCIES);
119+
runWithIgnores(DEFAULT_SKIP_TESTS_PACKAGES);
120+
}
121+
//
122+
else {
123+
runWithIgnores(DEFAULT_SKIP_TESTS_PACKAGES);
124+
}
125+
}
65126

66-
process.exit(0);
127+
runTests();

0 commit comments

Comments
 (0)