-
Notifications
You must be signed in to change notification settings - Fork 12k
/
Copy pathindex.ts
216 lines (187 loc) · 8.67 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { ResultKind, buildApplicationInternal } from '@angular/build/private';
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { execFile as execFileCb } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { promisify } from 'node:util';
import { colors } from '../../utils/color';
import { findTestFiles } from '../../utils/test-files';
import { OutputHashing } from '../browser-esbuild/schema';
import { writeTestFiles } from '../web-test-runner/write-test-files';
import { normalizeOptions } from './options';
import { Schema as JestBuilderSchema } from './schema';
const execFile = promisify(execFileCb);
/** Main execution function for the Jest builder. */
export default createBuilder(
async (schema: JestBuilderSchema, context: BuilderContext): Promise<BuilderOutput> => {
context.logger.warn(
'NOTE: The Jest builder is currently EXPERIMENTAL and not ready for production use.',
);
const options = normalizeOptions(schema);
const testOut = path.join(context.workspaceRoot, 'dist/test-out', randomUUID()); // TODO(dgp1130): Hide in temp directory.
// Verify Jest installation and get the path to it's binary.
// We need to `node_modules/.bin/jest`, but there is no means to resolve that directly. Fortunately Jest's `package.json` exports the
// same file at `bin/jest`, so we can just resolve that instead.
const jest = resolveModule('jest/bin/jest');
if (!jest) {
return {
success: false,
// TODO(dgp1130): Display a more accurate message for non-NPM users.
error:
'Jest is not installed, most likely you need to run `npm install jest --save-dev` in your project.',
};
}
// Verify that JSDom is installed in the project.
const environment = resolveModule('jest-environment-jsdom');
if (!environment) {
return {
success: false,
// TODO(dgp1130): Display a more accurate message for non-NPM users.
error:
'`jest-environment-jsdom` is not installed. Install it with `npm install jest-environment-jsdom --save-dev`.',
};
}
const [testFiles, customConfig] = await Promise.all([
findTestFiles(options.include, options.exclude, context.workspaceRoot),
findCustomJestConfig(context.workspaceRoot),
]);
// Warn if a custom Jest configuration is found. We won't use it, so if a developer is trying to use a custom config, this hopefully
// makes a better experience than silently ignoring the configuration.
// Ideally, this would be a hard error. However a Jest config could exist for testing other files in the workspace outside of Angular
// CLI, so we likely can't produce a hard error in this situation without an opt-out.
if (customConfig) {
context.logger.warn(
'A custom Jest config was found, but this is not supported by `@angular-devkit/build-angular:jest` and will be' +
` ignored: ${customConfig}. This is an experiment to see if completely abstracting away Jest's configuration is viable. Please` +
` consider if your use case can be met without directly modifying the Jest config. If this is a major obstacle for your use` +
` case, please post it in this issue so we can collect feedback and evaluate: https://github.com/angular/angular-cli/issues/25434.`,
);
}
// Build all the test files.
const jestGlobal = path.join(__dirname, 'jest-global.mjs');
const initTestBed = path.join(__dirname, 'init-test-bed.mjs');
const buildResult = await first(
buildApplicationInternal(
{
// Build all the test files and also the `jest-global` and `init-test-bed` scripts.
entryPoints: new Set([...testFiles, jestGlobal, initTestBed]),
tsConfig: options.tsConfig,
polyfills: options.polyfills ?? ['zone.js', 'zone.js/testing'],
outputPath: testOut,
aot: false,
index: false,
outputHashing: OutputHashing.None,
outExtension: 'mjs', // Force native ESM.
optimization: false,
sourceMap: {
scripts: true,
styles: false,
vendor: false,
},
},
context,
),
);
if (buildResult.kind === ResultKind.Failure) {
return { success: false };
} else if (buildResult.kind !== ResultKind.Full) {
return {
success: false,
error: 'A full build result is required from the application builder.',
};
}
// Write test files
await writeTestFiles(buildResult.files, testOut);
// Execute Jest on the built output directory.
const jestProc = execFile(process.execPath, [
'--experimental-vm-modules',
jest,
`--rootDir="${testOut}"`,
`--config=${path.join(__dirname, 'jest.config.mjs')}`,
'--testEnvironment=jsdom',
// TODO(dgp1130): Enable cache once we have a mechanism for properly clearing / disabling it.
'--no-cache',
// Run basically all files in the output directory, any excluded files were already dropped by the build.
`--testMatch="<rootDir>/**/*.mjs"`,
// Load polyfills and initialize the environment before executing each test file.
// IMPORTANT: Order matters here.
// First, we execute `jest-global.mjs` to initialize the `jest` global variable.
// Second, we execute user polyfills, including `zone.js` and `zone.js/testing`. This is dependent on the Jest global so it can patch
// the environment for fake async to work correctly.
// Third, we initialize `TestBed`. This is dependent on fake async being set up correctly beforehand.
`--setupFilesAfterEnv="<rootDir>/jest-global.mjs"`,
...(options.polyfills ? [`--setupFilesAfterEnv="<rootDir>/polyfills.mjs"`] : []),
`--setupFilesAfterEnv="<rootDir>/init-test-bed.mjs"`,
// Don't run any infrastructure files as tests, they are manually loaded where needed.
`--testPathIgnorePatterns="<rootDir>/jest-global\\.mjs"`,
...(options.polyfills ? [`--testPathIgnorePatterns="<rootDir>/polyfills\\.mjs"`] : []),
`--testPathIgnorePatterns="<rootDir>/init-test-bed\\.mjs"`,
// Skip shared chunks, as they are not entry points to tests.
`--testPathIgnorePatterns="<rootDir>/chunk-.*\\.mjs"`,
// Optionally enable color.
...(colors.enabled ? ['--colors'] : []),
]);
// Stream test output to the terminal.
jestProc.child.stdout?.on('data', (chunk) => {
context.logger.info(chunk);
});
jestProc.child.stderr?.on('data', (chunk) => {
// Write to stderr directly instead of `context.logger.error(chunk)` because the logger will overwrite Jest's coloring information.
process.stderr.write(chunk);
});
try {
await jestProc;
} catch (error) {
// No need to propagate error message, already piped to terminal output.
// TODO(dgp1130): Handle process spawning failures.
return { success: false };
}
return { success: true };
},
);
/** Returns the first item yielded by the given generator and cancels the execution. */
async function first<T>(generator: AsyncIterable<T>): Promise<T> {
for await (const value of generator) {
return value;
}
throw new Error('Expected generator to emit at least once.');
}
/** Safely resolves the given Node module string. */
function resolveModule(module: string): string | undefined {
try {
return require.resolve(module);
} catch {
return undefined;
}
}
/** Returns whether or not the provided directory includes a Jest configuration file. */
async function findCustomJestConfig(dir: string): Promise<string | undefined> {
const entries = await fs.readdir(dir, { withFileTypes: true });
// Jest supports many file extensions (`js`, `ts`, `cjs`, `cts`, `json`, etc.) Just look
// for anything with that prefix.
const config = entries.find((entry) => entry.isFile() && entry.name.startsWith('jest.config.'));
if (config) {
return path.join(dir, config.name);
}
// Jest also supports a `jest` key in `package.json`, look for a config there.
const packageJsonPath = path.join(dir, 'package.json');
let packageJson: string | undefined;
try {
packageJson = await fs.readFile(packageJsonPath, 'utf8');
} catch {
return undefined; // No package.json, therefore no Jest configuration in it.
}
const json = JSON.parse(packageJson) as { jest?: unknown };
if ('jest' in json) {
return packageJsonPath;
}
return undefined;
}