Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6bf6414ce | |||
| 395c3562d0 | |||
| 5a2ea09d9b | |||
| 9c5873c9fa | |||
| 47d4eb8eb8 | |||
| ad9344152f | |||
| 491966b6e3 | |||
| cda123802f | |||
| 9690aea0a8 | |||
| 6a1164f7ae | |||
| 0ce7acdf77 | |||
| 137ba06499 | |||
| 5df932b108 | |||
| 62e75edd24 | |||
| 2145117b24 | |||
| a42cabccf9 |
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user