Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): initial experimental implementat…
Browse files Browse the repository at this point in the history
…ion of `@web/test-runner` builder

This is a new `@angular-devkit/build-angular:web-test-runner` builder which invokes Web Test Runner to execute unit tests in a real browser.

The implementation calls `application` builder under the hood with some option overrides build the application to a temporary directory and then runs Web Test Runner on the output. This set up is still minimal, but sufficient to run and pass tests in the generated `ng new` application.

The `schema.json` file is directly copied from the `karma` builder, since this is intended to serve as a migration target for users coming from Karma. Most of the options don't actually work yet, which is logged when they are used.

The most interesting part of this change is configuring Jasmine to execute in Web Test Runner. This is done through the `testRunnerHtml` option which allows us to control the HTML page tests are executed on. We use `test_page.html` which very carefully controls the loading process. I opted to make a single `<script type="module">` which dynamic imports all the relevant pieces so the ordering can be directly controlled more easily. This is better than trying to manage multiple `<script>` tags and pass data between them. Ideally everything would be bundled into a single entry point, however this is not feasible due to the way that ordering requirements do not align with typical `import` structure. Jasmine must come before polyfills which must come before the runner which invokes user code. In an ideal world, this ordering relationship would be represented in `import` statements, but this is not practically feasible because Angular CLI doesn't own all the files (`./polyfills.js` is user-defined) and Jasmine's loading must be split into two places so Zone.js can properly patch it.

`jasmine_runner.js` serves the purpose of executing Jasmine tests and reporting their results to Web Test Runner. I tried to write `jasmine_runner.js` in TypeScript and compile it with a `ts_library`. Unfortunately I don't think this is feasible because it needs to import `@web/test-runner-core` at runtime. This dependency has some code generated at runtime in Web Test Runner, meaning we cannot bundle this dependency and must mark it as external and dynamic `import()` the package at runtime. This works fine in native ESM, but compiling with TypeScript outputs CommonJS code by default (and I don't believe our `@build_bazel_rules_nodejs` setup can easily change that), so any `import('@web/test-runner-core')` becomes `require('@web/test-runner-core')` which fails because that package is ESM-only. The `loadEsmModule` trick does work here either because Web Test Runner is applying Node module resolution at serve time, meaning it looks for `import('@web/test-runner-core')` and rewrites it to something like `import('/node_modules/@web/test-runner-core')`. In short, there is no easy syntax which circumvents the TypeScript compiler while also being statically analyzable to Web Test Runner.
  • Loading branch information
alan-agius4 committed Jan 3, 2024
1 parent 7a8bdee commit 68dae53
Show file tree
Hide file tree
Showing 13 changed files with 1,847 additions and 64 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"@typescript-eslint/eslint-plugin": "6.17.0",
"@typescript-eslint/parser": "6.17.0",
"@vitejs/plugin-basic-ssl": "1.0.2",
"@web/test-runner": "^0.17.3",
"@yarnpkg/lockfile": "1.1.0",
"ajv": "8.12.0",
"ajv-formats": "2.1.1",
Expand Down
1 change: 1 addition & 0 deletions packages/angular/cli/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ CLI_SCHEMA_DATA = [
"//packages/angular_devkit/build_angular:src/builders/dev-server/schema.json",
"//packages/angular_devkit/build_angular:src/builders/extract-i18n/schema.json",
"//packages/angular_devkit/build_angular:src/builders/jest/schema.json",
"//packages/angular_devkit/build_angular:src/builders/web-test-runner/schema.json",
"//packages/angular_devkit/build_angular:src/builders/karma/schema.json",
"//packages/angular_devkit/build_angular:src/builders/ng-packagr/schema.json",
"//packages/angular_devkit/build_angular:src/builders/prerender/schema.json",
Expand Down
23 changes: 23 additions & 0 deletions packages/angular/cli/lib/config/workspace-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@
"@angular-devkit/build-angular:ng-packagr",
"@angular-devkit/build-angular:prerender",
"@angular-devkit/build-angular:jest",
"@angular-devkit/build-angular:web-test-runner",
"@angular-devkit/build-angular:protractor",
"@angular-devkit/build-angular:server",
"@angular-devkit/build-angular:ssr-dev-server"
Expand Down Expand Up @@ -564,6 +565,28 @@
}
}
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"builder": {
"const": "@angular-devkit/build-angular:web-test-runner"
},
"defaultConfiguration": {
"type": "string",
"description": "A default named configuration to use when a target configuration is not provided."
},
"options": {
"$ref": "../../../../angular_devkit/build_angular/src/builders/web-test-runner/schema.json"
},
"configurations": {
"type": "object",
"additionalProperties": {
"$ref": "../../../../angular_devkit/build_angular/src/builders/web-test-runner/schema.json"
}
}
}
},
{
"type": "object",
"additionalProperties": false,
Expand Down
7 changes: 7 additions & 0 deletions packages/angular_devkit/build_angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ ts_json_schema(
src = "src/builders/prerender/schema.json",
)

ts_json_schema(
name = "web_test_runner_schema",
src = "src/builders/web-test-runner/schema.json",
)

ts_library(
name = "build_angular",
package_name = "@angular-devkit/build-angular",
Expand Down Expand Up @@ -106,6 +111,7 @@ ts_library(
"//packages/angular_devkit/build_angular:src/builders/protractor/schema.ts",
"//packages/angular_devkit/build_angular:src/builders/server/schema.ts",
"//packages/angular_devkit/build_angular:src/builders/ssr-dev-server/schema.ts",
"//packages/angular_devkit/build_angular:src/builders/web-test-runner/schema.ts",
],
data = glob(
include = [
Expand Down Expand Up @@ -156,6 +162,7 @@ ts_library(
"@npm//@types/text-table",
"@npm//@types/watchpack",
"@npm//@vitejs/plugin-basic-ssl",
"@npm//@web/test-runner",
"@npm//ajv",
"@npm//ansi-colors",
"@npm//autoprefixer",
Expand Down
5 changes: 5 additions & 0 deletions packages/angular_devkit/build_angular/builders.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
"schema": "./src/builders/karma/schema.json",
"description": "Run Karma unit tests."
},
"web-test-runner": {
"implementation": "./src/builders/web-test-runner",
"schema": "./src/builders/web-test-runner/schema.json",
"description": "Run unit tests with Web Test Runner."
},
"protractor": {
"implementation": "./src/builders/protractor",
"schema": "./src/builders/protractor/schema.json",
Expand Down
4 changes: 4 additions & 0 deletions packages/angular_devkit/build_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@angular/localize": "^17.0.0 || ^17.1.0-next.0",
"@angular/platform-server": "^17.0.0 || ^17.1.0-next.0",
"@angular/service-worker": "^17.0.0 || ^17.1.0-next.0",
"@web/test-runner": "^0.17.3",
"browser-sync": "^3.0.2",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
Expand All @@ -98,6 +99,9 @@
"@angular/service-worker": {
"optional": true
},
"@web/test-runner": {
"optional": true
},
"browser-sync": {
"optional": true
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* @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.io/license
*/

import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import type * as WebTestRunner from '@web/test-runner';
import { promises as fs } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { findTestFiles } from '../../utils/test-files';
import { buildApplicationInternal } from '../application';
import { OutputHashing } from '../browser-esbuild/schema';
import { WtrBuilderOptions, normalizeOptions } from './options';
import { Schema } from './schema';

export default createBuilder(
async (schema: Schema, ctx: BuilderContext): Promise<BuilderOutput> => {
ctx.logger.warn(
'NOTE: The Web Test Runner builder is currently EXPERIMENTAL and not ready for production use.',
);

// Dynamic import `@web/test-runner` from the user's workspace. As an optional peer dep, it may not be installed
// and may not be resolvable from `@angular-devkit/build-angular`.
const require = createRequire(`${ctx.workspaceRoot}/`);
let wtr: typeof WebTestRunner;
try {
wtr = require('@web/test-runner');
} catch {
return {
success: false,
// TODO(dgp1130): Display a more accurate message for non-NPM users.
error:
'Web Test Runner is not installed, most likely you need to run `npm install @web/test-runner --save-dev` in your project.',
};
}

const options = normalizeOptions(schema);
const testDir = 'dist/test-out';

// Parallelize startup work.
const [testFiles] = await Promise.all([
// Glob for files to test.
findTestFiles(options.include, options.exclude, ctx.workspaceRoot).then((files) =>
Array.from(files).map((file) => path.relative(process.cwd(), file)),
),
// Clean build output path.
fs.rm(testDir, { recursive: true, force: true }),
]);

// Build the tests and abort on any build failure.
const buildOutput = await buildTests(testFiles, testDir, options, ctx);
if (!buildOutput.success) {
return buildOutput;
}

// Run the built tests.
return await runTests(wtr, `${testDir}/browser`, options);
},
);

/** Build all the given test files and write the result to the given output path. */
async function buildTests(
testFiles: string[],
outputPath: string,
options: WtrBuilderOptions,
ctx: BuilderContext,
): Promise<BuilderOutput> {
const entryPoints = new Set([
...testFiles,
'jasmine-core/lib/jasmine-core/jasmine.js',
'@angular-devkit/build-angular/src/builders/web-test-runner/jasmine_runner.js',
]);

// Extract `zone.js/testing` to a separate entry point because it needs to be loaded after Jasmine.
const [polyfills, hasZoneTesting] = extractZoneTesting(options.polyfills);
if (hasZoneTesting) {
entryPoints.add('zone.js/testing');
}

// Build tests with `application` builder, using test files as entry points.
// Also bundle in Jasmine and the Jasmine runner script, which need to share chunked dependencies.
const buildOutput = await first(
buildApplicationInternal(
{
entryPoints,
tsConfig: options.tsConfig,
outputPath,
aot: false,
index: false,
outputHashing: OutputHashing.None,
optimization: false,
externalDependencies: [
// Resolved by `@web/test-runner` at runtime with dynamically generated code.
'@web/test-runner-core',
],
sourceMap: {
scripts: true,
styles: true,
vendor: true,
},
polyfills,
},
ctx,
),
);

return buildOutput;
}

function extractZoneTesting(
polyfills: readonly string[],
): [polyfills: string[], hasZoneTesting: boolean] {
const polyfillsWithoutZoneTesting = polyfills.filter(
(polyfill) => polyfill !== 'zone.js/testing',
);
const hasZoneTesting = polyfills.length !== polyfillsWithoutZoneTesting.length;

return [polyfillsWithoutZoneTesting, hasZoneTesting];
}

/** Run Web Test Runner on the given directory of bundled JavaScript tests. */
async function runTests(
wtr: typeof WebTestRunner,
testDir: string,
options: WtrBuilderOptions,
): Promise<BuilderOutput> {
const testPagePath = path.resolve(__dirname, 'test_page.html');
const testPage = await fs.readFile(testPagePath, 'utf8');

const runner = await wtr.startTestRunner({
config: {
rootDir: testDir,
files: [
`${testDir}/**/*.js`,
`!${testDir}/polyfills.js`,
`!${testDir}/chunk-*.js`,
`!${testDir}/jasmine.js`,
`!${testDir}/jasmine_runner.js`,
`!${testDir}/testing.js`, // `zone.js/testing`
],
testFramework: {
config: {
defaultTimeoutInterval: 5_000,
},
},
nodeResolve: true,
port: 9876,
watch: options.watch ?? false,

testRunnerHtml: (_testFramework, _config) => testPage,
},
readCliArgs: false,
readFileConfig: false,
autoExitProcess: false,
});
if (!runner) {
throw new Error('Failed to start Web Test Runner.');
}

// Wait for the tests to complete and stop the runner.
const passed = (await once(runner, 'finished')) as boolean;
await runner.stop();

// No need to return error messages because Web Test Runner already printed them to the console.
return { success: passed };
}

/** 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.');
}

/** Listens for a single emission of an event and returns the value emitted. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function once<Map extends Record<string, any>, EventKey extends string & keyof Map>(
emitter: WebTestRunner.EventEmitter<Map>,
event: EventKey,
): Promise<Map[EventKey]> {
return new Promise((resolve) => {
const onEmit = (arg: Map[EventKey]): void => {
emitter.off(event, onEmit);
resolve(arg);
};
emitter.on(event, onEmit);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* @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.io/license
*/

import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
import {
getConfig,
sessionFailed,
sessionFinished,
sessionStarted,
} from '@web/test-runner-core/browser/session.js';

/** Executes Angular Jasmine tests in the given environment and reports the results to Web Test Runner. */
export async function runJasmineTests(jasmineEnv) {
const allSpecs = [];
const failedSpecs = [];

jasmineEnv.addReporter({
specDone(result) {
const expectations = [...result.passedExpectations, ...result.failedExpectations];
allSpecs.push(...expectations.map((e) => ({ name: e.fullName, passed: e.passed })));

for (const e of result.failedExpectations) {
const message = `${result.fullName}\n${e.message}\n${e.stack}`;
// eslint-disable-next-line no-console
console.error(message);
failedSpecs.push({
message,
name: e.fullName,
stack: e.stack,
expected: e.expected,
actual: e.actual,
});
}
},

async jasmineDone(result) {
// eslint-disable-next-line no-console
console.log(`Tests ${result.overallStatus}!`);
await sessionFinished({
passed: result.overallStatus === 'passed',
errors: failedSpecs,
testResults: {
name: '',
suites: [],
tests: allSpecs,
},
});
},
});

await sessionStarted();

// Web Test Runner uses a different HTML page for every test, so we only get one `testFile` for the single `*.js` file we need to execute.
const { testFile, testFrameworkConfig } = await getConfig();
const config = { defaultTimeoutInterval: 60_000, ...(testFrameworkConfig ?? {}) };

// eslint-disable-next-line no-undef
jasmine.DEFAULT_TIMEOUT_INTERVAL = config.defaultTimeoutInterval;

// Initialize `TestBed` automatically for users. This assumes we already evaluated `zone.js/testing`.
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

// Load the test file and evaluate it.
try {
// eslint-disable-next-line no-undef
await import(new URL(testFile, document.baseURI).href);

// Execute the test functions.
// eslint-disable-next-line no-undef
jasmineEnv.execute();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
await sessionFailed(err);
}
}

0 comments on commit 68dae53

Please sign in to comment.