|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright Google LLC All Rights Reserved. |
| 4 | + * |
| 5 | + * Use of this source code is governed by an MIT-style license that can be |
| 6 | + * found in the LICENSE file at https://angular.io/license |
| 7 | + */ |
| 8 | + |
| 9 | +import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; |
| 10 | +import type * as WebTestRunner from '@web/test-runner'; |
| 11 | +import { promises as fs } from 'node:fs'; |
| 12 | +import { createRequire } from 'node:module'; |
| 13 | +import path from 'node:path'; |
| 14 | +import { findTestFiles } from '../../utils/test-files'; |
| 15 | +import { buildApplicationInternal } from '../application'; |
| 16 | +import { OutputHashing } from '../browser-esbuild/schema'; |
| 17 | +import { WtrBuilderOptions, normalizeOptions } from './options'; |
| 18 | +import { Schema } from './schema'; |
| 19 | + |
| 20 | +export default createBuilder( |
| 21 | + async (schema: Schema, ctx: BuilderContext): Promise<BuilderOutput> => { |
| 22 | + ctx.logger.warn( |
| 23 | + 'NOTE: The Web Test Runner builder is currently EXPERIMENTAL and not ready for production use.', |
| 24 | + ); |
| 25 | + |
| 26 | + // Dynamic import `@web/test-runner` from the user's workspace. As an optional peer dep, it may not be installed |
| 27 | + // and may not be resolvable from `@angular-devkit/build-angular`. |
| 28 | + const require = createRequire(`${ctx.workspaceRoot}/`); |
| 29 | + let wtr: typeof WebTestRunner; |
| 30 | + try { |
| 31 | + wtr = require('@web/test-runner'); |
| 32 | + } catch { |
| 33 | + return { |
| 34 | + success: false, |
| 35 | + // TODO(dgp1130): Display a more accurate message for non-NPM users. |
| 36 | + error: |
| 37 | + 'Web Test Runner is not installed, most likely you need to run `npm install @web/test-runner --save-dev` in your project.', |
| 38 | + }; |
| 39 | + } |
| 40 | + |
| 41 | + const options = normalizeOptions(schema); |
| 42 | + const testDir = 'dist/test-out'; |
| 43 | + |
| 44 | + // Parallelize startup work. |
| 45 | + const [testFiles] = await Promise.all([ |
| 46 | + // Glob for files to test. |
| 47 | + findTestFiles(options.include, options.exclude, ctx.workspaceRoot).then((files) => |
| 48 | + Array.from(files).map((file) => path.relative(process.cwd(), file)), |
| 49 | + ), |
| 50 | + // Clean build output path. |
| 51 | + fs.rm(testDir, { recursive: true, force: true }), |
| 52 | + ]); |
| 53 | + |
| 54 | + // Build the tests and abort on any build failure. |
| 55 | + const buildOutput = await buildTests(testFiles, testDir, options, ctx); |
| 56 | + if (!buildOutput.success) { |
| 57 | + return buildOutput; |
| 58 | + } |
| 59 | + |
| 60 | + // Run the built tests. |
| 61 | + return await runTests(wtr, `${testDir}/browser`, options); |
| 62 | + }, |
| 63 | +); |
| 64 | + |
| 65 | +/** Build all the given test files and write the result to the given output path. */ |
| 66 | +async function buildTests( |
| 67 | + testFiles: string[], |
| 68 | + outputPath: string, |
| 69 | + options: WtrBuilderOptions, |
| 70 | + ctx: BuilderContext, |
| 71 | +): Promise<BuilderOutput> { |
| 72 | + const entryPoints = new Set([ |
| 73 | + ...testFiles, |
| 74 | + 'jasmine-core/lib/jasmine-core/jasmine.js', |
| 75 | + '@angular-devkit/build-angular/src/builders/web-test-runner/jasmine_runner.js', |
| 76 | + ]); |
| 77 | + |
| 78 | + // Extract `zone.js/testing` to a separate entry point because it needs to be loaded after Jasmine. |
| 79 | + const [polyfills, hasZoneTesting] = extractZoneTesting(options.polyfills); |
| 80 | + if (hasZoneTesting) { |
| 81 | + entryPoints.add('zone.js/testing'); |
| 82 | + } |
| 83 | + |
| 84 | + // Build tests with `application` builder, using test files as entry points. |
| 85 | + // Also bundle in Jasmine and the Jasmine runner script, which need to share chunked dependencies. |
| 86 | + const buildOutput = await first( |
| 87 | + buildApplicationInternal( |
| 88 | + { |
| 89 | + entryPoints, |
| 90 | + tsConfig: options.tsConfig, |
| 91 | + outputPath, |
| 92 | + aot: false, |
| 93 | + index: false, |
| 94 | + outputHashing: OutputHashing.None, |
| 95 | + optimization: false, |
| 96 | + externalDependencies: [ |
| 97 | + // Resolved by `@web/test-runner` at runtime with dynamically generated code. |
| 98 | + '@web/test-runner-core', |
| 99 | + ], |
| 100 | + sourceMap: { |
| 101 | + scripts: true, |
| 102 | + styles: true, |
| 103 | + vendor: true, |
| 104 | + }, |
| 105 | + polyfills, |
| 106 | + }, |
| 107 | + ctx, |
| 108 | + ), |
| 109 | + ); |
| 110 | + |
| 111 | + return buildOutput; |
| 112 | +} |
| 113 | + |
| 114 | +function extractZoneTesting( |
| 115 | + polyfills: readonly string[], |
| 116 | +): [polyfills: string[], hasZoneTesting: boolean] { |
| 117 | + const polyfillsWithoutZoneTesting = polyfills.filter( |
| 118 | + (polyfill) => polyfill !== 'zone.js/testing', |
| 119 | + ); |
| 120 | + const hasZoneTesting = polyfills.length !== polyfillsWithoutZoneTesting.length; |
| 121 | + |
| 122 | + return [polyfillsWithoutZoneTesting, hasZoneTesting]; |
| 123 | +} |
| 124 | + |
| 125 | +/** Run Web Test Runner on the given directory of bundled JavaScript tests. */ |
| 126 | +async function runTests( |
| 127 | + wtr: typeof WebTestRunner, |
| 128 | + testDir: string, |
| 129 | + options: WtrBuilderOptions, |
| 130 | +): Promise<BuilderOutput> { |
| 131 | + const testPagePath = path.resolve(__dirname, 'test_page.html'); |
| 132 | + const testPage = await fs.readFile(testPagePath, 'utf8'); |
| 133 | + |
| 134 | + const runner = await wtr.startTestRunner({ |
| 135 | + config: { |
| 136 | + rootDir: testDir, |
| 137 | + files: [ |
| 138 | + `${testDir}/**/*.js`, |
| 139 | + `!${testDir}/polyfills.js`, |
| 140 | + `!${testDir}/chunk-*.js`, |
| 141 | + `!${testDir}/jasmine.js`, |
| 142 | + `!${testDir}/jasmine_runner.js`, |
| 143 | + `!${testDir}/testing.js`, // `zone.js/testing` |
| 144 | + ], |
| 145 | + testFramework: { |
| 146 | + config: { |
| 147 | + defaultTimeoutInterval: 5_000, |
| 148 | + }, |
| 149 | + }, |
| 150 | + nodeResolve: true, |
| 151 | + port: 9876, |
| 152 | + watch: options.watch ?? false, |
| 153 | + |
| 154 | + testRunnerHtml: (_testFramework, _config) => testPage, |
| 155 | + }, |
| 156 | + readCliArgs: false, |
| 157 | + readFileConfig: false, |
| 158 | + autoExitProcess: false, |
| 159 | + }); |
| 160 | + if (!runner) { |
| 161 | + throw new Error('Failed to start Web Test Runner.'); |
| 162 | + } |
| 163 | + |
| 164 | + // Wait for the tests to complete and stop the runner. |
| 165 | + const passed = (await once(runner, 'finished')) as boolean; |
| 166 | + await runner.stop(); |
| 167 | + |
| 168 | + // No need to return error messages because Web Test Runner already printed them to the console. |
| 169 | + return { success: passed }; |
| 170 | +} |
| 171 | + |
| 172 | +/** Returns the first item yielded by the given generator and cancels the execution. */ |
| 173 | +async function first<T>(generator: AsyncIterable<T>): Promise<T> { |
| 174 | + for await (const value of generator) { |
| 175 | + return value; |
| 176 | + } |
| 177 | + |
| 178 | + throw new Error('Expected generator to emit at least once.'); |
| 179 | +} |
| 180 | + |
| 181 | +/** Listens for a single emission of an event and returns the value emitted. */ |
| 182 | +// eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 183 | +function once<Map extends Record<string, any>, EventKey extends string & keyof Map>( |
| 184 | + emitter: WebTestRunner.EventEmitter<Map>, |
| 185 | + event: EventKey, |
| 186 | +): Promise<Map[EventKey]> { |
| 187 | + return new Promise((resolve) => { |
| 188 | + const onEmit = (arg: Map[EventKey]): void => { |
| 189 | + emitter.off(event, onEmit); |
| 190 | + resolve(arg); |
| 191 | + }; |
| 192 | + emitter.on(event, onEmit); |
| 193 | + }); |
| 194 | +} |
0 commit comments