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