Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[heft] Add support for Jest to heft. #1966

Merged
merged 8 commits into from
Jul 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api-documenter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@
"@types/node": "10.17.13",
"@types/resolve": "1.17.1",
"gulp": "~4.0.2",
"jest": "25.4.0"
"jest": "~25.4.0"
}
}
11 changes: 11 additions & 0 deletions apps/heft/includes/jest-shared.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"silent": false,

"rootDir": "../../../../",
"testURL": "http://localhost/",
"testMatch": ["<rootDir>/src/**/*.test.ts?(x)"],
"transform": {
"src[\\\\/].+\\.ts$": "<rootDir>/node_modules/@rushstack/heft/lib/plugins/JestPlugin/HeftJestSrcTransform.js"
},
"passWithNoTests": true
}
4 changes: 4 additions & 0 deletions apps/heft/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"main": "lib/index.js",
"types": "dist/heft.d.ts",
"devDependencies": {
"@jest/types": "~25.4.0",
"@types/eslint": "7.2.0",
"@types/glob": "7.1.1",
"@types/node": "10.17.13",
Expand All @@ -21,12 +22,15 @@
"@rushstack/eslint-config": "workspace:*"
},
"dependencies": {
"@jest/core": "~25.4.0",
"@jest/reporters": "~25.4.0",
"@rushstack/node-core-library": "workspace:*",
"@rushstack/ts-command-line": "workspace:*",
"@types/tapable": "1.0.5",
"chokidar": "~3.4.0",
"glob-escape": "~0.0.2",
"glob": "~7.0.5",
"jest-snapshot": "~25.4.0",
"resolve": "~1.17.0",
"tapable": "1.1.3",
"true-case-path": "~2.2.1"
Expand Down
2 changes: 1 addition & 1 deletion apps/heft/src/cli/HeftToolsCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class HeftToolsCommandLineParser extends CommandLineParser {
const buildAction: BuildAction = new BuildAction({ ...actionOptions, cleanAction });
const devDeployAction: DevDeployAction = new DevDeployAction(actionOptions);
const startAction: StartAction = new StartAction(actionOptions);
const testAction: TestAction = new TestAction({ ...actionOptions, cleanAction });
const testAction: TestAction = new TestAction({ ...actionOptions, cleanAction, buildAction });

this._heftSession = new HeftSession({
getIsDebugMode: () => this.isDebug,
Expand Down
87 changes: 82 additions & 5 deletions apps/heft/src/cli/actions/TestAction.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,117 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
iclanton marked this conversation as resolved.
Show resolved Hide resolved
// See LICENSE in the project root for license information.

iclanton marked this conversation as resolved.
Show resolved Hide resolved
import { SyncHook } from 'tapable';
import { SyncHook, AsyncParallelHook, AsyncSeriesHook } from 'tapable';
import { CommandLineFlagParameter } from '@rushstack/ts-command-line';

import { BuildAction, IBuildActionOptions, IBuildActionContext } from './BuildAction';
import { ActionHooksBase, IActionContext } from './HeftActionBase';

/**
* @public
*/
export class TestHooks extends ActionHooksBase<ITestActionProperties> {}
export class TestHooks extends ActionHooksBase<ITestActionProperties> {
public readonly run: AsyncParallelHook = new AsyncParallelHook();
public readonly configureTest: AsyncSeriesHook = new AsyncSeriesHook();
}

/**
* @public
*/
export interface ITestActionProperties {}
export interface ITestActionProperties {
watchMode: boolean;
productionFlag: boolean;
}

/**
* @public
*/
export interface ITestActionContext extends IActionContext<TestHooks, ITestActionProperties> {}

export interface ITestActionOptions extends IBuildActionOptions {}
export interface ITestActionOptions extends IBuildActionOptions {
buildAction: BuildAction;
}

export class TestAction extends BuildAction {
public testActionHook: SyncHook<ITestActionContext> = new SyncHook<ITestActionContext>(['action']);
private _buildAction: BuildAction;

private _noBuildFlag: CommandLineFlagParameter;

public constructor(options: ITestActionOptions) {
super(options, {
actionName: 'test',
summary: 'Build the project and run tests.',
documentation: ''
});

this._buildAction = options.buildAction;
}

public onDefineParameters(): void {
super.onDefineParameters();

this._noBuildFlag = this.defineFlagParameter({
parameterLongName: '--no-build',
description: 'If provided, only run tests. Do not build first.'
});
}

protected async actionExecute(buildActionContext: IBuildActionContext): Promise<void> {
throw new Error('Not implemented yet...');
const testActionContext: ITestActionContext = {
hooks: new TestHooks(),
properties: {
watchMode: buildActionContext.properties.watchMode,
productionFlag: buildActionContext.properties.productionFlag
}
};
const shouldBuild: boolean = !this._noBuildFlag.value;

if (testActionContext.properties.watchMode) {
if (!shouldBuild) {
throw new Error(`${this._watchFlag.longName} is not compatible with ${this._noBuildFlag.longName}`);
} else if (buildActionContext.properties.noTest) {
iclanton marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(`${this._watchFlag.longName} is not compatible with ${this._noTestFlag.longName}`);
}
}

this.testActionHook.call(testActionContext);

if (testActionContext.hooks.overrideAction.isUsed()) {
await testActionContext.hooks.overrideAction.promise(buildActionContext.properties);
return;
}

await testActionContext.hooks.loadActionConfiguration.promise();
await testActionContext.hooks.afterLoadActionConfiguration.promise();

if (testActionContext.properties.watchMode) {
// In --watch mode, run all configuration upfront and then kick off all stages
// concurrently with the expectation that the their promises will never resolve
// and that they will handle watching filesystem changes

this._buildAction.actionHook.call(buildActionContext);
await testActionContext.hooks.configureTest.promise();

await Promise.all([
super.actionExecute(buildActionContext),
this._runStageWithLogging('Test', testActionContext)
]);
} else {
if (shouldBuild) {
// Run Build
this._buildAction.actionHook.call(buildActionContext);
await super.actionExecute(buildActionContext);
}

if (!buildActionContext.properties.noTest && !buildActionContext.properties.liteFlag) {
await testActionContext.hooks.configureTest.promise();
if (shouldBuild) {
await this._runStageWithLogging('Test', testActionContext);
} else {
await testActionContext.hooks.run.promise();
}
}
}
}
}
2 changes: 2 additions & 0 deletions apps/heft/src/pluginFramework/PluginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CleanPlugin } from '../plugins/CleanPlugin';
import { CopyStaticAssetsPlugin } from '../plugins/CopyStaticAssetsPlugin';
import { PackageJsonConfigurationPlugin } from '../plugins/PackageJsonConfigurationPlugin';
import { ApiExtractorPlugin } from '../plugins/ApiExtractorPlugin/ApiExtractorPlugin';
import { JestPlugin } from '../plugins/JestPlugin/JestPlugin';

export interface IPluginManagerOptions {
terminal: Terminal;
Expand Down Expand Up @@ -50,6 +51,7 @@ export class PluginManager {
this._applyPlugin(new CleanPlugin());
this._applyPlugin(new PackageJsonConfigurationPlugin());
this._applyPlugin(new ApiExtractorPlugin());
this._applyPlugin(new JestPlugin());
}

public initializePlugin(pluginSpecifier: string, options?: object): void {
Expand Down
112 changes: 112 additions & 0 deletions apps/heft/src/plugins/JestPlugin/HeftJestReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'path';
import { Terminal, Colors } from '@rushstack/node-core-library';
import {
Reporter,
Test,
TestResult,
AggregatedResult,
Context,
ReporterOnStartOptions,
Config
} from '@jest/reporters';
import { HeftConfiguration } from '../../configuration/HeftConfiguration';

export interface IHeftJestReporterOptions {
heftConfiguration: HeftConfiguration;
}

export default class HeftJestReporter implements Reporter {
private _terminal: Terminal;
private _buildFolder: string;

public constructor(jestConfig: Config.GlobalConfig, options: IHeftJestReporterOptions) {
this._terminal = options.heftConfiguration.terminal;
this._buildFolder = options.heftConfiguration.buildFolder;
}

public async onTestStart(test: Test): Promise<void> {
this._terminal.writeLine(
Colors.whiteBackground(Colors.black('START')),
` ${this._getTestPath(test.path)}`
);
}

public async onTestResult(
test: Test,
testResult: TestResult,
aggregatedResult: AggregatedResult
): Promise<void> {
const { numPassingTests, numFailingTests, failureMessage } = testResult;

if (numFailingTests > 0) {
this._terminal.write(Colors.redBackground(Colors.black('FAIL')));
} else {
this._terminal.write(Colors.greenBackground(Colors.black('PASS')));
}

const duration: string = test.duration ? `${test.duration / 1000}s` : '?';
this._terminal.writeLine(
` ${this._getTestPath(
test.path
)} (duration: ${duration}, ${numPassingTests} passed, ${numFailingTests} failed)`
);

if (failureMessage) {
this._terminal.writeErrorLine(failureMessage);
}

if (testResult.snapshot.updated) {
this._terminal.writeErrorLine(
`Updated ${this._formatWithPlural(testResult.snapshot.updated, 'snapshot', 'snapshots')}`
);
}

if (testResult.snapshot.added) {
this._terminal.writeErrorLine(
`Added ${this._formatWithPlural(testResult.snapshot.added, 'snapshot', 'snapshots')}`
);
}
}

public async onRunStart(
{ numTotalTestSuites }: AggregatedResult,
options: ReporterOnStartOptions
): Promise<void> {
// Jest prints some text that changes the console's color without a newline, so we reset the console's color here
// and print a newline.
this._terminal.writeLine('\u001b[0m');
this._terminal.writeLine(
`Run start. ${this._formatWithPlural(numTotalTestSuites, 'test suite', 'test suites')}`
);
}

public async onRunComplete(contexts: Set<Context>, results: AggregatedResult): Promise<void> {
const { numPassedTests, numFailedTests, numTotalTests } = results;

this._terminal.writeLine();
this._terminal.writeLine('Tests finished:');

const successesText: string = ` Successes: ${numPassedTests}`;
this._terminal.writeLine(numPassedTests > 0 ? Colors.green(successesText) : successesText);

const failText: string = ` Failures: ${numFailedTests}`;
this._terminal.writeLine(numFailedTests > 0 ? Colors.red(failText) : failText);

this._terminal.writeLine(` Total: ${numTotalTests}`);
}

public getLastError(): void {
// This reporter doesn't have any errors to throw
}

private _getTestPath(fullTestPath: string): string {
return path.relative(this._buildFolder, fullTestPath);
}

private _formatWithPlural(num: number, singular: string, plural: string): string {
return `${num} ${num === 1 ? singular : plural}`;
}
}
33 changes: 33 additions & 0 deletions apps/heft/src/plugins/JestPlugin/HeftJestSrcTransform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'path';
import { Path, FileSystem } from '@rushstack/node-core-library';
import { InitialOptionsWithRootDir } from '@jest/types/build/Config';

/**
* This Jest transformer maps TS files under a 'src' folder to their compiled equivalent under 'lib'
*/
export function process(src: string, filename: string, jestOptions: InitialOptionsWithRootDir): string {
const srcFolder: string = path.join(jestOptions.rootDir, 'src');
if (Path.isUnder(filename, srcFolder)) {
const fileBasename: string = path.basename(filename, path.extname(filename));
const srcRelativeFolderPath: string = path.dirname(path.relative(srcFolder, filename));
const libFilename: string = path.join(
jestOptions.rootDir,
'lib',
srcRelativeFolderPath,
`${fileBasename}.js`
);

try {
return FileSystem.readFile(libFilename);
} catch (error) {
if (!FileSystem.isNotExistError(error)) {
throw error;
}
}
}

return src;
}
Loading