feat(nx-plugin): add --includeHasher option to generate hasher boilerplate (#9891)

Co-authored-by: James Henry <james@henry.sc>
This commit is contained in:
Craigory Coppola 2022-04-27 12:44:48 -04:00 committed by GitHub
parent 984a3abca9
commit 99fcb7873e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 254 additions and 26 deletions

View File

@ -70,6 +70,7 @@ It only uses language primitives and immutable objects
- [ExecutorContext](../../nx-devkit/index#executorcontext)
- [ExecutorsJson](../../nx-devkit/index#executorsjson)
- [GeneratorsJson](../../nx-devkit/index#generatorsjson)
- [HasherContext](../../nx-devkit/index#hashercontext)
- [ImplicitJsonSubsetDependency](../../nx-devkit/index#implicitjsonsubsetdependency)
- [MigrationsJson](../../nx-devkit/index#migrationsjson)
- [NxAffectedConfig](../../nx-devkit/index#nxaffectedconfig)
@ -104,6 +105,7 @@ It only uses language primitives and immutable objects
### Workspace Type aliases
- [CustomHasher](../../nx-devkit/index#customhasher)
- [Executor](../../nx-devkit/index#executor)
- [Generator](../../nx-devkit/index#generator)
- [GeneratorCallback](../../nx-devkit/index#generatorcallback)
@ -374,6 +376,12 @@ A plugin for Nx
---
### HasherContext
**HasherContext**: `Object`
---
### ImplicitJsonSubsetDependency
**ImplicitJsonSubsetDependency**<`T`\>: `Object`
@ -512,6 +520,27 @@ A plugin for Nx
## Workspace Type aliases
### CustomHasher
Ƭ **CustomHasher**: (`task`: [`Task`](../../nx-devkit/index#task), `context`: [`HasherContext`](../../nx-devkit/index#hashercontext)) => `Promise`<[`Hash`](../../nx-devkit/index#hash)\>
#### Type declaration
▸ (`task`, `context`): `Promise`<[`Hash`](../../nx-devkit/index#hash)\>
##### Parameters
| Name | Type |
| :-------- | :----------------------------------------------------- |
| `task` | [`Task`](../../nx-devkit/index#task) |
| `context` | [`HasherContext`](../../nx-devkit/index#hashercontext) |
##### Returns
`Promise`<[`Hash`](../../nx-devkit/index#hash)\>
---
### Executor
Ƭ **Executor**<`T`\>: (`options`: `T`, `context`: [`ExecutorContext`](../../nx-devkit/index#executorcontext)) => `Promise`<`Object`\> \| `AsyncIterableIterator`<`Object`\>

View File

@ -290,6 +290,11 @@
"enum": ["jest", "none"],
"description": "Test runner to use for unit tests.",
"default": "jest"
},
"includeHasher": {
"type": "boolean",
"default": false,
"description": "Should the boilerplate for a custom hasher be generated?"
}
},
"required": ["project", "name"],

View File

@ -1281,7 +1281,7 @@
"file": "shared/running-custom-commands"
},
{
"name": "Creating Custom Builders",
"name": "Creating Custom Executors",
"id": "creating-custom-builders",
"file": "shared/tools-workspace-builders"
}

View File

@ -213,3 +213,57 @@ export default async function multipleExecutor(
For other ideas on how to create your own executors, you can always check out Nx's own open-source executors as well!
(For example, our [cypress executor](https://github.com/nrwl/nx/blob/master/packages/cypress/src/executors/cypress/cypress.impl.ts))
## Using Custom Hashers
For most executors, the default hashing in Nx makes sense. The output of the executor is dependent on the files in the project that it is being run for, or that project's dependencies, and nothing else. Changing a miscellaneous file at the workspace root will not affect that executor, and changing _*any*_ file inside of the project may affect the executor. When dealing with targets which only depend on a small subset of the files in a project, or may depend on arbitrary data that is not stored within the project, the default hasher may not make sense anymore. In these cases, the target will either experience more frequent cache misses than necessary or not be able to be cached.
Executors can provide a custom hasher that Nx uses when determining if a target run should be a cache hit, or if it must be run. When generating an executor for a plugin, you can use `nx g @nrwl/nx-plugin:executor my-executor --project my-plugin --includeHasher` to automatically add a custom hasher.
If you want to add a custom hasher manually, create a new file beside your executor's implementation. We will use `hasher.ts` as an example here. You'll also need to update `executors.json`, so that it resembles something like this:
```json
{
"executors": {
"echo": {
"implementation": "./src/executors/my-executor/executor",
"hasher": "./src/executors/my-executor/hasher",
"schema": "./src/executors/my-executor/schema.json"
}
}
}
```
This would allow you to write a custom function in `hasher.ts`, which Nx would use to calculate the target's hash. As an example, consider the below hasher which mimics the behavior of Nx's default hashing algorithm.
```typescript
import { CustomHasher, Task, HasherContext } from '@nrwl/devkit';
export const mimicNxHasher: CustomHasher = async (
task: Task,
context: HasherContext
) => {
return context.hasher.hashTaskWithDepsAndContext(task);
};
export default mimicNxHasher;
```
The hash function can do anything it wants, but it is important to remember that the hasher replaces the hashing done normally by Nx. If you change the hasher, Nx may return cache hits when you do not anticipate it. Imagine the below custom hasher:
```typescript
import { CustomHasher, Task, HasherContext } from '@nrwl/devkit';
export const badHasher: CustomHasher = async (
task: Task,
context: HasherContext
) => {
return {
value: 'my-static-hash',
};
};
export default badHasher;
```
This hasher would never return a different hash, so every run of a task that consumes the executor would be a cache hit. It is important that anything that would change the result of your executor's implementation is accounted for in the hasher.

View File

@ -147,7 +147,9 @@ describe('Nx Plugin', () => {
const executor = uniq('executor');
runCLI(`generate @nrwl/nx-plugin:plugin ${plugin} --linter=eslint`);
runCLI(`generate @nrwl/nx-plugin:executor ${executor} --project=${plugin}`);
runCLI(
`generate @nrwl/nx-plugin:executor ${executor} --project=${plugin} --includeHasher`
);
const lintResults = runCLI(`lint ${plugin}`);
expect(lintResults).toContain('All files pass linting.');
@ -160,16 +162,19 @@ describe('Nx Plugin', () => {
`libs/${plugin}/src/executors/${executor}/schema.d.ts`,
`libs/${plugin}/src/executors/${executor}/schema.json`,
`libs/${plugin}/src/executors/${executor}/executor.ts`,
`libs/${plugin}/src/executors/${executor}/hasher.ts`,
`libs/${plugin}/src/executors/${executor}/executor.spec.ts`,
`dist/libs/${plugin}/src/executors/${executor}/schema.d.ts`,
`dist/libs/${plugin}/src/executors/${executor}/schema.json`,
`dist/libs/${plugin}/src/executors/${executor}/executor.js`
`dist/libs/${plugin}/src/executors/${executor}/executor.js`,
`dist/libs/${plugin}/src/executors/${executor}/hasher.js`
);
const executorsJson = readJson(`libs/${plugin}/executors.json`);
expect(executorsJson).toMatchObject({
executors: expect.objectContaining({
[executor]: {
implementation: `./src/executors/${executor}/executor`,
hasher: `./src/executors/${executor}/hasher`,
schema: `./src/executors/${executor}/schema.json`,
description: `${executor} executor`,
},

View File

@ -38,6 +38,8 @@ export type {
GeneratorsJson,
ExecutorsJson,
MigrationsJson,
CustomHasher,
HasherContext,
} from 'nx/src/config/misc-interfaces';
/**

View File

@ -1,21 +1,8 @@
import {
NxJsonConfiguration,
ProjectGraph,
Task,
TaskGraph,
WorkspaceJsonConfiguration,
Hasher,
Hash,
} from '@nrwl/devkit';
import { Task, Hash, HasherContext } from '@nrwl/devkit';
export default async function run(
task: Task,
context: {
hasher: Hasher;
projectGraph: ProjectGraph;
taskGraph: TaskGraph;
workspaceConfig: WorkspaceJsonConfiguration & NxJsonConfiguration;
}
context: HasherContext
): Promise<Hash> {
const jestPluginConfig = context.workspaceConfig.pluginsConfig
? (context.workspaceConfig.pluginsConfig['@nrwl/jest'] as any)

View File

@ -21,6 +21,7 @@ describe('NxPlugin Executor Generator', () => {
project: projectName,
name: 'my-executor',
unitTestRunner: 'jest',
includeHasher: false,
});
expect(
@ -43,6 +44,7 @@ describe('NxPlugin Executor Generator', () => {
name: 'my-executor',
description: 'my-executor description',
unitTestRunner: 'jest',
includeHasher: false,
});
const executorJson = readJson(tree, 'libs/my-plugin/executors.json');
@ -63,6 +65,7 @@ describe('NxPlugin Executor Generator', () => {
project: projectName,
name: 'my-executor',
unitTestRunner: 'jest',
includeHasher: false,
});
const executorsJson = readJson(tree, 'libs/my-plugin/executors.json');
@ -78,6 +81,7 @@ describe('NxPlugin Executor Generator', () => {
name: 'my-executor',
description: 'my-executor custom description',
unitTestRunner: 'jest',
includeHasher: false,
});
const executorsJson = readJson(tree, 'libs/my-plugin/executors.json');
@ -95,6 +99,7 @@ describe('NxPlugin Executor Generator', () => {
name: 'my-executor',
description: 'my-executor description',
unitTestRunner: 'none',
includeHasher: true,
});
expect(
@ -102,7 +107,57 @@ describe('NxPlugin Executor Generator', () => {
'libs/my-plugin/src/executors/my-executor/executor.spec.ts'
)
).toBeFalsy();
expect(
tree.exists('libs/my-plugin/src/executors/my-executor/hasher.spec.ts')
).toBeFalsy();
});
});
});
describe('--includeHasher', () => {
it('should generate hasher files', async () => {
await executorGenerator(tree, {
project: projectName,
name: 'my-executor',
includeHasher: true,
unitTestRunner: 'jest',
});
expect(
tree.exists('libs/my-plugin/src/executors/my-executor/hasher.spec.ts')
).toBeTruthy();
expect(
tree
.read('libs/my-plugin/src/executors/my-executor/hasher.ts')
.toString()
).toMatchInlineSnapshot(`
"import { CustomHasher } from '@nrwl/devkit';
/**
* This is a boilerplate custom hasher that matches
* the default Nx hasher. If you need to extend the behavior,
* you can consume workspace details from the context.
*/
export const myExecutorHasher: CustomHasher = async (task, context) => {
return context.hasher.hashTaskWithDepsAndContext(task)
};
export default myExecutorHasher;
"
`);
});
it('should update executors.json', async () => {
await executorGenerator(tree, {
project: projectName,
name: 'my-executor',
includeHasher: true,
unitTestRunner: 'jest',
});
const executorsJson = readJson(tree, 'libs/my-plugin/executors.json');
expect(executorsJson.executors['my-executor'].hasher).toEqual(
'./src/executors/my-executor/hasher'
);
});
});
});

View File

@ -5,6 +5,7 @@ import {
generateFiles,
updateJson,
getWorkspaceLayout,
joinPathFragments,
} from '@nrwl/devkit';
import type { Tree } from '@nrwl/devkit';
import type { Schema } from './schema';
@ -13,6 +14,7 @@ import * as path from 'path';
interface NormalizedSchema extends Schema {
fileName: string;
className: string;
propertyName: string;
projectRoot: string;
projectSourceRoot: string;
npmScope: string;
@ -31,7 +33,7 @@ function addFiles(host: Tree, options: NormalizedSchema) {
if (options.unitTestRunner === 'none') {
host.delete(
path.join(
joinPathFragments(
options.projectSourceRoot,
'executors',
options.fileName,
@ -41,6 +43,29 @@ function addFiles(host: Tree, options: NormalizedSchema) {
}
}
function addHasherFiles(host: Tree, options: NormalizedSchema) {
generateFiles(
host,
path.join(__dirname, './files/hasher'),
`${options.projectSourceRoot}/executors`,
{
...options,
tmpl: '',
}
);
if (options.unitTestRunner === 'none') {
host.delete(
joinPathFragments(
options.projectSourceRoot,
'executors',
options.fileName,
'hasher.spec.ts'
)
);
}
}
function updateExecutorJson(host: Tree, options: NormalizedSchema) {
let executorPath: string;
if (host.exists(path.join(options.projectRoot, 'executors.json'))) {
@ -53,10 +78,15 @@ function updateExecutorJson(host: Tree, options: NormalizedSchema) {
let executors = json.executors ?? json.builders;
executors ||= {};
executors[options.name] = {
implementation: `./src/executors/${options.name}/executor`,
schema: `./src/executors/${options.name}/schema.json`,
implementation: `./src/executors/${options.fileName}/executor`,
schema: `./src/executors/${options.fileName}/schema.json`,
description: options.description,
};
if (options.includeHasher) {
executors[
options.name
].hasher = `./src/executors/${options.fileName}/hasher`;
}
json.executors = executors;
return json;
@ -65,7 +95,7 @@ function updateExecutorJson(host: Tree, options: NormalizedSchema) {
function normalizeOptions(host: Tree, options: Schema): NormalizedSchema {
const { npmScope } = getWorkspaceLayout(host);
const { fileName, className } = names(options.name);
const { fileName, className, propertyName } = names(options.name);
const { root: projectRoot, sourceRoot: projectSourceRoot } =
readProjectConfiguration(host, options.project);
@ -81,6 +111,7 @@ function normalizeOptions(host: Tree, options: Schema): NormalizedSchema {
...options,
fileName,
className,
propertyName,
description,
projectRoot,
projectSourceRoot,
@ -92,6 +123,9 @@ export async function executorGenerator(host: Tree, schema: Schema) {
const options = normalizeOptions(host, schema);
addFiles(host, options);
if (options.includeHasher) {
addHasherFiles(host, options);
}
updateExecutorJson(host, options);
}

View File

@ -0,0 +1,22 @@
import { Hasher, HasherContext } from '@nrwl/devkit';
import { <%=propertyName%>Hasher } from './hasher';
describe('<%=propertyName%>Hasher', () => {
it('should generate hash', async () => {
const mockHasher: Hasher = {
hashTaskWithDepsAndContext: jest.fn().mockReturnValue({value: 'hashed-task'})
} as unknown as Hasher
const hash = await <%=propertyName%>Hasher({
id: 'my-task-id',
target: {
project: 'proj',
target: 'target'
},
overrides: {}
}, {
hasher: mockHasher
} as unknown as HasherContext)
expect(hash).toEqual({value: 'hashed-task'})
})
})

View File

@ -0,0 +1,12 @@
import { CustomHasher } from '@nrwl/devkit';
/**
* This is a boilerplate custom hasher that matches
* the default Nx hasher. If you need to extend the behavior,
* you can consume workspace details from the context.
*/
export const <%=propertyName%>Hasher: CustomHasher = async (task, context) => {
return context.hasher.hashTaskWithDepsAndContext(task)
};
export default <%=propertyName%>Hasher;

View File

@ -3,4 +3,5 @@ export interface Schema {
name: string;
description?: string;
unitTestRunner: 'jest' | 'none';
includeHasher: boolean;
}

View File

@ -40,6 +40,11 @@
"enum": ["jest", "none"],
"description": "Test runner to use for unit tests.",
"default": "jest"
},
"includeHasher": {
"type": "boolean",
"default": false,
"description": "Should the boilerplate for a custom hasher be generated?"
}
},
"required": ["project", "name"],

View File

@ -84,6 +84,7 @@ async function addFiles(host: Tree, options: NormalizedSchema) {
project: options.name,
name: 'build',
unitTestRunner: options.unitTestRunner,
includeHasher: false,
});
}

View File

@ -1,9 +1,12 @@
import type { NxJsonConfiguration } from './nx-json';
import { TaskGraph } from './task-graph';
import { Hash, Hasher } from '../hasher/hasher';
import { ProjectGraph } from './project-graph';
import { Task, TaskGraph } from './task-graph';
import {
TargetConfiguration,
WorkspaceJsonConfiguration,
} from './workspace-json-project-json';
import type { NxJsonConfiguration } from './nx-json';
/**
* A callback function that is executed after changes are made to the file system
*/
@ -82,7 +85,7 @@ export interface ExecutorsJson {
export interface ExecutorConfig {
schema: any;
hasherFactory?: () => any;
hasherFactory?: () => CustomHasher;
implementationFactory: () => Executor;
batchImplementationFactory?: () => TaskGraphExecutor;
}
@ -100,6 +103,18 @@ export type Executor<T = any> = (
| Promise<{ success: boolean }>
| AsyncIterableIterator<{ success: boolean }>;
export interface HasherContext {
hasher: Hasher;
projectGraph: ProjectGraph<any>;
taskGraph: TaskGraph;
workspaceConfig: WorkspaceJsonConfiguration & NxJsonConfiguration;
}
export type CustomHasher = (
task: Task,
context: HasherContext
) => Promise<Hash>;
/**
* Implementation of a target of a project that handles multiple projects to be batched
*/

View File

@ -22,6 +22,7 @@ import {
Generator,
GeneratorsJson,
ExecutorsJson,
CustomHasher,
} from './misc-interfaces';
import { PackageJson } from '../utils/package-json';
@ -126,7 +127,7 @@ export class Workspaces {
: null;
const hasherFactory = executorConfig.hasher
? this.getImplementationFactory<Function>(
? this.getImplementationFactory<CustomHasher>(
executorConfig.hasher,
executorsDir
)