Compare commits

...

16 Commits

Author SHA1 Message Date
Max Kless c6bf6414ce feat(graph): write nx-console e2e tests for the new tooltip functionality 2023-08-09 16:45:58 +02:00
Max Kless 395c3562d0 cleanup(graph): remove unused var 2023-08-09 10:26:15 +02:00
Max Kless 5a2ea09d9b cleanup(graph): improve graph e2e test 2023-08-09 10:02:41 +02:00
Max Kless 9c5873c9fa fix(graph): adjust implementation to upstream changes 2023-08-09 09:50:19 +02:00
Max Kless 47d4eb8eb8 feat(graph): add e2e test to test expanded task inputs 2023-08-09 09:29:23 +02:00
Max Kless ad9344152f feat(graph): add task tooltip e2e test 2023-08-09 09:29:23 +02:00
Max Kless 491966b6e3 feat(graph): expand task inputs and display them in the UI 2023-08-09 09:29:20 +02:00
Jonathan Cammisuli cda123802f fix(core): update test to use ProjectGraphBuilder + createTaskGraph 2023-08-08 20:59:17 -04:00
Jonathan Cammisuli 9690aea0a8 fix(core): change the way we gather inputs for the graph view 2023-08-08 14:55:09 -04:00
FrozenPandaz 6a1164f7ae chore(core): update unit tests 2023-08-08 14:50:13 -04:00
Jonathan Cammisuli 0ce7acdf77 fix(core): change the way we gather inputs for the graph view 2023-08-08 14:16:59 -04:00
Jonathan Cammisuli 137ba06499 fix(core): revert changes to run-command 2023-08-08 11:27:07 -04:00
Jonathan Cammisuli 5df932b108 fix(core): review changes 2023-08-07 14:11:40 -04:00
Jonathan Cammisuli 62e75edd24 fix(core): update unit test 2023-08-07 14:11:40 -04:00
Jonathan Cammisuli 2145117b24 feat(core): add task inputs to task graph for graph view 2023-08-07 14:11:40 -04:00
Jonathan Cammisuli a42cabccf9 feat(core): gather task inputs 2023-08-07 14:11:39 -04:00
31 changed files with 17944 additions and 65 deletions
+113
View File
@@ -1,3 +1,4 @@
import { parseJson } from '@nx/devkit';
import {
checkFilesExist,
cleanupProject,
@@ -8,6 +9,8 @@ import {
uniq,
updateFile,
updateProjectConfig,
readFile,
updateJson,
} from '@nx/e2e/utils';
describe('Extra Nx Misc Tests', () => {
@@ -270,4 +273,114 @@ describe('Extra Nx Misc Tests', () => {
expect(output).not.toContain('Installed');
});
});
describe('task graph inputs', () => {
const readExpandedTaskInputResponse = (): Record<
string,
Record<string, string[]>
> =>
parseJson(
readFile('static/environment.js').match(
/window\.expandedTaskInputsResponse\s*=\s*(.*?);/
)[1]
);
const baseLib = 'lib-base-123';
beforeAll(() => {
runCLI(`generate @nx/js:lib ${baseLib}`);
});
it('should correctly expand default task inputs', () => {
runCLI('graph --file=graph.html');
expect(readExpandedTaskInputResponse()[`${baseLib}:build`])
.toMatchInlineSnapshot(`
{
"external": [
"external:@nx/js",
],
"general": [
"nx.json",
".gitignore",
"libs/lib-base-123/package.json",
"tsconfig.base.json",
],
"lib-base-123": [
"libs/lib-base-123/.eslintrc.json",
"libs/lib-base-123/README.md",
"libs/lib-base-123/jest.config.ts",
"libs/lib-base-123/package.json",
"libs/lib-base-123/project.json",
"libs/lib-base-123/src/index.ts",
"libs/lib-base-123/src/lib/lib-base-123.spec.ts",
"libs/lib-base-123/src/lib/lib-base-123.ts",
"libs/lib-base-123/tsconfig.json",
"libs/lib-base-123/tsconfig.lib.json",
"libs/lib-base-123/tsconfig.spec.json",
],
}
`);
});
it('should correctly expand dependent task inputs', () => {
const dependentLib = 'lib-dependent-123';
runCLI(`generate @nx/js:lib ${dependentLib}`);
updateProjectConfig(baseLib, (config) => {
config.targets['build'].inputs = ['default', '^default'];
config.implicitDependencies = [dependentLib];
return config;
});
updateJson('nx.json', (json) => {
json.namedInputs = {
...json.namedInputs,
default: ['{projectRoot}/**/*'],
};
return json;
});
runCLI('graph --file=graph.html');
expect(readExpandedTaskInputResponse()[`${baseLib}:build`])
.toMatchInlineSnapshot(`
{
"external": [
"external:@nx/js",
],
"general": [
"nx.json",
".gitignore",
"libs/lib-base-123/package.json",
"tsconfig.base.json",
],
"lib-base-123": [
"libs/lib-base-123/.eslintrc.json",
"libs/lib-base-123/README.md",
"libs/lib-base-123/jest.config.ts",
"libs/lib-base-123/package.json",
"libs/lib-base-123/project.json",
"libs/lib-base-123/src/index.ts",
"libs/lib-base-123/src/lib/lib-base-123.spec.ts",
"libs/lib-base-123/src/lib/lib-base-123.ts",
"libs/lib-base-123/tsconfig.json",
"libs/lib-base-123/tsconfig.lib.json",
"libs/lib-base-123/tsconfig.spec.json",
],
"lib-dependent-123": [
"libs/lib-dependent-123/.eslintrc.json",
"libs/lib-dependent-123/README.md",
"libs/lib-dependent-123/jest.config.ts",
"libs/lib-dependent-123/package.json",
"libs/lib-dependent-123/project.json",
"libs/lib-dependent-123/src/index.ts",
"libs/lib-dependent-123/src/lib/lib-dependent-123.spec.ts",
"libs/lib-dependent-123/src/lib/lib-dependent-123.ts",
"libs/lib-dependent-123/tsconfig.json",
"libs/lib-dependent-123/tsconfig.lib.json",
"libs/lib-dependent-123/tsconfig.spec.json",
],
}
`);
});
});
});
@@ -0,0 +1,28 @@
import { defineConfig } from 'cypress';
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import setupNodeEvents from './src/plugins/index';
const cypressJsonConfig = {
fileServerFolder: '.',
fixturesFolder: './src/fixtures',
video: true,
videosFolder: '../../dist/cypress/graph/client-e2e/videos',
screenshotsFolder: '../../dist/cypress/graph/client-e2e/screenshots',
chromeWebSecurity: false,
specPattern: './src/e2e/**/nx-console*.cy.{js,jsx,ts,tsx}',
supportFile: 'src/support/e2e.ts',
};
export default defineConfig({
e2e: {
...nxE2EPreset(__dirname),
...cypressJsonConfig,
setupNodeEvents,
/**
* TODO(@nx/cypress): In Cypress v12,the testIsolation option is turned on by default.
* This can cause tests to start breaking where not indended.
* You should consider enabling this once you verify tests do not depend on each other
* More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation
**/
testIsolation: false,
},
});
@@ -0,0 +1 @@
Cr24
+9 -2
View File
@@ -30,6 +30,11 @@
"cypressConfig": "graph/client-e2e/cypress-release-static.config.ts",
"devServerTarget": "graph-client:serve-base:release-static",
"baseUrl": "http://localhost:4205"
},
"nx-console": {
"cypressConfig": "graph/client-e2e/cypress-nx-console.config.ts",
"devServerTarget": "graph-client:serve-base:nx-console",
"baseUrl": "http://localhost:4202"
}
},
"defaultConfiguration": "dev"
@@ -42,7 +47,8 @@
"npx nx e2e-base e2e-graph-client --configuration dev",
"npx nx e2e-base e2e-graph-client --configuration watch",
"npx nx e2e-base e2e-graph-client --configuration release",
"npx nx e2e-base e2e-graph-client --configuration release-static"
"npx nx e2e-base e2e-graph-client --configuration release-static",
"npx nx e2e-base e2e-graph-client --configuration nx-console"
],
"parallel": false
}
@@ -54,7 +60,8 @@
"commands": [
"npx nx e2e-base e2e-graph-client --configuration dev",
"npx nx e2e-base e2e-graph-client --configuration release",
"npx nx e2e-base e2e-graph-client --configuration release-static"
"npx nx e2e-base e2e-graph-client --configuration release-static",
"npx nx e2e-base e2e-graph-client --configuration nx-console"
],
"parallel": false
}
+63 -1
View File
@@ -18,11 +18,12 @@ import {
getToggleAllButtonForFolder,
getUncheckedProjectItems,
getUnfocusProjectButton,
openTooltipForNode,
} from '../support/app.po';
import * as affectedJson from '../fixtures/affected.json';
import { testProjectsRoutes, testTaskRoutes } from '../support/routing-tests';
import * as nxExamplesJson from '../fixtures/nx-examples-project-graph.json';
import * as nxExamplesTaskInputs from '../fixtures/nx-examples-task-inputs.json';
describe('dev mode - task graph', () => {
before(() => {
@@ -181,4 +182,65 @@ describe('dev mode - task graph', () => {
// and also new /projects route
testTaskRoutes('browser', ['/e2e/tasks']);
});
describe('file inputs', () => {
beforeEach(() => {
cy.intercept(
{
method: 'GET',
url: '/task-inputs.json*',
},
async (req) => {
// Extract the desired query parameter
const taskId = req.url.split('taskId=')[1];
// Load the fixture data and find the property based on the query parameter
const expandedInputs = nxExamplesTaskInputs[taskId];
// Reply with the selected property
req.reply({
body: expandedInputs,
});
}
).as('getTaskInputs');
});
it('should display input files', () => {
getSelectTargetDropdown().select('build', { force: true });
cy.get('[data-project="cart"]').click({
force: true,
});
openTooltipForNode('cart:build');
cy.get('[data-cy="inputs-accordion"]').click();
cy.get('[data-cy="input-list-entry"]').should('have.length', 20);
const expectedSections = [
'cart-cart-page',
'cart-e2e',
'products',
'products-e2e',
'products-home-page',
'products-product-detail-page',
'shared-assets',
'shared-cart-state',
'shared-header',
'shared-jsxify',
'shared-product-data',
'shared-product-state',
'shared-product-types',
'shared-product-ui',
'shared-styles',
'External Inputs',
];
cy.get('[data-cy="input-section-entry"]').each((el, idx) => {
expect(el.text()).to.equal(expectedSections[idx]);
});
const sharedHeaderSelector =
'[data-cy="input-section-entry"]:contains(shared-header)';
cy.get(sharedHeaderSelector).click();
cy.get(sharedHeaderSelector)
.nextAll('[data-cy="input-list-entry"]')
.should('have.length', 9);
});
});
});
@@ -0,0 +1,80 @@
import {
getCheckedProjectItems,
getFocusButtonForProject,
openTooltipForNode,
} from '../support/app.po';
describe('nx-console environment', () => {
let fileClickEvents = [];
let openProjectEvents = [];
let runTaskEvents = [];
beforeEach(() => {
fileClickEvents = [];
openProjectEvents = [];
runTaskEvents = [];
cy.visit('/');
cy.window().then((win) => {
win.externalApi.registerFileClickCallback((url) =>
fileClickEvents.push(url)
);
win.externalApi.registerOpenProjectConfigCallback((projectName) =>
openProjectEvents.push(projectName)
);
win.externalApi.registerRunTaskCallback((taskId) =>
runTaskEvents.push(taskId)
);
});
});
describe('tooltips', () => {
it('should show open project button and send correct event', () => {
cy.window().then((win) => win.externalApi.focusProject('cart'));
cy.get('#focused-project-name').should('contain.text', 'cart');
openTooltipForNode('#cart');
cy.get('[data-cy="project-open-config-button"]').should('be.visible');
cy.get('[data-cy="project-open-config-button"]')
.click()
.then(() => {
expect(openProjectEvents).to.have.length(1);
});
});
it('should show clickable edge file links and send correct event', () => {
cy.window().then((win) => win.externalApi.focusProject('cart'));
cy.get('#focused-project-name').should('contain.text', 'cart');
openTooltipForNode('edge[source = "cart"][target = "cart-cart-page"]');
cy.get('[data-cy="project-edge-file-entry"]').should(
'have.length.above',
0
);
cy.get('[data-cy="project-edge-file-entry"]')
.first()
.click()
.then(() => {
expect(fileClickEvents).to.have.length(1);
});
});
it('should show run task button and send correct event', () => {
cy.window().then((win) => win.externalApi.focusProject('cart'));
cy.window().then((win) =>
win.externalApi.router.navigate('/tasks/build')
);
cy.get('[data-project="cart"]').click({
force: true,
});
openTooltipForNode('[id = "cart:build:production"]');
cy.get('[data-cy="task-run-button"]').should('be.visible');
cy.get('[data-cy="task-run-button"]')
.click()
.then(() => {
expect(runTaskEvents).to.have.length(1);
});
});
});
});
File diff suppressed because it is too large Load Diff
+15
View File
@@ -45,3 +45,18 @@ export const getToggleAllButtonForFolder = (folderName: string) =>
export const getSelectTargetDropdown = () =>
cy.get('[data-cy=selected-target-dropdown]');
export const openTooltipForNode = (elementSelector: string) => {
cy.window().then((win) => {
const element =
// @ts-ignore - we will access private methods only in this e2e test
win.externalApi.graphService.renderGraph.cy.$(elementSelector);
if (element.group() === 'nodes') {
const pos = element.renderedPosition();
cy.get('#cytoscape-graph').click(pos.x, pos.y);
} else {
element.trigger('click');
}
});
};
@@ -250,7 +250,6 @@ export const projectGraphMachine = createMachine<
setGraph: assign((ctx, event) => {
if (event.type !== 'setProjects' && event.type !== 'updateGraph')
return;
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.fileMap = event.fileMap;
@@ -305,7 +305,7 @@ export function ProjectsSidebar(): JSX.Element {
await projectGraphDataService.getProjectGraph(
projectInfo.projectGraphUrl
);
console.log(response);
projectGraphService.send({
type: 'updateGraph',
projects: response.projects,
@@ -1,10 +1,10 @@
import { TaskList } from './task-list';
import {
useNavigate,
useParams,
useRouteLoaderData,
useSearchParams,
} from 'react-router-dom';
import { TaskList } from './task-list';
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type {
@@ -12,14 +12,16 @@ import type {
TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */
import { getGraphService } from '../machines/graph.service';
import { useEffect, useMemo } from 'react';
import { getGraphService } from '../machines/graph.service';
import { CheckboxPanel } from '../ui-components/checkbox-panel';
import { Dropdown } from '@nx/graph/ui-components';
import { ShowHideAll } from '../ui-components/show-hide-all';
import { useCurrentPath } from '../hooks/use-current-path';
import { ShowHideAll } from '../ui-components/show-hide-all';
import { createTaskName, useRouteConstructor } from '../util';
import { GraphInteractionEvents } from '@nx/graph/ui-graph';
import { getProjectGraphDataService } from '../hooks/get-project-graph-data-service';
export function TasksSidebar() {
const graphService = getGraphService();
@@ -31,4 +31,15 @@ export class FetchProjectGraphService implements ProjectGraphService {
return response.json();
}
async getExpandedTaskInputs(
taskId: string
): Promise<Record<string, string[]>> {
const request = new Request(`task-inputs.json?taskId=${taskId}`, {
mode: 'no-cors',
});
const response = await fetch(request);
return await response.json();
}
}
+1
View File
@@ -22,6 +22,7 @@ export interface ProjectGraphService {
getHash: () => Promise<string>;
getProjectGraph: (url: string) => Promise<ProjectGraphClientResponse>;
getTaskGraph: (url: string) => Promise<TaskGraphClientResponse>;
getExpandedTaskInputs?: (taskId: string) => Promise<Record<string, string[]>>;
}
export interface Environment {
@@ -19,4 +19,12 @@ export class LocalProjectGraphService implements ProjectGraphService {
async getTaskGraph(url: string): Promise<TaskGraphClientResponse> {
return new Promise((resolve) => resolve(window.taskGraphResponse));
}
async getExpandedTaskInputs(
taskId: string
): Promise<Record<string, string[]>> {
return new Promise((resolve) =>
resolve(window.expandedTaskInputsResponse[taskId])
);
}
}
@@ -1,16 +1,20 @@
import { GraphService } from '@nx/graph/ui-graph';
import { selectValueByThemeStatic } from '../theme-resolver';
import { getEnvironmentConfig } from '../hooks/use-environment-config';
import { getProjectGraphDataService } from '../hooks/get-project-graph-data-service';
let graphService: GraphService;
export function getGraphService(): GraphService {
const environment = getEnvironmentConfig();
if (!graphService) {
const projectDataService = getProjectGraphDataService();
graphService = new GraphService(
'cytoscape-graph',
selectValueByThemeStatic('dark', 'light'),
environment.environment === 'nx-console' ? 'nx-console' : undefined
environment.environment === 'nx-console' ? 'nx-console' : undefined,
'TB',
projectDataService.getExpandedTaskInputs
);
}
@@ -7,6 +7,7 @@ import {
Tooltip,
} from '@nx/graph/ui-tooltips';
import { ProjectNodeActions } from './project-node-actions';
import { TaskNodeActions } from './task-node-actions';
const tooltipService = getTooltipService();
@@ -29,7 +30,11 @@ export function TooltipDisplay() {
tooltipToRender = <ProjectEdgeNodeTooltip {...currentTooltip.props} />;
break;
case 'taskNode':
tooltipToRender = <TaskNodeTooltip {...currentTooltip.props} />;
tooltipToRender = (
<TaskNodeTooltip {...currentTooltip.props}>
<TaskNodeActions {...currentTooltip.props} />
</TaskNodeTooltip>
);
break;
}
}
@@ -0,0 +1,122 @@
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import { TaskNodeTooltipProps } from '@nx/graph/ui-tooltips';
import { useState } from 'react';
export function TaskNodeActions(props: TaskNodeTooltipProps) {
const [isOpen, setIsOpen] = useState(false);
const project = props.id.split(':')[0];
return (
<div className="overflow-auto w-full min-w-[350px] max-w-full rounded-md border border-slate-200 dark:border-slate-800 w-full">
<div
className="flex justify-between items-center w-full bg-slate-50 px-4 py-2 text-xs font-medium uppercase text-slate-500 dark:bg-slate-800 dark:text-slate-400"
onClick={() => setIsOpen(!isOpen)}
data-cy="inputs-accordion"
>
<span>Inputs</span>
<span>
{isOpen ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</div>
<ul
className={`max-h-[300px] divide-y divide-slate-200 overflow-auto dark:divide-slate-800 ${
!isOpen && 'hidden'
}`}
>
{Object.entries(props.inputs ?? {})
.sort(compareInputSectionKeys(project))
.map(([key, inputs]) => {
if (key === 'general' || key === project) {
return renderInputs(inputs);
}
if (key === 'external') {
return InputAccordion({ section: 'External Inputs', inputs });
}
return InputAccordion({ section: key, inputs });
})}
</ul>
</div>
);
}
function InputAccordion({ section, inputs }) {
const [isOpen, setIsOpen] = useState(false);
return [
<li
key={section}
className="flex justify-between items-center whitespace-nowrap px-4 py-2 text-sm font-medium text-slate-800 dark:text-slate-300"
onClick={() => setIsOpen(!isOpen)}
data-cy="input-section-entry"
>
<span className="block truncate font-normal font-bold">{section}</span>
<span>
{isOpen ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</li>,
isOpen ? renderInputs(inputs) : undefined,
];
}
function renderInputs(inputs: string[]) {
return inputs.map((input) => (
<li
key={input}
className="whitespace-nowrap px-4 py-2 text-sm font-medium text-slate-800 dark:text-slate-300"
title={input}
data-cy="input-list-entry"
>
<span className="block truncate font-normal">{input}</span>
</li>
));
}
function compareInputSectionKeys(project: string) {
return ([keya]: [string, string[]], [keyb]: [string, string[]]) => {
const first = 'general';
const second = project;
const last = 'external';
// Check if 'keya' and/or 'keyb' are one of the special strings
if (
keya === first ||
keya === second ||
keya === last ||
keyb === first ||
keyb === second ||
keyb === last
) {
// If 'keya' is 'general', 'keya' should always be first
if (keya === first) return -1;
// If 'keyb' is 'general', 'keyb' should always be first
if (keyb === first) return 1;
// At this point, we know neither 'keya' nor 'keyb' are 'general'
// If 'keya' is project, 'keya' should be second (i.e., before 'keyb' unless 'keyb' is 'general')
if (keya === second) return -1;
// If 'keyb' is project, 'keyb' should be second (i.e., before 'keya')
if (keyb === second) return 1;
// At this point, we know neither 'keya' nor 'keyb' are 'general' or project
// If 'keya' is 'external', 'keya' should be last (i.e., after 'keyb')
if (keya === last) return 1;
// If 'keyb' is 'external', 'keyb' should be last (i.e., after 'keya')
if (keyb === last) return -1;
}
// If neither 'keya' nor 'b' are one of the special strings, sort alphabetically
if (keya < keyb) {
return -1;
}
if (keya > keyb) {
return 1;
}
return 0;
};
}
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,7 @@
"data": {
"tags": [],
"root": "libs/project-a",
"files": [],
"targets": {}
"files": []
}
},
{
@@ -17,8 +16,7 @@
"data": {
"tags": [],
"root": "libs/project-a",
"files": [],
"targets": {}
"files": []
}
},
{
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,7 @@
/* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line
import type {
ExpandedTaskInputsReponse,
ProjectGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
@@ -15,6 +16,7 @@ export declare global {
localMode: 'serve' | 'build';
projectGraphResponse?: ProjectGraphClientResponse;
taskGraphResponse?: TaskGraphClientResponse;
expandedTaskInputsResponse?: ExpandedTaskInputsReponse;
environment: 'dev' | 'watch' | 'release' | 'nx-console';
appConfig: AppConfig;
useXstateInspect: boolean;
+4 -1
View File
@@ -32,7 +32,10 @@ export class GraphService {
container: string | HTMLElement,
theme: 'light' | 'dark',
public renderMode?: 'nx-console' | 'nx-docs',
rankDir: 'TB' | 'LR' = 'TB'
rankDir: 'TB' | 'LR' = 'TB',
public getTaskInputs: (
taskId: string
) => Promise<Record<string, string[]>> = undefined
) {
use(cytoscapeDagre);
use(popper);
+16 -1
View File
@@ -6,12 +6,13 @@ import {
ProjectEdgeNodeTooltipProps,
} from '@nx/graph/ui-tooltips';
import { TooltipEvent } from './interfaces';
import { GraphInteractionEvents } from './graph-interaction-events';
export class GraphTooltipService {
private subscribers: Set<Function> = new Set();
constructor(graph: GraphService) {
graph.listen((event) => {
graph.listen((event: GraphInteractionEvents) => {
switch (event.type) {
case 'GraphRegenerated':
this.hideAll();
@@ -49,6 +50,20 @@ export class GraphTooltipService {
...event.data,
runTaskCallback,
});
if (graph.getTaskInputs) {
graph.getTaskInputs(event.data.id).then((inputs) => {
if (
this.currentTooltip.type === 'taskNode' &&
this.currentTooltip.props.id === event.data.id
) {
this.openTaskNodeTooltip(event.ref, {
...event.data,
runTaskCallback,
inputs,
});
}
});
}
break;
case 'EdgeClick':
const callback =
@@ -45,6 +45,7 @@ export function ProjectEdgeNodeTooltip({
? () => fileClickCallback(fileDep.fileName)
: () => {}
}
data-cy="project-edge-file-entry"
>
<span className="block truncate font-normal">
{fileDep.fileName}
@@ -33,7 +33,10 @@ export function ProjectNodeToolTip({
title="Edit project.json in editor"
onClick={openConfigCallback}
>
<PencilSquareIcon className="h-5 w-5" />
<PencilSquareIcon
className="h-5 w-5"
data-cy="project-open-config-button"
/>
</button>
) : undefined}
</h4>
@@ -1,11 +1,15 @@
import { PlayIcon } from '@heroicons/react/24/outline';
import { Tag } from '@nx/graph/ui-components';
import { ReactNode } from 'react';
export interface TaskNodeTooltipProps {
id: string;
executor: string;
runTaskCallback?: () => void;
description?: string;
inputs?: Record<string, string[]>;
children?: ReactNode | ReactNode[];
}
export function TaskNodeTooltip({
@@ -13,10 +17,11 @@ export function TaskNodeTooltip({
executor,
description,
runTaskCallback: runTargetCallback,
children,
}: TaskNodeTooltipProps) {
return (
<div className="text-sm text-slate-700 dark:text-slate-400">
<h4 className="flex justify-between items-center gap-4">
<h4 className="flex justify-between items-center gap-4 mb-3">
<div className="flex items-center">
<Tag className="mr-3">{executor}</Tag>
<span className="font-mono">{id}</span>
@@ -27,12 +32,12 @@ export function TaskNodeTooltip({
title="Run Task"
onClick={runTargetCallback}
>
<PlayIcon className="h-5 w-5" />
<PlayIcon className="h-5 w-5" data-cy="task-run-button" />
</button>
) : undefined}
</h4>
<h4></h4>
{description ? <p className="mt-4">{description}</p> : null}
{children}
</div>
);
}
+262 -28
View File
@@ -1,35 +1,54 @@
import { workspaceRoot } from '../../utils/workspace-root';
import { createHash } from 'crypto';
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
import { copySync, ensureDirSync } from 'fs-extra';
import * as http from 'http';
import * as open from 'open';
import { basename, dirname, extname, isAbsolute, join, parse } from 'path';
import { performance } from 'perf_hooks';
import * as minimatch from 'minimatch';
import { URL } from 'node:url';
import * as open from 'open';
import {
basename,
dirname,
extname,
isAbsolute,
join,
parse,
relative,
} from 'path';
import { performance } from 'perf_hooks';
import { readNxJson, workspaceLayout } from '../../config/configuration';
import { output } from '../../utils/output';
import { writeJsonFile } from '../../utils/fileutils';
import {
ProjectFileMap,
ProjectGraph,
ProjectGraphDependency,
ProjectGraphProjectNode,
} from '../../config/project-graph';
import { writeJsonFile } from '../../utils/fileutils';
import { output } from '../../utils/output';
import { workspaceRoot } from '../../utils/workspace-root';
import { Server } from 'net';
import { NxJsonConfiguration } from '../../config/nx-json';
import { FileData } from '../../config/project-graph';
import { TaskGraph } from '../../config/task-graph';
import { daemonClient } from '../../daemon/client/client';
import { fileHasher } from '../../hasher/file-hasher';
import {
expandNamedInput,
filterUsingGlobPatterns,
getInputs,
} from '../../hasher/task-hasher';
import { readProjectFileMapCache } from '../../project-graph/nx-deps-cache';
import { pruneExternalNodes } from '../../project-graph/operators';
import { createProjectGraphAsync } from '../../project-graph/project-graph';
import {
createTaskGraph,
mapTargetDefaultsToDependencies,
} from '../../tasks-runner/create-task-graph';
import { TargetDefaults, TargetDependencies } from '../../config/nx-json';
import { TaskGraph } from '../../config/task-graph';
import { daemonClient } from '../../daemon/client/client';
import { Server } from 'net';
import { readProjectFileMapCache } from '../../project-graph/nx-deps-cache';
import { fileHasher } from '../../hasher/file-hasher';
import { getAffectedGraphNodes } from '../affected/affected';
import { allFileData } from '../../utils/all-file-data';
import { splitArgsIntoNxArgsAndOverrides } from '../../utils/command-line-utils';
import { HashPlanner } from '../../hasher/hash-planner';
import { getAffectedGraphNodes } from '../affected/affected';
import { getRootTsConfigPath } from '../../plugins/js/utils/typescript';
export interface ProjectGraphClientResponse {
hash: string;
@@ -45,9 +64,14 @@ export interface ProjectGraphClientResponse {
export interface TaskGraphClientResponse {
taskGraphs: Record<string, TaskGraph>;
plans?: Record<string, string[]>;
errors: Record<string, string>;
}
export interface ExpandedTaskInputsReponse {
[taskId: string]: Record<string, string[]>;
}
// maps file extention to MIME types
const mimeType = {
'.ico': 'image/x-icon',
@@ -71,7 +95,8 @@ function buildEnvironmentJs(
watchMode: boolean,
localMode: 'build' | 'serve',
depGraphClientResponse?: ProjectGraphClientResponse,
taskGraphClientResponse?: TaskGraphClientResponse
taskGraphClientResponse?: TaskGraphClientResponse,
expandedTaskInputsReponse?: ExpandedTaskInputsReponse
) {
let environmentJs = `window.exclude = ${JSON.stringify(exclude)};
window.watch = ${!!watchMode};
@@ -103,6 +128,9 @@ function buildEnvironmentJs(
taskGraphClientResponse
)};
`;
environmentJs += `window.expandedTaskInputsResponse = ${JSON.stringify(
expandedTaskInputsReponse
)};`;
} else {
environmentJs += `window.projectGraphResponse = null;`;
environmentJs += `window.taskGraphResponse = null;`;
@@ -316,13 +344,18 @@ export async function generateGraph(
);
const taskGraphClientResponse = await createTaskGraphClientResponse();
const taskInputsReponse = await createExpandedTaskInputResponse(
taskGraphClientResponse,
depGraphClientResponse
);
const environmentJs = buildEnvironmentJs(
args.exclude || [],
args.watch,
!!args.file && args.file.endsWith('html') ? 'build' : 'serve',
depGraphClientResponse,
taskGraphClientResponse
taskGraphClientResponse,
taskInputsReponse
);
html = html.replace(/src="/g, 'src="static/');
html = html.replace(/href="styles/g, 'href="static/styles');
@@ -453,7 +486,6 @@ async function startServer(
// by limiting the path to current directory only
const sanitizePath = basename(parsedUrl.pathname);
if (sanitizePath === 'project-graph.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(currentDepGraphClientResponse));
@@ -466,6 +498,23 @@ async function startServer(
return;
}
if (sanitizePath === 'task-inputs.json') {
performance.mark('task input generation:start');
const taskId = parsedUrl.searchParams.get('taskId');
res.writeHead(200, { 'Content-Type': 'application/json' });
const inputs = await getExpandedTaskInputs(taskId);
performance.mark('task input generation:end');
res.end(JSON.stringify(inputs));
performance.measure(
'task input generation',
'task input generation:start',
'task input generation:end'
);
return;
}
if (sanitizePath === 'currentHash') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hash: currentDepGraphClientResponse.hash }));
@@ -479,7 +528,6 @@ async function startServer(
}
let pathname = join(__dirname, '../../core/graph/', sanitizePath);
// if the file is not found or is a directory, return index.html
if (!existsSync(pathname) || statSync(pathname).isDirectory()) {
res.writeHead(200, { 'Content-Type': 'text/html' });
@@ -617,32 +665,93 @@ async function createDepGraphClientResponse(
};
}
async function createTaskGraphClientResponse(): Promise<TaskGraphClientResponse> {
let graph = pruneExternalNodes(
await createProjectGraphAsync({ exitOnError: true })
);
async function createTaskGraphClientResponse(
pruneExternal: boolean = true
): Promise<TaskGraphClientResponse> {
let graph: ProjectGraph;
if (pruneExternal) {
graph = pruneExternalNodes(
await createProjectGraphAsync({ exitOnError: true })
);
} else {
graph = await createProjectGraphAsync({ exitOnError: true });
}
const nxJson = readNxJson();
performance.mark('task graph generation:start');
const taskGraphs = getAllTaskGraphsForWorkspace(graph);
const taskGraphs = getAllTaskGraphsForWorkspace(nxJson, graph);
performance.mark('task graph generation:end');
const planner = new HashPlanner(nxJson, graph, {});
performance.mark('task hash plan generation:start');
const plans: Record<string, string[]> = {};
for (const individualTaskGraph of Object.values(taskGraphs.taskGraphs)) {
for (const task of Object.values(individualTaskGraph.tasks)) {
if (plans[task.id]) {
continue;
}
plans[task.id] = planner.getHashPlan(task.id, individualTaskGraph);
}
}
performance.mark('task hash plan generation:end');
performance.measure(
'task graph generation',
'task graph generation:start',
'task graph generation:end'
);
return taskGraphs;
performance.measure(
'task hash plan generation',
'task hash plan generation:start',
'task hash plan generation:end'
);
return {
...taskGraphs,
plans,
};
}
function getAllTaskGraphsForWorkspace(projectGraph: ProjectGraph): {
async function createExpandedTaskInputResponse(
taskGraphClientResponse: TaskGraphClientResponse,
depGraphClientResponse: ProjectGraphClientResponse
): Promise<ExpandedTaskInputsReponse> {
performance.mark('task input static generation:start');
const allWorkspaceFiles = await allFileData();
const response: Record<string, Record<string, string[]>> = {};
Object.entries(taskGraphClientResponse.plans).forEach(([key, inputs]) => {
const [project] = key.split(':');
const expandedInputs = expandInputs(
inputs,
depGraphClientResponse.projects.find((p) => p.name === project),
allWorkspaceFiles,
depGraphClientResponse
);
response[key] = expandedInputs;
});
performance.mark('task input static generation:end');
performance.measure(
'task input static generation',
'task input static generation:start',
'task input static generation:end'
);
return response;
}
function getAllTaskGraphsForWorkspace(
nxJson: NxJsonConfiguration,
projectGraph: ProjectGraph
): {
taskGraphs: Record<string, TaskGraph>;
errors: Record<string, string>;
} {
const nxJson = readNxJson();
const defaultDependencyConfigs = mapTargetDefaultsToDependencies(
nxJson.targetDefaults
);
@@ -650,6 +759,7 @@ function getAllTaskGraphsForWorkspace(projectGraph: ProjectGraph): {
const taskGraphs: Record<string, TaskGraph> = {};
const taskGraphErrors: Record<string, string> = {};
// TODO(cammisuli): improve performance here. Cache results or something.
for (const projectName in projectGraph.nodes) {
const project = projectGraph.nodes[projectName];
const targets = Object.keys(project.data.targets);
@@ -720,6 +830,130 @@ function createTaskId(
}
}
async function getExpandedTaskInputs(
taskId: string
): Promise<Record<string, string[]>> {
const [project] = taskId.split(':');
const taskGraphResponse = await createTaskGraphClientResponse(false);
const allWorkspaceFiles = await allFileData();
const inputs = taskGraphResponse.plans[taskId];
if (inputs) {
return expandInputs(
inputs,
currentDepGraphClientResponse.projects.find((p) => p.name === project),
allWorkspaceFiles,
currentDepGraphClientResponse
);
}
return {};
}
function expandInputs(
inputs: string[],
project: ProjectGraphProjectNode,
allWorkspaceFiles: FileData[],
depGraphClientResponse: ProjectGraphClientResponse
): Record<string, string[]> {
const projectNames = depGraphClientResponse.projects.map((p) => p.name);
const workspaceRootInputs: string[] = [];
const projectRootInputs: string[] = [];
const externalInputs: string[] = [];
const otherInputs: string[] = [];
inputs.forEach((input) => {
if (input.startsWith('{workspaceRoot}')) {
workspaceRootInputs.push(input);
return;
}
const maybeProjectName = input.split(':')[0];
if (projectNames.includes(maybeProjectName)) {
projectRootInputs.push(input);
return;
}
if (
input === 'ProjectConfiguration' ||
input === 'TsConfig' ||
input === 'AllExternalDependencies'
) {
otherInputs.push(input);
return;
}
// there shouldn't be any other imports in here, but external ones are always going to have a modifier in front
if (input.includes(':')) {
externalInputs.push(input);
return;
}
});
const workspaceRootsExpanded: string[] = workspaceRootInputs.flatMap(
(input) => {
const matches = [];
const withoutWorkspaceRoot = input.substring(16);
const matchingFile = allWorkspaceFiles.find(
(t) => t.file === withoutWorkspaceRoot
);
if (matchingFile) {
matches.push(matchingFile.file);
} else {
allWorkspaceFiles
.filter((f) => minimatch(f.file, withoutWorkspaceRoot))
.forEach((f) => {
matches.push(f.file);
});
}
return matches;
}
);
const otherInputsExpanded = otherInputs.map((input) => {
if (input === 'TsConfig') {
return relative(workspaceRoot, getRootTsConfigPath());
}
if (input === 'ProjectConfiguration') {
return depGraphClientResponse.fileMap[project.name].find(
(file) =>
file.file === `${project.data.root}/project.json` ||
file.file === `${project.data.root}/package.json`
).file;
}
return input;
});
const projectRootsExpanded = projectRootInputs
.map((input) => {
const fileSetProjectName = input.split(':')[0];
const fileSetProject = depGraphClientResponse.projects.find(
(p) => p.name === fileSetProjectName
);
const fileSets = input.replace(`${fileSetProjectName}:`, '').split(',');
const projectInputExpanded = {
[fileSetProject.name]: filterUsingGlobPatterns(
fileSetProject.data.root,
depGraphClientResponse.fileMap[fileSetProject.name],
fileSets
).map((f) => f.file),
};
return projectInputExpanded;
})
.reduce((curr, acc) => {
for (let key in curr) {
acc[key] = curr[key];
}
return acc;
}, {});
return {
general: [...workspaceRootsExpanded, ...otherInputsExpanded],
...projectRootsExpanded,
external: externalInputs,
};
}
interface GraphJsonResponse {
tasks?: TaskGraph;
graph: ProjectGraph;
@@ -0,0 +1,127 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`task planner should be able to handle multiple filesets per project 1`] = `
{
"parent:test": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"child:!{projectRoot}/**/*.spec.ts",
"env:MY_TEST_HASH_ENV",
"parent:{projectRoot}/**/*",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/global1",
"{workspaceRoot}/global2",
"{workspaceRoot}/nx.json",
],
}
`;
exports[`task planner should build plans where the project graph has circular dependencies 1`] = `
{
"child:build": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"child:{projectRoot}/**/*",
"parent:{projectRoot}/**/*",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
"parent:build": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"child:{projectRoot}/**/*",
"parent:{projectRoot}/**/*",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
}
`;
exports[`task planner should include npm projects 1`] = `
{
"app:build": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"app:{projectRoot}/**/*",
"npm:react",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
}
`;
exports[`task planner should make a plan with multiple filesets of a project 1`] = `
{
"parent:build": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"parent:!{projectRoot}/**/*.spec.ts",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
"parent:test": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"parent:{projectRoot}/**/*",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
}
`;
exports[`task planner should plan non-default filesets 1`] = `
{
"parent:build": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"child:{projectRoot}/**/*",
"parent:!{projectRoot}/**/*.spec.ts",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
}
`;
exports[`task planner should plan the task where the project has dependencies 1`] = `
{
"parent:build": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"child:{projectRoot}/**/*",
"parent:{projectRoot}/**/*",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
}
`;
exports[`task planner should use targetDefaults from nx.json 1`] = `
{
"parent:build": [
"AllExternalDependencies",
"ProjectConfiguration",
"TsConfig",
"child:!{projectRoot}/**/*.spec.ts",
"parent:!{projectRoot}/**/*.spec.ts",
"{workspaceRoot}/.gitignore",
"{workspaceRoot}/.nxignore",
"{workspaceRoot}/nx.json",
],
}
`;
+627
View File
@@ -0,0 +1,627 @@
import { TempFs } from '../utils/testing/temp-fs';
let tempFs = new TempFs('task-planner');
import { withEnvironmentVariables } from '../../internal-testing-utils/with-environment';
import { InProcessTaskHasher } from './task-hasher';
import { fileHasher } from './file-hasher';
import { HashPlanner } from './hash-planner';
import { Task, TaskGraph } from '../config/task-graph';
import { ProjectGraphBuilder } from '../project-graph/project-graph-builder';
import { createTaskGraph } from '../tasks-runner/create-task-graph';
jest.mock('../utils/workspace-root', () => {
return {
workspaceRoot: tempFs.tempDir,
};
});
describe('task planner', () => {
const packageJson = {
name: 'nrwl',
};
const tsConfigBaseJson = JSON.stringify({
compilerOptions: {
paths: {
'@nx/parent': ['libs/parent/src/index.ts'],
'@nx/child': ['libs/child/src/index.ts'],
},
},
});
const allWorkspaceFiles = [
{ file: 'yarn.lock', hash: 'yarn.lock.hash' },
{ file: 'nx.json', hash: 'nx.json.hash' },
{ file: 'package-lock.json', hash: 'package-lock.json.hash' },
{ file: 'package.json', hash: 'package.json.hash' },
{ file: 'pnpm-lock.yaml', hash: 'pnpm-lock.yaml.hash' },
{ file: 'tsconfig.base.json', hash: tsConfigBaseJson },
{ file: 'workspace.json', hash: 'workspace.json.hash' },
{ file: 'global1', hash: 'global1.hash' },
{ file: 'global2', hash: 'global2.hash' },
];
// TODO(cammisuli): This function is temporary until the new file hashing is implemented
// This should just match snapshots of the planner
async function assertHashPlan(
task: Task | Task[],
taskGraph: TaskGraph,
taskHasher: InProcessTaskHasher,
hashPlanner: HashPlanner
) {
if (!Array.isArray(task)) task = [task];
function getHashPlans(
tasks: Task[],
taskGraph: TaskGraph
): Record<string, string[]> {
return tasks.reduce((acc, task) => {
acc[task.id] = hashPlanner.getHashPlan(task.id, taskGraph, [
task.target.project,
]);
return acc;
}, {});
}
const hashes = await taskHasher.hashTasks(task, taskGraph);
const plans = getHashPlans(task, taskGraph);
let hashNodes = hashes.map((hash) => {
return Object.keys(hash.details.nodes).sort();
});
let planNodes = Object.values(plans).map((plan) => plan.sort());
for (let i = 0; i < hashNodes.length; i++) {
expect(planNodes[i]).toEqual(hashNodes[i]);
}
return plans;
}
beforeEach(async () => {
await tempFs.createFiles({
'tsconfig.base.json': tsConfigBaseJson,
'yarn.lock': 'content',
'package.json': JSON.stringify(packageJson),
});
});
afterEach(() => {
tempFs.reset();
});
it('should build a plan that matches the original task-hasher', async () => {
await withEnvironmentVariables({ TESTENV: 'env123' }, async () => {
let projectFileMap = {
parent: [{ file: '/file', hash: 'file.hash' }],
unrelated: [{ file: 'libs/unrelated/filec.ts', hash: 'filec.hash' }],
};
const builder = new ProjectGraphBuilder();
builder.addNode({
name: 'parent',
type: 'lib',
data: {
root: 'parent',
targets: {
build: {
executor: 'nx:run-commands',
inputs: [
'default',
'^default',
{ runtime: 'echo runtime123' },
{ env: 'TESTENV' },
{ env: 'NONEXISTENTENV' },
{
input: 'default',
projects: ['unrelated', 'tag:some-tag'],
},
],
},
},
},
});
builder.addNode({
name: 'unrelated',
type: 'lib',
data: {
root: 'libs/unrelated',
targets: { build: {} },
},
});
builder.addNode({
name: 'tagged',
type: 'lib',
data: {
root: 'libs/tagged',
targets: { build: {} },
tags: ['some-tag'],
},
});
const projectGraph = builder.getUpdatedProjectGraph();
const taskGraph = createTaskGraph(
projectGraph,
{},
['parent'],
['build'],
undefined,
{},
false
);
let options = {
runtimeCacheInputs: ['echo runtime456'],
};
let nxJson = {} as any;
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson,
options,
fileHasher
);
const planner = new HashPlanner(nxJson, projectGraph, options);
await assertHashPlan(
taskGraph.tasks['parent:build'],
taskGraph,
hasher,
planner
);
});
});
it('should plan the task where the project has dependencies', async () => {
const projectFileMap = {
parent: [
{ file: '/filea.ts', hash: 'a.hash' },
{ file: '/filea.spec.ts', hash: 'a.spec.hash' },
],
child: [
{ file: '/fileb.ts', hash: 'b.hash' },
{ file: '/fileb.spec.ts', hash: 'b.spec.hash' },
],
};
const builder = new ProjectGraphBuilder(undefined, projectFileMap);
builder.addNode({
name: 'parent',
type: 'lib',
data: {
root: 'libs/parent',
targets: { build: { executor: 'unknown' } },
},
});
builder.addNode({
name: 'child',
type: 'lib',
data: {
root: 'libs/child',
targets: { build: { executor: 'none' } },
},
});
builder.addStaticDependency('parent', 'child', '/filea.ts');
const projectGraph = builder.getUpdatedProjectGraph();
let taskGraph = createTaskGraph(
projectGraph,
{ build: ['^build'] },
['parent'],
['build'],
undefined,
{}
);
let nxJson = {} as any;
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson,
{},
fileHasher
);
const planner = new HashPlanner(nxJson, projectGraph, {});
const hashPlan = await assertHashPlan(
taskGraph.tasks['parent:build'],
taskGraph,
hasher,
planner
);
expect(hashPlan).toMatchSnapshot();
});
it('should plan non-default filesets', async () => {
let projectFileMap = {
parent: [
{ file: 'libs/parent/filea.ts', hash: 'a.hash' },
{ file: 'libs/parent/filea.spec.ts', hash: 'a.spec.hash' },
],
child: [
{ file: 'libs/child/fileb.ts', hash: 'b.hash' },
{ file: 'libs/child/fileb.spec.ts', hash: 'b.spec.hash' },
],
};
let builder = new ProjectGraphBuilder(undefined, projectFileMap);
builder.addNode({
name: 'parent',
type: 'lib',
data: {
root: 'libs/parent',
targets: {
build: {
inputs: ['prod', '^prod'],
executor: 'nx:run-commands',
},
},
},
});
builder.addNode({
name: 'child',
type: 'lib',
data: {
root: 'libs/child',
namedInputs: {
prod: ['default'],
},
targets: { build: { executor: 'unknown' } },
},
});
builder.addStaticDependency('parent', 'child', 'libs/parent/filea.ts');
let projectGraph = builder.getUpdatedProjectGraph();
let taskGraph = createTaskGraph(
projectGraph,
{ build: ['^build'] },
['parent'],
['build'],
undefined,
{}
);
let nxJson = {
namedInputs: {
prod: ['!{projectRoot}/**/*.spec.ts'],
},
} as any;
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson,
{},
fileHasher
);
const planner = new HashPlanner(nxJson, projectGraph, {});
let hashPlans = await assertHashPlan(
taskGraph.tasks['parent:build'],
taskGraph,
hasher,
planner
);
expect(hashPlans).toMatchSnapshot();
});
it('should make a plan with multiple filesets of a project', async () => {
let projectFileMap = {
parent: [
{ file: 'libs/parent/filea.ts', hash: 'a.hash' },
{ file: 'libs/parent/filea.spec.ts', hash: 'a.spec.hash' },
],
};
let builder = new ProjectGraphBuilder(undefined, projectFileMap);
builder.addNode({
name: 'parent',
type: 'lib',
data: {
root: 'libs/parent',
targets: {
build: {
inputs: ['prod'],
executor: 'nx:run-commands',
},
test: {
inputs: ['default'],
dependsOn: ['build'],
executor: 'nx:run-commands',
},
},
},
});
let projectGraph = builder.getUpdatedProjectGraph();
let taskGraph = createTaskGraph(
projectGraph,
{},
['parent'],
['build', 'test'],
undefined,
{}
);
let nxJson = {
namedInputs: {
prod: ['!{projectRoot}/**/*.spec.ts'],
},
} as any;
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson,
{},
fileHasher
);
const planner = new HashPlanner(nxJson, projectGraph, {});
const tasks = Object.values(taskGraph.tasks);
let plans = await assertHashPlan(tasks, taskGraph, hasher, planner);
expect(plans).toMatchSnapshot();
});
it('should be able to handle multiple filesets per project', async () => {
await withEnvironmentVariables(
{ MY_TEST_HASH_ENV: 'MY_TEST_HASH_ENV_VALUE' },
async () => {
let projectFileMap = {
parent: [
{ file: 'libs/parent/filea.ts', hash: 'a.hash' },
{ file: 'libs/parent/filea.spec.ts', hash: 'a.spec.hash' },
],
child: [
{ file: 'libs/child/fileb.ts', hash: 'b.hash' },
{ file: 'libs/child/fileb.spec.ts', hash: 'b.spec.hash' },
],
};
const builder = new ProjectGraphBuilder(undefined, projectFileMap);
builder.addNode({
name: 'parent',
type: 'lib',
data: {
root: 'libs/parent',
targets: {
test: {
inputs: ['default', '^prod'],
executor: 'nx:run-commands',
},
},
},
});
builder.addNode({
name: 'child',
type: 'lib',
data: {
root: 'libs/child',
namedInputs: {
prod: [
'!{projectRoot}/**/*.spec.ts',
'{workspaceRoot}/global2',
{ env: 'MY_TEST_HASH_ENV' },
],
},
targets: {
test: {
inputs: ['default'],
executor: 'nx:run-commands',
},
},
},
});
builder.addStaticDependency('parent', 'child', 'libs/parent/filea.ts');
let projectGraph = builder.getUpdatedProjectGraph();
let taskGraph = createTaskGraph(
projectGraph,
{ build: ['^build'] },
['parent'],
['test'],
undefined,
{}
);
let nxJson = {
namedInputs: {
default: ['{projectRoot}/**/*', '{workspaceRoot}/global1'],
prod: ['!{projectRoot}/**/*.spec.ts'],
},
};
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson as any,
{},
fileHasher
);
const planner = new HashPlanner(nxJson as any, projectGraph, {});
const tasks = Object.values(taskGraph.tasks);
let plans = await assertHashPlan(tasks, taskGraph, hasher, planner);
expect(plans).toMatchSnapshot();
}
);
});
it('should use targetDefaults from nx.json', async () => {
let projectFileMap = {
parent: [
{ file: 'libs/parent/filea.ts', hash: 'a.hash' },
{ file: 'libs/parent/filea.spec.ts', hash: 'a.spec.hash' },
],
child: [
{ file: 'libs/child/fileb.ts', hash: 'b.hash' },
{ file: 'libs/child/fileb.spec.ts', hash: 'b.spec.hash' },
],
};
const builder = new ProjectGraphBuilder(undefined, projectFileMap);
builder.addNode({
name: 'parent',
type: 'lib',
data: {
root: 'libs/parent',
targets: {
build: { executor: 'nx:run-commands' },
},
},
});
builder.addNode({
name: 'child',
type: 'lib',
data: {
root: 'libs/child',
targets: { build: { executor: 'nx:run-commands' } },
},
});
builder.addStaticDependency('parent', 'child', 'libs/parent/filea.ts');
let projectGraph = builder.getUpdatedProjectGraph();
let taskGraph = createTaskGraph(
projectGraph,
{ build: ['^build'] },
['parent'],
['build'],
undefined,
{}
);
let nxJson = {
namedInputs: {
prod: ['!{projectRoot}/**/*.spec.ts'],
},
targetDefaults: {
build: {
inputs: ['prod', '^prod'],
},
},
} as any;
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson,
{},
fileHasher
);
const planner = new HashPlanner(nxJson, projectGraph, {});
let plans = await assertHashPlan(
taskGraph.tasks['parent:build'],
taskGraph,
hasher,
planner
);
expect(plans).toMatchSnapshot();
});
it('should build plans where the project graph has circular dependencies', async () => {
let projectFileMap = {
parent: [{ file: '/filea.ts', hash: 'a.hash' }],
child: [{ file: '/fileb.ts', hash: 'b.hash' }],
};
let builder = new ProjectGraphBuilder(undefined, projectFileMap);
builder.addNode({
name: 'parent',
type: 'lib',
data: {
root: 'libs/parent',
targets: { build: { executor: 'nx:run-commands' } },
},
});
builder.addNode({
name: 'child',
type: 'lib',
data: {
root: 'libs/child',
targets: { build: { executor: 'nx:run-commands' } },
},
});
builder.addStaticDependency('parent', 'child', '/filea.ts');
builder.addStaticDependency('child', 'parent', '/fileb.ts');
let projectGraph = builder.getUpdatedProjectGraph();
let taskGraph = createTaskGraph(
projectGraph,
{ build: ['^build'] },
['parent'],
['build'],
undefined,
{}
);
let nxJson = {} as any;
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson,
{},
fileHasher
);
let planner = new HashPlanner(nxJson, projectGraph, {});
let tasks = Object.values(taskGraph.tasks);
let plans = await assertHashPlan(tasks, taskGraph, hasher, planner);
expect(plans).toMatchSnapshot();
});
it('should include npm projects', async () => {
let projectFileMap = {
app: [{ file: '/filea.ts', hash: 'a.hash' }],
};
let builder = new ProjectGraphBuilder(undefined, projectFileMap);
builder.addNode({
name: 'app',
type: 'app',
data: {
root: 'apps/app',
targets: { build: { executor: 'nx:run-commands' } },
},
});
builder.addExternalNode({
name: 'npm:react',
type: 'npm',
data: {
version: '17.0.0',
packageName: 'react',
},
});
builder.addStaticDependency('app', 'npm:react', '/filea.ts');
let projectGraph = builder.getUpdatedProjectGraph();
let taskGraph = createTaskGraph(
projectGraph,
{ build: ['^build'] },
['app'],
['build'],
undefined,
{}
);
let nxJson = {} as any;
const hasher = new InProcessTaskHasher(
projectFileMap,
allWorkspaceFiles,
projectGraph,
nxJson,
{},
fileHasher
);
const planner = new HashPlanner(nxJson, projectGraph, {});
let plans = await assertHashPlan(
taskGraph.tasks['app:build'],
taskGraph,
hasher,
planner
);
expect(plans).toMatchSnapshot();
});
});
+412
View File
@@ -0,0 +1,412 @@
import * as minimatch from 'minimatch';
import { NxJsonConfiguration } from '../config/nx-json';
import { ProjectGraph, ProjectGraphDependency } from '../config/project-graph';
import { Task, TaskGraph } from '../config/task-graph';
import {
ExpandedDepsOutput,
ExpandedInput,
ExpandedSelfInput,
expandNamedInput,
expandSingleProjectInputs,
extractPatternsFromFileSets,
getInputs,
getNamedInputs,
isDepsOutput,
isSelfInput,
LEGACY_FILESET_INPUTS,
} from './task-hasher';
import { findMatchingProjects } from '../utils/find-matching-projects';
import { findAllProjectNodeDependencies } from '../utils/project-graph-utils';
import { workspaceRoot } from '../utils/workspace-root';
import { getOutputsForTargetAndConfiguration } from '../tasks-runner/utils';
export class HashPlanner {
legacyRuntimeInputs: ExpandedSelfInput[];
constructor(
private readonly nxJson: NxJsonConfiguration,
private readonly projectGraph: ProjectGraph,
private options: { runtimeCacheInputs?: string[] }
) {
const legacyRuntimeInputs: ExpandedSelfInput[] = (
this.options && this.options.runtimeCacheInputs
? this.options.runtimeCacheInputs
: []
).map((r) => ({ runtime: r }));
if (process.env.NX_CLOUD_ENCRYPTION_KEY) {
legacyRuntimeInputs.push({ env: 'NX_CLOUD_ENCRYPTION_KEY' });
}
this.legacyRuntimeInputs = legacyRuntimeInputs;
}
getHashPlan(
taskId: string,
taskGraph: TaskGraph,
visited: string[] = [taskId]
): string[] {
const task = taskGraph.tasks[taskId];
const { selfInputs, depsInputs, depsOutputs, projectInputs } = getInputs(
task,
this.projectGraph,
this.nxJson
);
const target = this.targetInput(
task.target.project,
task.target.target,
selfInputs
);
const selfAndInputs = this.getSelfAndDepsInputs(
task.target.project,
task,
{ selfInputs, depsInputs, depsOutputs, projectInputs },
taskGraph,
visited,
// TODO(cammisuli): put this back when the task hasher is replaced
// target.includes('AllExternalDependencies')
false
);
return selfAndInputs.concat(target);
}
private getNamedInputsForDependencies(
projectName: string,
task: Task,
namedInput: string,
taskGraph: TaskGraph,
visited: string[],
skipExternalDeps
): string[] {
const projectNode = this.projectGraph.nodes[projectName];
const namedInputs = {
default: [{ fileset: '{projectRoot}/**/*' }],
...this.nxJson.namedInputs,
...projectNode.data.namedInputs,
};
const expandedInputs = expandNamedInput(namedInput, namedInputs);
const selfInputs = expandedInputs.filter(isSelfInput);
const depsOutputs = expandedInputs.filter(isDepsOutput);
const depsInputs = [{ input: namedInput, dependencies: true as true }]; // true is boolean by default
return this.getSelfAndDepsInputs(
projectName,
task,
{ selfInputs, depsInputs, depsOutputs, projectInputs: [] },
taskGraph,
visited,
skipExternalDeps
);
}
private getSelfAndDepsInputs(
projectName: string,
task: Task,
inputs: {
selfInputs: ExpandedSelfInput[];
depsInputs: { input: string; dependencies: true }[];
depsOutputs: ExpandedDepsOutput[];
projectInputs: { input: string; projects: string[] }[];
},
taskGraph: TaskGraph,
visited: string[],
skipExternalDeps
): string[] {
const projectGraphDeps = this.projectGraph.dependencies[projectName] ?? [];
const self = this.singleProjectInputs(projectName, inputs.selfInputs);
const deps = this.getDepsInputs(
task,
inputs.depsInputs,
projectGraphDeps,
taskGraph,
visited,
skipExternalDeps
);
const depsOut = this.getDepsOutputs(task, taskGraph, inputs.depsOutputs);
const projects = this.getProjectInputs(inputs.projectInputs);
return Array.from(new Set([...self, ...deps, ...depsOut, ...projects]));
}
private getDepsInputs(
task: Task,
inputs: { input: string }[],
projectGraphDeps: ProjectGraphDependency[],
taskGraph: TaskGraph,
visited: string[],
skipExternalDeps
): string[] {
return inputs
.map((input) => {
return projectGraphDeps
.map((d) => {
if (visited.indexOf(d.target) > -1) {
return null;
} else {
visited.push(d.target);
if (this.projectGraph.nodes[d.target]) {
return this.getNamedInputsForDependencies(
d.target,
task,
input.input || 'default',
taskGraph,
visited,
skipExternalDeps
);
} else {
if (skipExternalDeps) {
return null;
} else {
// external dependency
const deps = findAllProjectNodeDependencies(
d.target,
this.projectGraph,
true
);
return [d.target, ...deps];
}
}
}
})
.flat();
})
.flat()
.filter((r) => !!r);
}
private getDepsOutputs(
task: Task,
taskGraph: TaskGraph,
depsOutputs: ExpandedDepsOutput[]
): string[] {
if (depsOutputs.length === 0) {
return [];
}
const result: string[] = [];
for (const { dependentTasksOutputFiles, transitive } of depsOutputs) {
result.push(
...this.getDepOutput(
task,
taskGraph,
dependentTasksOutputFiles,
transitive
)
);
}
return result;
}
private getDepOutput(
task: Task,
taskGraph: TaskGraph,
dependentTasksOutputFiles: string,
transitive?: boolean
): string[] {
// task has no dependencies
if (!taskGraph.dependencies[task.id]) {
return [];
}
const inputs: string[] = [];
for (const d of taskGraph.dependencies[task.id]) {
const childTask = taskGraph.tasks[d];
const outputs = getOutputsForTargetAndConfiguration(
childTask,
this.projectGraph.nodes[childTask.target.project]
);
const { getFilesForOutputs } =
require('../native') as typeof import('../native');
const outputFiles = getFilesForOutputs(workspaceRoot, outputs);
const filteredFiles = outputFiles.filter(
(p) =>
p === dependentTasksOutputFiles ||
minimatch(p, dependentTasksOutputFiles)
);
inputs.push(...filteredFiles);
if (transitive) {
inputs.push(
...this.getDepOutput(
childTask,
taskGraph,
dependentTasksOutputFiles,
transitive
)
);
}
}
return inputs;
}
private targetInput(
projectName: string,
targetName: string,
selfInputs: ExpandedSelfInput[]
): string[] | undefined {
const projectNode = this.projectGraph.nodes[projectName];
const target = projectNode.data.targets[targetName];
if (!target) {
return;
}
// we can only vouch for @nx packages's executor dependencies
// if it's "run commands" or third-party we skip traversing since we have no info what this command depends on
if (
target.executor.startsWith(`@nrwl/`) ||
target.executor.startsWith(`@nx/`)
) {
const executorPackage = target.executor.split(':')[0];
return [this.findExternalDependencyNodeName(executorPackage)];
} else {
// use command external dependencies if available to construct the hash
const externalDeps: string[] = [];
let hasCommandExternalDependencies = false;
for (const input of selfInputs) {
if (input['externalDependencies']) {
// if we have externalDependencies with empty array we still want to override the default hash
hasCommandExternalDependencies = true;
const externalDependencies = input['externalDependencies'];
for (let externalDependency of externalDependencies) {
let externalNodeName =
this.findExternalDependencyNodeName(externalDependency);
if (!externalDependency) {
throw new Error(
`The externalDependency "${externalDependency}" for "${projectName}:${targetName}" could not be found`
);
}
const deps = findAllProjectNodeDependencies(
externalNodeName,
this.projectGraph,
true
);
externalDeps.push(externalDependency, ...deps);
}
}
}
if (hasCommandExternalDependencies) {
return externalDeps;
} else {
return ['AllExternalDependencies'];
}
}
}
private findExternalDependencyNodeName(packageName: string): string {
if (this.projectGraph.externalNodes?.[packageName]) {
return packageName;
}
if (this.projectGraph.externalNodes?.[`npm:${packageName}`]) {
return `npm:${packageName}`;
}
for (const node of Object.values(this.projectGraph.externalNodes ?? {})) {
if (node.data.packageName === packageName) {
return node.name;
}
}
// not found, just return the package name
return `external:${packageName}`;
}
private singleProjectInputs(
projectName: string,
inputs: ExpandedInput[]
): string[] {
const filesets = extractPatternsFromFileSets(inputs);
const projectFilesets = [];
const workspaceFilesets = [];
let invalidFilesetNoPrefix = null;
for (let f of filesets) {
if (f.startsWith('{projectRoot}/') || f.startsWith('!{projectRoot}/')) {
projectFilesets.push(f);
} else if (
f.startsWith('{workspaceRoot}/') ||
f.startsWith('!{workspaceRoot}/')
) {
workspaceFilesets.push(f);
} else {
invalidFilesetNoPrefix = f;
}
}
if (invalidFilesetNoPrefix) {
throw new Error(
[
`"${invalidFilesetNoPrefix}" is an invalid fileset.`,
'All filesets have to start with either {workspaceRoot} or {projectRoot}.',
'For instance: "!{projectRoot}/**/*.spec.ts" or "{workspaceRoot}/package.json".',
`If "${invalidFilesetNoPrefix}" is a named input, make sure it is defined in, for instance, nx.json.`,
].join('\n')
);
}
const notFilesets = inputs.filter((r) => !r['fileset']);
return [
...this.projectFileSetInputs(projectName, projectFilesets),
...[
...workspaceFilesets,
...LEGACY_FILESET_INPUTS.map((input) => input.fileset),
].map((fileset) => this.rootFilesetInput(fileset)),
...[...notFilesets, ...this.legacyRuntimeInputs].map((r) =>
r['runtime'] ? this.runtimeInput(r['runtime']) : this.envInput(r['env'])
),
];
}
private getProjectInputs(
projectInputs: { input: string; projects: string[] }[]
): string[] {
const gatheredInputs: string[][] = [];
for (const input of projectInputs) {
const projects = findMatchingProjects(
input.projects,
this.projectGraph.nodes
);
for (const project of projects) {
const namedInputs = getNamedInputs(
this.nxJson,
this.projectGraph.nodes[project]
);
const expandedInput = expandSingleProjectInputs(
[{ input: input.input }],
namedInputs
);
gatheredInputs.push(this.singleProjectInputs(project, expandedInput));
}
}
return gatheredInputs.flat();
}
private rootFilesetInput(fileset: string): string {
return fileset;
}
private projectFileSetInputs(
projectName: string,
filesetPatterns: string[]
): string[] {
let projectInput = [];
projectInput.push(`${projectName}:${filesetPatterns.join(',')}`);
projectInput.push(`ProjectConfiguration`);
projectInput.push(`TsConfig`);
return projectInput;
}
private runtimeInput(runtime: string): string {
return `runtime:${runtime}`;
}
private envInput(envVarName: string): string {
return `env:${envVarName}`;
}
}
+17 -17
View File
@@ -22,18 +22,18 @@ import { join, relative } from 'path';
import { normalizePath } from '../utils/path';
import { findAllProjectNodeDependencies } from '../utils/project-graph-utils';
type ExpandedSelfInput =
export type ExpandedSelfInput =
| { fileset: string }
| { runtime: string }
| { env: string }
| { externalDependencies: string[] };
type ExpandedDepsOutput = {
export type ExpandedDepsOutput = {
dependentTasksOutputFiles: string;
transitive?: boolean;
};
type ExpandedInput = ExpandedSelfInput | ExpandedDepsOutput;
export type ExpandedInput = ExpandedSelfInput | ExpandedDepsOutput;
/**
* A data structure returned by the default hasher.
@@ -93,10 +93,17 @@ export class DaemonBasedTaskHasher implements TaskHasher {
}
}
export const LEGACY_FILESET_INPUTS = [
'nx.json',
// ignore files will change the set of inputs to the hasher
'.gitignore',
'.nxignore',
].map((d) => ({ fileset: `{workspaceRoot}/${d}` }));
export class InProcessTaskHasher implements TaskHasher {
static version = '3.0';
private taskHasher: TaskHasherImpl;
private taskHasher: TaskHasherImpl;
constructor(
private readonly projectFileMap: ProjectFileMap,
private readonly allWorkspaceFiles: FileData[],
@@ -110,23 +117,14 @@ export class InProcessTaskHasher implements TaskHasher {
? this.options.runtimeCacheInputs
: []
).map((r) => ({ runtime: r }));
if (process.env.NX_CLOUD_ENCRYPTION_KEY) {
legacyRuntimeInputs.push({ env: 'NX_CLOUD_ENCRYPTION_KEY' });
}
const legacyFilesetInputs = [
'nx.json',
// ignore files will change the set of inputs to the hasher
'.gitignore',
'.nxignore',
].map((d) => ({ fileset: `{workspaceRoot}/${d}` }));
this.taskHasher = new TaskHasherImpl(
nxJson,
legacyRuntimeInputs,
legacyFilesetInputs,
LEGACY_FILESET_INPUTS,
this.projectFileMap,
this.allWorkspaceFiles,
this.projectGraph,
@@ -900,15 +898,17 @@ function splitInputsIntoSelfAndDependencies(
};
}
function isSelfInput(input: ExpandedInput): input is ExpandedSelfInput {
export function isSelfInput(input: ExpandedInput): input is ExpandedSelfInput {
return !('dependentTasksOutputFiles' in input);
}
function isDepsOutput(input: ExpandedInput): input is ExpandedDepsOutput {
export function isDepsOutput(
input: ExpandedInput
): input is ExpandedDepsOutput {
return 'dependentTasksOutputFiles' in input;
}
function expandSingleProjectInputs(
export function expandSingleProjectInputs(
inputs: ReadonlyArray<InputDefinition | string>,
namedInputs: { [inputName: string]: ReadonlyArray<InputDefinition | string> }
): ExpandedInput[] {