Skip to content

Commit 97c0cf8

Browse files
dgp1130angular-robot[bot]
authored andcommitted
refactor: add Jest test execution to Jest builder
This runs Jest on the outputs of the built test files and reports the results of the test execution. It depends on `jest` and `jest-environment-jsdom` as optional peer deps validated in the builder, so there isn't a strict dependency on Jest for applications which don't use it. Jest exports a `runJest()` function, however it can't be used directly because we need to opt-in to `--experimental-vm-modules` in ordre to run Jest on native ESM JavaScript as produced by `browser-esbuild`. This means we need a Node subprocess in order to add this flag, because the `ng` command cannot add a Node flag to its own current execution. This unfortunately means we can't just `import * as jest from 'jest';` or even `require.resolve('jest')` because that returns the library entry point exporting `runJest()`, rather than a script which actually runs Jest on load. Fortunately, Jest exports it's `node_modules/.bin/jest` entry point from its `package.json` under `jest/bin/jest`, so we `require.resolve()` _that_ to get the path to the correct file. Executing Jest is fairly straightforward, running on the output of the `browser-esbuild` execution with outputs streamed to the console. We opted to use JSDom over Domino in order to align with the existing Jest ecosystem.
1 parent 7fe6570 commit 97c0cf8

File tree

3 files changed

+97
-3
lines changed

3 files changed

+97
-3
lines changed

packages/angular_devkit/build_angular/package.json

+8
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
"@angular/localize": "^16.0.0-next.0",
7979
"@angular/platform-server": "^16.0.0-next.0",
8080
"@angular/service-worker": "^16.0.0-next.0",
81+
"jest": "^29.5.0",
82+
"jest-environment-jsdom": "^29.5.0",
8183
"karma": "^6.3.0",
8284
"ng-packagr": "^16.0.0-next.1",
8385
"protractor": "^7.0.0",
@@ -94,6 +96,12 @@
9496
"@angular/service-worker": {
9597
"optional": true
9698
},
99+
"jest": {
100+
"optional": true
101+
},
102+
"jest-environment-jsdom": {
103+
"optional": true
104+
},
97105
"karma": {
98106
"optional": true
99107
},

packages/angular_devkit/build_angular/src/builders/jest/index.ts

+88-2
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77
*/
88

99
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
10+
import { execFile as execFileCb } from 'child_process';
11+
import * as path from 'path';
12+
import { promisify } from 'util';
13+
import { colors } from '../../utils/color';
1014
import { buildEsbuildBrowserInternal } from '../browser-esbuild';
1115
import { BrowserEsbuildOptions } from '../browser-esbuild/options';
1216
import { OutputHashing } from '../browser-esbuild/schema';
1317
import { normalizeOptions } from './options';
1418
import { Schema as JestBuilderSchema } from './schema';
1519
import { findTestFiles } from './test-files';
1620

21+
const execFile = promisify(execFileCb);
22+
1723
/** Main execution function for the Jest builder. */
1824
export default createBuilder(
1925
async (schema: JestBuilderSchema, context: BuilderContext): Promise<BuilderOutput> => {
@@ -22,11 +28,35 @@ export default createBuilder(
2228
);
2329

2430
const options = normalizeOptions(schema);
25-
const testFiles = await findTestFiles(options, context.workspaceRoot);
2631
const testOut = 'dist/test-out'; // TODO(dgp1130): Hide in temp directory.
2732

33+
// Verify Jest installation and get the path to it's binary.
34+
// We need to `node_modules/.bin/jest`, but there is no means to resolve that directly. Fortunately Jest's `package.json` exports the
35+
// same file at `bin/jest`, so we can just resolve that instead.
36+
const jest = resolveModule('jest/bin/jest');
37+
if (!jest) {
38+
return {
39+
success: false,
40+
// TODO(dgp1130): Display a more accurate message for non-NPM users.
41+
error:
42+
'Jest is not installed, most likely you need to run `npm install jest --save-dev` in your project.',
43+
};
44+
}
45+
46+
// Verify that JSDom is installed in the project.
47+
const environment = resolveModule('jest-environment-jsdom');
48+
if (!environment) {
49+
return {
50+
success: false,
51+
// TODO(dgp1130): Display a more accurate message for non-NPM users.
52+
error:
53+
'`jest-environment-jsdom` is not installed. Install it with `npm install jest-environment-jsdom --save-dev`.',
54+
};
55+
}
56+
2857
// Build all the test files.
29-
return await build(context, {
58+
const testFiles = await findTestFiles(options, context.workspaceRoot);
59+
const buildResult = await build(context, {
3060
entryPoints: testFiles,
3161
tsConfig: options.tsConfig,
3262
polyfills: options.polyfills,
@@ -44,6 +74,53 @@ export default createBuilder(
4474
vendor: false,
4575
},
4676
});
77+
if (!buildResult.success) {
78+
return buildResult;
79+
}
80+
81+
// Execute Jest on the built output directory.
82+
const jestProc = execFile('node', [
83+
'--experimental-vm-modules',
84+
jest,
85+
86+
`--rootDir=${testOut}`,
87+
'--testEnvironment=jsdom',
88+
89+
// TODO(dgp1130): Enable cache once we have a mechanism for properly clearing / disabling it.
90+
'--no-cache',
91+
92+
// Run basically all files in the output directory, any excluded files were already dropped by the build.
93+
`--testMatch=${path.join('<rootDir>', '**', '*.mjs')}`,
94+
95+
// Load polyfills before each test, and don't run them directly as a test.
96+
`--setupFilesAfterEnv=${path.join('<rootDir>', 'polyfills.mjs')}`,
97+
`--testPathIgnorePatterns=${path.join('<rootDir>', 'polyfills\\.mjs')}`,
98+
99+
// Skip shared chunks, as they are not entry points to tests.
100+
`--testPathIgnorePatterns=${path.join('<rootDir>', 'chunk-.*\\.mjs')}`,
101+
102+
// Optionally enable color.
103+
...(colors.enabled ? ['--colors'] : []),
104+
]);
105+
106+
// Stream test output to the terminal.
107+
jestProc.child.stdout?.on('data', (chunk) => {
108+
context.logger.info(chunk);
109+
});
110+
jestProc.child.stderr?.on('data', (chunk) => {
111+
// Write to stderr directly instead of `context.logger.error(chunk)` because the logger will overwrite Jest's coloring information.
112+
process.stderr.write(chunk);
113+
});
114+
115+
try {
116+
await jestProc;
117+
} catch (error) {
118+
// No need to propagate error message, already piped to terminal output.
119+
// TODO(dgp1130): Handle process spawning failures.
120+
return { success: false };
121+
}
122+
123+
return { success: true };
47124
},
48125
);
49126

@@ -64,3 +141,12 @@ async function build(
64141
};
65142
}
66143
}
144+
145+
/** Safely resolves the given Node module string. */
146+
function resolveModule(module: string): string | undefined {
147+
try {
148+
return require.resolve(module);
149+
} catch {
150+
return undefined;
151+
}
152+
}

packages/angular_devkit/build_angular/src/builders/jest/schema.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"type": "string"
1111
},
1212
"default": ["**/*.spec.ts"],
13-
"description": "Globs of files to include, relative to project root. \nThere are 2 special cases:\n - when a path to directory is provided, all spec files ending \".spec.@(ts|tsx)\" will be included\n - when a path to a file is provided, and a matching spec file exists it will be included instead."
13+
"description": "Globs of files to include, relative to project root."
1414
},
1515
"exclude": {
1616
"type": "array",

0 commit comments

Comments
 (0)