From 29ebd3dc27a163cdbbe6de8b8981d116737c1f99 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Mon, 4 Jan 2021 13:10:14 -0800 Subject: [PATCH] refactor: clean up mock fixtures and re-enable tests for executions (#130) * test: adding test data for node executions * test: mocks and refactoring to re-enable NodeExecutionDetails tests * chore: lint error * test: getting first test for NE table working again * test: mocks and a couple of tests for NE table * refactor: msw handlers to use a backing map and return 404s * test: more tests for NE table * test: adding fixture for dynamic external workflow * test: using mock fixture for sub workflow tests * test: move remaining mocks to fixtures and fix tests * test: re-enabling more execution tests * fix: removing global query handlers for caching entitiesq * test: re-enable ExecutionNodeViews tests * fix: typo in import path * fix: show DataError by default for failed queries * chore: documentation * chore: pr feedback --- .eslintrc.js | 3 +- package.json | 2 +- src/common/types.ts | 7 + src/components/Cache/utils.ts | 4 +- .../ExecutionDetails/ExecutionNodeViews.tsx | 1 - .../ExecutionWorkflowGraph.tsx | 4 +- .../NodeExecutionDetailsPanelContent.tsx | 1 - .../test/ExecutionNodeViews.test.tsx | 140 +-- .../test/NodeExecutionDetails.test.tsx | 56 +- .../useExecutionNodeViewsState.ts | 16 +- .../Executions/Tables/NodeExecutionsTable.tsx | 6 +- .../Tables/test/NodeExecutionsTable.test.tsx | 879 +++++++++--------- .../TaskExecutionNodes.tsx | 3 +- .../Executions/nodeExecutionQueries.ts | 59 +- src/components/Workflow/workflowQueries.ts | 16 +- src/components/common/WaitForQuery.tsx | 3 +- src/components/data/queryCache.ts | 28 +- src/components/data/queryObservers.ts | 56 -- src/mocks/createAdminServer.ts | 507 ++++++++++ src/mocks/data/constants.ts | 32 +- .../data/fixtures/basicPythonWorkflow.ts | 127 +++ .../fixtures/dynamicExternalSubworkflow.ts | 204 ++++ .../data/fixtures/dynamicPythonWorkflow.ts | 221 +++++ .../data/fixtures/oneFailedTaskWorkflow.ts | 130 +++ src/mocks/data/fixtures/types.ts | 42 + src/mocks/data/generators.ts | 206 ++++ src/mocks/data/insertFixture.ts | 80 ++ src/mocks/data/launchPlans.ts | 15 - src/mocks/data/nodeExecutions.ts | 0 src/mocks/data/projects.ts | 20 + src/mocks/data/utils.ts | 139 +++ src/mocks/data/workflowExecutions.ts | 48 - src/mocks/data/workflows.ts | 15 - src/mocks/getDefaultData.ts | 10 - src/mocks/handlers.ts | 102 -- src/mocks/insertDefaultData.ts | 9 + src/mocks/server.ts | 11 +- src/mocks/utils.ts | 43 +- .../AdminEntity/test/AdminEntity.spec.ts | 6 +- src/models/Task/api.ts | 13 +- src/models/Task/utils.ts | 10 + src/test/setupTests.ts | 8 +- src/test/utils.ts | 28 + yarn.lock | 17 +- 44 files changed, 2471 insertions(+), 856 deletions(-) delete mode 100644 src/components/data/queryObservers.ts create mode 100644 src/mocks/createAdminServer.ts create mode 100644 src/mocks/data/fixtures/basicPythonWorkflow.ts create mode 100644 src/mocks/data/fixtures/dynamicExternalSubworkflow.ts create mode 100644 src/mocks/data/fixtures/dynamicPythonWorkflow.ts create mode 100644 src/mocks/data/fixtures/oneFailedTaskWorkflow.ts create mode 100644 src/mocks/data/fixtures/types.ts create mode 100644 src/mocks/data/generators.ts create mode 100644 src/mocks/data/insertFixture.ts delete mode 100644 src/mocks/data/launchPlans.ts delete mode 100644 src/mocks/data/nodeExecutions.ts create mode 100644 src/mocks/data/utils.ts delete mode 100644 src/mocks/data/workflowExecutions.ts delete mode 100644 src/mocks/data/workflows.ts delete mode 100644 src/mocks/getDefaultData.ts delete mode 100644 src/mocks/handlers.ts create mode 100644 src/mocks/insertDefaultData.ts diff --git a/.eslintrc.js b/.eslintrc.js index ef13318f2..9788979a1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { 'jest/valid-title': 'off', '@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/ban-types': 'warn', - '@typescript-eslint/no-empty-function': 'warn' + '@typescript-eslint/no-empty-function': 'warn', + '@typescript-eslint/explicit-module-boundary-types': 'off' } }; diff --git a/package.json b/package.json index 594a7bab3..38fcbf66d 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ "react-intersection-observer": "^8.25.1", "react-json-tree": "^0.11.0", "react-loading-skeleton": "^1.1.2", - "react-query": "^3.2.0-beta", + "react-query": "^3.3.0", "react-query-devtools": "^3.0.0-beta", "react-router": "^5.0.1", "react-router-dom": "^5.0.1", diff --git a/src/common/types.ts b/src/common/types.ts index 52c3d380a..68f860d13 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -12,3 +12,10 @@ export interface ResourceDefinition { // TODO: This should probably be an enum type: string; } + +/** Converts type `T` to one in which all fields are optional and + * all fields in nested objects are also optional. + */ +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; diff --git a/src/components/Cache/utils.ts b/src/components/Cache/utils.ts index dab436216..79885b7f6 100644 --- a/src/components/Cache/utils.ts +++ b/src/components/Cache/utils.ts @@ -1,9 +1,11 @@ import * as objectHash from 'object-hash'; +export type KeyableType = any[] | object | string | symbol; + /** Generic cache key generator. For object, will generate a unique hash. * Strings are passed through for convenience. */ -export function getCacheKey(id: any[] | object | string | symbol): string { +export function getCacheKey(id: KeyableType): string { if (typeof id === 'symbol') { return id.toString(); } diff --git a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx index ca403840f..37bfed641 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx @@ -53,7 +53,6 @@ export const ExecutionNodeViews: React.FC = ({ nodeExecutionsRequestConfig } = useExecutionNodeViewsState(execution, appliedFilters); - // TODO: Handle error case (table usually rendered the error) const renderNodeExecutionsTable = (nodeExecutions: NodeExecution[]) => ( = ({ workflowId }) => { const workflowQuery = useQuery( - makeWorkflowQuery(workflowId) + makeWorkflowQuery(useQueryClient(), workflowId) ); const nodeExecutionsById = React.useMemo( () => keyBy(nodeExecutions, 'id.nodeId'), diff --git a/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx b/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx index 85b607c79..1f0cc5a4f 100644 --- a/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx +++ b/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx @@ -93,7 +93,6 @@ const NodeExecutionLinkContent: React.FC<{ }> = ({ execution }) => { const commonStyles = useCommonStyles(); const styles = useStyles(); - useStyles(); const { workflowNodeMetadata } = execution.closure; if (!workflowNodeMetadata) { return null; diff --git a/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx b/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx index f0c37a5a3..d0671617a 100644 --- a/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx +++ b/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx @@ -1,12 +1,15 @@ -import { render } from '@testing-library/react'; -import { createQueryClient } from 'components/data/queryCache'; -import { createMockExecutionEntities } from 'components/Executions/__mocks__/createMockExecutionEntities'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { filterLabels } from 'components/Executions/filters/constants'; +import { nodeExecutionStatusFilters } from 'components/Executions/filters/statusFilters'; +import { oneFailedTaskWorkflow } from 'mocks/data/fixtures/oneFailedTaskWorkflow'; +import { insertFixture } from 'mocks/data/insertFixture'; +import { mockServer } from 'mocks/server'; +import { Execution } from 'models/Execution/types'; import * as React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; -import { - ExecutionNodeViews, - ExecutionNodeViewsProps -} from '../ExecutionNodeViews'; +import { createTestQueryClient } from 'test/utils'; +import { tabs } from '../constants'; +import { ExecutionNodeViews } from '../ExecutionNodeViews'; // We don't need to verify the content of the graph component here and it is // difficult to make it work correctly in a test environment. @@ -14,80 +17,83 @@ jest.mock('../ExecutionWorkflowGraph.tsx', () => ({ ExecutionWorkflowGraph: () => null })); -// TODO: Update this to use MSW and re-enable -describe.skip('ExecutionNodeViews', () => { +// ExecutionNodeViews uses query params for NE list, so we must match them +// for the list to be returned properly +const baseQueryParams = { + filters: '', + 'sort_by.direction': 'ASCENDING', + 'sort_by.key': 'created_at' +}; + +describe('ExecutionNodeViews', () => { let queryClient: QueryClient; - let props: ExecutionNodeViewsProps; + let execution: Execution; + let fixture: ReturnType; beforeEach(() => { - queryClient = createQueryClient(); - const { workflowExecution } = createMockExecutionEntities({ - workflowName: 'SampleWorkflow', - nodeExecutionCount: 2 - }); + fixture = oneFailedTaskWorkflow.generate(); + execution = fixture.workflowExecutions.top.data; + insertFixture(mockServer, fixture); + const nodeExecutions = fixture.workflowExecutions.top.nodeExecutions; - props = { execution: workflowExecution }; + mockServer.insertNodeExecutionList( + execution.id, + Object.values(nodeExecutions).map(({ data }) => data), + baseQueryParams + ); + mockServer.insertNodeExecutionList( + execution.id, + [nodeExecutions.failedNode.data], + { ...baseQueryParams, filters: 'value_in(phase,FAILED)' } + ); + queryClient = createTestQueryClient(); }); - it('is disabled', () => {}); - const renderViews = () => render( - + ); - // it('only applies filter when viewing the nodes tab', async () => { - // const { getByText } = renderViews(); - // const nodesTab = await waitFor(() => getByText(tabs.nodes.label)); - // const graphTab = await waitFor(() => getByText(tabs.graph.label)); + it('maintains filter when switching back to nodes tab', async () => { + const { nodeExecutions } = fixture.workflowExecutions.top; + const failedNodeName = nodeExecutions.failedNode.data.id.nodeId; + const succeededNodeName = nodeExecutions.pythonNode.data.id.nodeId; - // fireEvent.click(nodesTab); - // const statusButton = await waitFor(() => - // getByText(filterLabels.status) - // ); - // fireEvent.click(statusButton); - // const successFilter = await waitFor(() => - // getByText(nodeExecutionStatusFilters.succeeded.label) - // ); + const { getByText, queryByText } = renderViews(); + const nodesTab = await waitFor(() => getByText(tabs.nodes.label)); + const graphTab = await waitFor(() => getByText(tabs.graph.label)); - // mockListNodeExecutions.mockClear(); - // fireEvent.click(successFilter); - // await waitFor(() => mockListNodeExecutions.mock.calls.length > 0); - // // Verify at least one filter is passed - // expect(mockListNodeExecutions).toHaveBeenCalledWith( - // expect.anything(), - // expect.objectContaining({ - // filter: expect.arrayContaining([ - // expect.objectContaining({ key: expect.any(String) }) - // ]) - // }) - // ); + // Ensure we are on Nodes tab + fireEvent.click(nodesTab); + await waitFor(() => getByText(succeededNodeName)); - // fireEvent.click(statusButton); - // await waitForElementToBeRemoved(successFilter); - // mockListNodeExecutions.mockClear(); - // fireEvent.click(graphTab); - // await waitFor(() => mockListNodeExecutions.mock.calls.length > 0); - // // No filter expected on the graph tab - // expect(mockListNodeExecutions).toHaveBeenCalledWith( - // expect.anything(), - // expect.objectContaining({ filter: [] }) - // ); + const statusButton = await waitFor(() => + getByText(filterLabels.status) + ); - // mockListNodeExecutions.mockClear(); - // fireEvent.click(nodesTab); - // await waitFor(() => mockListNodeExecutions.mock.calls.length > 0); - // // Verify (again) at least one filter is passed, after changing back to - // // nodes tab. - // expect(mockListNodeExecutions).toHaveBeenCalledWith( - // expect.anything(), - // expect.objectContaining({ - // filter: expect.arrayContaining([ - // expect.objectContaining({ key: expect.any(String) }) - // ]) - // }) - // ); - // }); + // Apply 'Failed' filter and wait for list to include only the failed item + fireEvent.click(statusButton); + const failedFilter = await waitFor(() => + screen.getByLabelText(nodeExecutionStatusFilters.failed.label) + ); + + // Wait for succeeded task to disappear and ensure failed task remains + fireEvent.click(failedFilter); + await waitFor(() => queryByText(succeededNodeName) == null); + await waitFor(() => + expect(getByText(failedNodeName)).toBeInTheDocument() + ); + + // Switch to the Graph tab + fireEvent.click(statusButton); + fireEvent.click(graphTab); + await waitFor(() => queryByText(failedNodeName) == null); + + // Switch back to Nodes Tab and verify filter still applied + fireEvent.click(nodesTab); + await waitFor(() => getByText(failedNodeName)); + expect(queryByText(succeededNodeName)).toBeNull(); + }); }); diff --git a/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx b/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx index 7b9e58d88..7e2e851b8 100644 --- a/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx +++ b/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx @@ -1,62 +1,50 @@ import { render, waitFor } from '@testing-library/react'; -import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; -import { APIContext } from 'components/data/apiContext'; import { cacheStatusMessages, viewSourceExecutionString } from 'components/Executions/constants'; -import { - NodeExecutionDetails, - NodeExecutionDisplayType -} from 'components/Executions/types'; import { Core } from 'flyteidl'; +import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; +import { insertFixture } from 'mocks/data/insertFixture'; +import { mockServer } from 'mocks/server'; import { NodeExecution, TaskNodeMetadata } from 'models'; -import { createMockNodeExecutions } from 'models/Execution/__mocks__/mockNodeExecutionsData'; import { mockExecution as mockTaskExecution } from 'models/Execution/__mocks__/mockTaskExecutionsData'; -import { listTaskExecutions } from 'models/Execution/api'; import * as React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router'; import { Routes } from 'routes'; import { makeIdentifier } from 'test/modelUtils'; +import { createTestQueryClient } from 'test/utils'; import { NodeExecutionDetailsPanelContent } from '../NodeExecutionDetailsPanelContent'; -// TODO: Update this to use MSW and re-enable -describe.skip('NodeExecutionDetails', () => { +describe('NodeExecutionDetails', () => { + let fixture: ReturnType; let execution: NodeExecution; - let details: NodeExecutionDetails; - let mockListTaskExecutions: jest.Mock>; + let queryClient: QueryClient; + beforeEach(() => { - const { executions } = createMockNodeExecutions(1); - details = { - displayType: NodeExecutionDisplayType.PythonTask, - displayId: 'com.flyte.testTask', - cacheKey: 'abcdefg' - }; - execution = executions[0]; - mockListTaskExecutions = jest.fn().mockResolvedValue({ entities: [] }); + fixture = basicPythonWorkflow.generate(); + execution = + fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + insertFixture(mockServer, fixture); + queryClient = createTestQueryClient(); }); const renderComponent = () => render( - + - + ); - it('renders displayId', async () => { - const { queryByText } = renderComponent(); - await waitFor(() => {}); - expect(queryByText(details.displayId)).toBeInTheDocument(); + it('renders name for task nodes', async () => { + const { name } = fixture.tasks.python.id; + const { getByText } = renderComponent(); + await waitFor(() => expect(getByText(name))); }); describe('with cache information', () => { @@ -72,6 +60,7 @@ describe.skip('NodeExecutionDetails', () => { } }; execution.closure.taskNodeMetadata = taskNodeMetadata; + mockServer.insertNodeExecution(execution); }); [ @@ -84,9 +73,10 @@ describe.skip('NodeExecutionDetails', () => { ].forEach(cacheStatusValue => it(`renders correct status for ${Core.CatalogCacheStatus[cacheStatusValue]}`, async () => { taskNodeMetadata.cacheStatus = cacheStatusValue; + mockServer.insertNodeExecution(execution); const { getByText } = renderComponent(); await waitFor(() => - getByText(cacheStatusMessages[cacheStatusValue]) + expect(getByText(cacheStatusMessages[cacheStatusValue])) ); }) ); diff --git a/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts b/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts index 85b32991f..3fcd53acb 100644 --- a/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts +++ b/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts @@ -1,5 +1,4 @@ import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; -import { every } from 'lodash'; import { Execution, executionSortFields, @@ -8,6 +7,8 @@ import { NodeExecution, SortDirection } from 'models'; +import { useQueryClient } from 'react-query'; +import { executionRefreshIntervalMs } from '../constants'; import { makeNodeExecutionListQuery } from '../nodeExecutionQueries'; import { executionIsTerminal, nodeExecutionIsTerminal } from '../utils'; @@ -26,11 +27,18 @@ export function useExecutionNodeViewsState( }; const shouldEnableQuery = (executions: NodeExecution[]) => - every(executions, nodeExecutionIsTerminal) && - executionIsTerminal(execution); + !executionIsTerminal(execution) || + executions.some(ne => !nodeExecutionIsTerminal(ne)); const nodeExecutionsQuery = useConditionalQuery( - makeNodeExecutionListQuery(execution.id, nodeExecutionsRequestConfig), + { + ...makeNodeExecutionListQuery( + useQueryClient(), + execution.id, + nodeExecutionsRequestConfig + ), + refetchInterval: executionRefreshIntervalMs + }, shouldEnableQuery ); diff --git a/src/components/Executions/Tables/NodeExecutionsTable.tsx b/src/components/Executions/Tables/NodeExecutionsTable.tsx index c60d39e80..1ab9e70e1 100644 --- a/src/components/Executions/Tables/NodeExecutionsTable.tsx +++ b/src/components/Executions/Tables/NodeExecutionsTable.tsx @@ -2,13 +2,10 @@ import * as classnames from 'classnames'; import { getCacheKey } from 'components/Cache'; import { DetailsPanel } from 'components/common'; import { useCommonStyles } from 'components/common/styles'; -import { WaitForQuery } from 'components/common/WaitForQuery'; import * as scrollbarSize from 'dom-helpers/util/scrollbarSize'; import { NodeExecution, NodeExecutionIdentifier } from 'models/Execution/types'; import * as React from 'react'; -import { useQuery } from 'react-query'; import { NodeExecutionDetailsPanelContent } from '../ExecutionDetails/NodeExecutionDetailsPanelContent'; -import { makeNodeExecutionQuery } from '../nodeExecutionQueries'; import { NodeExecutionsTableContext } from './contexts'; import { ExecutionsTableHeader } from './ExecutionsTableHeader'; import { generateColumns } from './nodeExecutionColumns'; @@ -61,8 +58,7 @@ export const NodeExecutionsTable: React.FC = ({ const rowProps = { selectedExecution, - setSelectedExecution, - onHeightChange: () => {} + setSelectedExecution }; const content = executionsWithKeys.length > 0 ? ( diff --git a/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx b/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx index 722786bdd..95dab4049 100644 --- a/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx +++ b/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx @@ -7,457 +7,484 @@ import { screen, waitFor } from '@testing-library/react'; -import { getCacheKey } from 'components/Cache'; -import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; -import { APIContext, APIContextValue } from 'components/data/apiContext'; -import { createQueryClient } from 'components/data/queryCache'; -import { createMockExecutionEntities } from 'components/Executions/__mocks__/createMockExecutionEntities'; -import { cacheStatusMessages } from 'components/Executions/constants'; +import { cacheStatusMessages } from 'components'; import { ExecutionContext, ExecutionContextData, NodeExecutionsRequestConfigContext } from 'components/Executions/contexts'; -import { fetchStates } from 'components/hooks'; +import { makeNodeExecutionListQuery } from 'components/Executions/nodeExecutionQueries'; +import { NodeExecutionDisplayType } from 'components/Executions/types'; +import { nodeExecutionIsTerminal } from 'components/Executions/utils'; +import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; import { Core } from 'flyteidl'; -import { cloneDeep, isEqual } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; +import { dynamicExternalSubWorkflow } from 'mocks/data/fixtures/dynamicExternalSubworkflow'; +import { + dynamicPythonNodeExecutionWorkflow, + dynamicPythonTaskWorkflow +} from 'mocks/data/fixtures/dynamicPythonWorkflow'; +import { oneFailedTaskWorkflow } from 'mocks/data/fixtures/oneFailedTaskWorkflow'; +import { insertFixture } from 'mocks/data/insertFixture'; +import { mockServer } from 'mocks/server'; import { - CompiledNode, Execution, FilterOperationName, - getTask, - getWorkflow, NodeExecution, - nodeExecutionQueryParams, RequestConfig, - TaskExecution, - TaskNodeMetadata, - Workflow, - WorkflowExecutionIdentifier + TaskNodeMetadata } from 'models'; -import { createMockExecution } from 'models/__mocks__/executionsData'; -import { - createMockTaskExecutionForNodeExecution, - createMockTaskExecutionsListResponse, - mockExecution as mockTaskExecution -} from 'models/Execution/__mocks__/mockTaskExecutionsData'; -import { - getExecution, - listNodeExecutions, - listTaskExecutionChildren, - listTaskExecutions -} from 'models/Execution/api'; import { NodeExecutionPhase } from 'models/Execution/enums'; -import { mockTasks } from 'models/Task/__mocks__/mockTaskData'; import * as React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; +import { QueryClient, QueryClientProvider, useQueryClient } from 'react-query'; import { makeIdentifier } from 'test/modelUtils'; -import { obj } from 'test/utils'; -import { Identifier } from 'typescript'; -import { State } from 'xstate'; +import { createTestQueryClient, findNearestAncestorByRole } from 'test/utils'; import { titleStrings } from '../constants'; -import { - NodeExecutionsTable, - NodeExecutionsTableProps -} from '../NodeExecutionsTable'; +import { NodeExecutionsTable } from '../NodeExecutionsTable'; -// TODO: Update this to use MSW to provide entities and re-enable tests -describe.skip('NodeExecutionsTable', () => { - let nodeExecutions: NodeExecution[]; +describe('NodeExecutionsTable', () => { + let workflowExecution: Execution; let queryClient: QueryClient; let executionContext: ExecutionContextData; let requestConfig: RequestConfig; - beforeEach(async () => { - nodeExecutions = []; + beforeEach(() => { requestConfig = {}; - queryClient = createQueryClient(); + queryClient = createTestQueryClient(); + }); - executionContext = { - execution: createMockExecution() + const shouldUpdateFn = (nodeExecutions: NodeExecution[]) => + nodeExecutions.some(ne => !nodeExecutionIsTerminal(ne)); + + const selectNode = async (container: HTMLElement, nodeId: string) => { + const nodeNameAnchor = await waitFor(() => + getByText(container, nodeId) + ); + fireEvent.click(nodeNameAnchor); + // Wait for Details Panel to render and then for the nodeId header + const detailsPanel = await waitFor(() => + screen.getByTestId('details-panel') + ); + await waitFor(() => getByText(detailsPanel, nodeId)); + return detailsPanel; + }; + + const expandParentNode = async (rowContainer: HTMLElement) => { + const expander = await waitFor(() => + getByTitle(rowContainer, titleStrings.expandRow) + ); + fireEvent.click(expander); + return await waitFor(() => getAllByRole(rowContainer, 'list')); + }; + + const TestTable = () => { + const query = useConditionalQuery( + { + ...makeNodeExecutionListQuery( + useQueryClient(), + workflowExecution.id, + requestConfig + ), + // During tests, we only want to wait for the next tick to refresh + refetchInterval: 1 + }, + shouldUpdateFn + ); + return query.data ? : null; + }; + + const renderTable = () => + render( + + + + + + + + ); + + describe('for basic executions', () => { + let fixture: ReturnType; + + beforeEach(() => { + fixture = basicPythonWorkflow.generate(); + workflowExecution = fixture.workflowExecutions.top.data; + insertFixture(mockServer, fixture); + + executionContext = { + execution: workflowExecution + }; + }); + + const updateNodeExecutions = (executions: NodeExecution[]) => { + executions.forEach(mockServer.insertNodeExecution); + mockServer.insertNodeExecutionList( + fixture.workflowExecutions.top.data.id, + executions + ); }; + + it('renders task name for task nodes', async () => { + const { getByText } = renderTable(); + await waitFor(() => + expect( + getByText(fixture.tasks.python.id.name) + ).toBeInTheDocument() + ); + }); + + it('renders NodeExecutions with no associated spec information as Unknown', async () => { + const workflowExecution = fixture.workflowExecutions.top.data; + // For a NodeExecution which has no node in the associated workflow spec and + // no task executions, we don't have a way to identify its type. + // We'll change the python NodeExecution to reference a node id which doesn't exist + // in the spec and remove its TaskExecutions. + const nodeExecution = + fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + nodeExecution.id.nodeId = 'unknownNode'; + nodeExecution.metadata = {}; + mockServer.insertNodeExecution(nodeExecution); + mockServer.insertNodeExecutionList(workflowExecution.id, [ + nodeExecution + ]); + mockServer.insertTaskExecutionList(nodeExecution.id, []); + + const { container } = renderTable(); + const pythonNodeNameEl = await waitFor(() => + getByText(container, nodeExecution.id.nodeId) + ); + const rowEl = findNearestAncestorByRole( + pythonNodeNameEl, + 'listitem' + ); + await waitFor(() => + expect(getByText(rowEl, NodeExecutionDisplayType.Unknown)) + ); + }); + + describe('for task nodes with cache status', () => { + let taskNodeMetadata: TaskNodeMetadata; + let cachedNodeExecution: NodeExecution; + beforeEach(() => { + const { nodeExecutions } = fixture.workflowExecutions.top; + const { taskExecutions } = nodeExecutions.pythonNode; + cachedNodeExecution = nodeExecutions.pythonNode.data; + taskNodeMetadata = { + cacheStatus: Core.CatalogCacheStatus.CACHE_MISS, + catalogKey: { + datasetId: makeIdentifier({ + resourceType: Core.ResourceType.DATASET + }), + sourceTaskExecution: { + ...taskExecutions.firstAttempt.data.id + } + } + }; + cachedNodeExecution.closure.taskNodeMetadata = taskNodeMetadata; + }); + + [ + Core.CatalogCacheStatus.CACHE_HIT, + Core.CatalogCacheStatus.CACHE_LOOKUP_FAILURE, + Core.CatalogCacheStatus.CACHE_POPULATED, + Core.CatalogCacheStatus.CACHE_PUT_FAILURE + ].forEach(cacheStatusValue => + it(`renders correct icon for ${Core.CatalogCacheStatus[cacheStatusValue]}`, async () => { + taskNodeMetadata.cacheStatus = cacheStatusValue; + updateNodeExecutions([cachedNodeExecution]); + const { getByTitle } = await renderTable(); + await waitFor(() => + expect( + getByTitle(cacheStatusMessages[cacheStatusValue]) + ) + ); + }) + ); + + [ + Core.CatalogCacheStatus.CACHE_DISABLED, + Core.CatalogCacheStatus.CACHE_MISS + ].forEach(cacheStatusValue => + it(`renders no icon for ${Core.CatalogCacheStatus[cacheStatusValue]}`, async () => { + taskNodeMetadata.cacheStatus = cacheStatusValue; + updateNodeExecutions([cachedNodeExecution]); + const { getByText, queryByTitle } = await renderTable(); + await waitFor(() => + getByText(cachedNodeExecution.id.nodeId) + ); + expect( + queryByTitle(cacheStatusMessages[cacheStatusValue]) + ).toBeNull(); + }) + ); + }); }); - const Table = (props: NodeExecutionsTableProps) => ( - - - - - - - - ); - - const getProps = async () => - ({ - nodeExecutions - } as NodeExecutionsTableProps); - - const renderTable = async () => { - return render(); - }; + describe('for nodes with children', () => { + describe('with isParentNode flag', () => { + let fixture: ReturnType; + beforeEach(() => { + fixture = dynamicPythonNodeExecutionWorkflow.generate(); + workflowExecution = fixture.workflowExecutions.top.data; + insertFixture(mockServer, fixture); + executionContext = { execution: workflowExecution }; + }); + + it('correctly renders children', async () => { + const { container } = renderTable(); + const dynamicTaskNameEl = await waitFor(() => + getByText(container, fixture.tasks.dynamic.id.name) + ); + const dynamicRowEl = findNearestAncestorByRole( + dynamicTaskNameEl, + 'listitem' + ); + const childContainerList = await expandParentNode(dynamicRowEl); + await waitFor(() => + expect( + getByText( + childContainerList[0], + fixture.tasks.python.id.name + ) + ) + ); + }); + + it('correctly renders groups', async () => { + const { nodeExecutions } = fixture.workflowExecutions.top; + // We returned two task execution attempts, each with children + const { container } = renderTable(); + const nodeNameEl = await waitFor(() => + getByText( + container, + nodeExecutions.dynamicNode.data.id.nodeId + ) + ); + const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); + const childGroups = await expandParentNode(rowEl); + expect(childGroups).toHaveLength(2); + }); + }); + + describe('without isParentNode flag, using taskNodeMetadata ', () => { + let fixture: ReturnType; + beforeEach(() => { + fixture = dynamicPythonTaskWorkflow.generate(); + workflowExecution = fixture.workflowExecutions.top.data; + executionContext = { + execution: workflowExecution + }; + }); + + it('correctly renders children', async () => { + // The dynamic task node should have a single child node + // which runs the basic python task. Expand it and then + // look for the python task name to verify it was rendered. + const { container } = renderTable(); + const dynamicTaskNameEl = await waitFor(() => + getByText(container, fixture.tasks.dynamic.id.name) + ); + const dynamicRowEl = findNearestAncestorByRole( + dynamicTaskNameEl, + 'listitem' + ); + const childContainerList = await expandParentNode(dynamicRowEl); + await waitFor(() => + expect( + getByText( + childContainerList[0], + fixture.tasks.python.id.name + ) + ) + ); + }); + + it('correctly renders groups', async () => { + // We returned two task execution attempts, each with children + const { container } = renderTable(); + const nodeNameEl = await waitFor(() => + getByText( + container, + fixture.workflowExecutions.top.nodeExecutions + .dynamicNode.data.id.nodeId + ) + ); + const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); + const childGroups = await expandParentNode(rowEl); + expect(childGroups).toHaveLength(2); + }); + }); + + describe('without isParentNode flag, using workflowNodeMetadata', () => { + let fixture: ReturnType; + beforeEach(() => { + fixture = dynamicExternalSubWorkflow.generate(); + insertFixture(mockServer, fixture); + workflowExecution = fixture.workflowExecutions.top.data; + executionContext = { + execution: workflowExecution + }; + }); + + it('correctly renders children', async () => { + const { container } = renderTable(); + const dynamicTaskNameEl = await waitFor(() => + getByText( + container, + fixture.tasks.generateSubWorkflow.id.name + ) + ); + const dynamicRowEl = findNearestAncestorByRole( + dynamicTaskNameEl, + 'listitem' + ); + const childContainerList = await expandParentNode(dynamicRowEl); + await waitFor(() => + expect( + getByText( + childContainerList[0], + fixture.workflows.sub.id.name + ) + ) + ); + }); + + it('correctly renders groups', async () => { + const parentNodeExecution = + fixture.workflowExecutions.top.nodeExecutions + .dynamicWorkflowGenerator.data; + // We returned a single WF execution child, so there should only + // be one child group + const { container } = renderTable(); + const nodeNameEl = await waitFor(() => + getByText(container, parentNodeExecution.id.nodeId) + ); + const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); + const childGroups = await expandParentNode(rowEl); + expect(childGroups).toHaveLength(1); + }); + }); + }); - it('is disabled', () => {}); - - // it('renders task name for task nodes', async () => { - // const { queryAllByText, getAllByRole } = await renderTable(); - // await waitFor(() => getAllByRole('listitem').length > 0); - - // const node = dataCache.getNodeForNodeExecution(mockNodeExecutions[0]); - // const taskId = node?.node.taskNode?.referenceId; - // expect(taskId).toBeDefined(); - // const task = dataCache.getTaskTemplate(taskId!); - // expect(task).toBeDefined(); - // expect(queryAllByText(task!.id.name)[0]).toBeInTheDocument(); - // }); - - // describe('for nodes with children', () => { - // let parentNodeExecution: NodeExecution; - // let childNodeExecutions: NodeExecution[]; - // beforeEach(() => { - // parentNodeExecution = mockNodeExecutions[0]; - // }); - - // const expandParentNode = async (container: HTMLElement) => { - // const expander = await waitFor(() => - // getByTitle(container, titleStrings.expandRow) - // ); - // fireEvent.click(expander); - // return await waitFor(() => getAllByRole(container, 'list')); - // }; - - // describe('with isParentNode flag', () => { - // beforeEach(() => { - // const id = parentNodeExecution.id; - // const { nodeId } = id; - // childNodeExecutions = [ - // { - // ...parentNodeExecution, - // id: { ...id, nodeId: `${nodeId}-child1` }, - // metadata: { retryGroup: '0', specNodeId: nodeId } - // }, - // { - // ...parentNodeExecution, - // id: { ...id, nodeId: `${nodeId}-child2` }, - // metadata: { retryGroup: '0', specNodeId: nodeId } - // }, - // { - // ...parentNodeExecution, - // id: { ...id, nodeId: `${nodeId}-child1` }, - // metadata: { retryGroup: '1', specNodeId: nodeId } - // }, - // { - // ...parentNodeExecution, - // id: { ...id, nodeId: `${nodeId}-child2` }, - // metadata: { retryGroup: '1', specNodeId: nodeId } - // } - // ]; - // mockNodeExecutions[0].metadata = { isParentNode: true }; - // setExecutionChildren( - // { - // id: mockExecution.id, - // parentNodeId: parentNodeExecution.id.nodeId - // }, - // childNodeExecutions - // ); - // }); - - // it('correctly fetches children', async () => { - // const { getByText } = await renderTable(); - // await waitFor(() => getByText(mockNodeExecutions[0].id.nodeId)); - // expect(mockListNodeExecutions).toHaveBeenCalledWith( - // expect.anything(), - // expect.objectContaining({ - // params: { - // [nodeExecutionQueryParams.parentNodeId]: - // parentNodeExecution.id.nodeId - // } - // }) - // ); - // expect(mockListTaskExecutionChildren).not.toHaveBeenCalled(); - // }); - - // it('does not fetch children if flag is false', async () => { - // mockNodeExecutions[0].metadata = { isParentNode: false }; - // const { getByText } = await renderTable(); - // await waitFor(() => getByText(mockNodeExecutions[0].id.nodeId)); - // expect(mockListNodeExecutions).not.toHaveBeenCalledWith( - // expect.anything(), - // expect.objectContaining({ - // params: { - // [nodeExecutionQueryParams.parentNodeId]: - // parentNodeExecution.id.nodeId - // } - // }) - // ); - // expect(mockListTaskExecutionChildren).not.toHaveBeenCalled(); - // }); - - // it('correctly renders groups', async () => { - // const { container } = await renderTable(); - // const childGroups = await expandParentNode(container); - // expect(childGroups).toHaveLength(2); - // }); - // }); - - // describe('without isParentNode flag, using taskNodeMetadata ', () => { - // let taskExecutions: TaskExecution[]; - // beforeEach(() => { - // taskExecutions = [0, 1].map(retryAttempt => - // createMockTaskExecutionForNodeExecution( - // parentNodeExecution.id, - // mockNodes[0], - // retryAttempt, - // { isParent: true } - // ) - // ); - // childNodeExecutions = [ - // { - // ...parentNodeExecution - // } - // ]; - // mockNodeExecutions = mockNodeExecutions.slice(0, 1); - // mockListTaskExecutions.mockImplementation(async id => { - // const entities = - // id.nodeId === parentNodeExecution.id.nodeId - // ? taskExecutions - // : []; - // return { entities }; - // }); - // mockListTaskExecutionChildren.mockResolvedValue({ - // entities: childNodeExecutions - // }); - // }); - - // it('correctly fetches children', async () => { - // const { getByText } = await renderTable(); - // await waitFor(() => getByText(mockNodeExecutions[0].id.nodeId)); - // expect(mockListNodeExecutions).not.toHaveBeenCalledWith( - // expect.anything(), - // expect.objectContaining({ - // params: { - // [nodeExecutionQueryParams.parentNodeId]: - // parentNodeExecution.id.nodeId - // } - // }) - // ); - // expect(mockListTaskExecutionChildren).toHaveBeenCalledWith( - // expect.objectContaining(taskExecutions[0].id), - // expect.anything() - // ); - // }); - - // it('correctly renders groups', async () => { - // // We returned two task execution attempts, each with children - // const { container } = await renderTable(); - // const childGroups = await expandParentNode(container); - // expect(childGroups).toHaveLength(2); - // }); - // }); - - // describe('without isParentNode flag, using workflowNodeMetadata', () => { - // let childExecution: Execution; - // let childNodeExecutions: NodeExecution[]; - // beforeEach(() => { - // childExecution = cloneDeep(executionContext.execution); - // childExecution.id.name = 'childExecution'; - // dataCache.insertExecution(childExecution); - // dataCache.insertWorkflowExecutionReference( - // childExecution.id, - // mockWorkflow.id - // ); - - // childNodeExecutions = cloneDeep(mockNodeExecutions); - // childNodeExecutions.forEach( - // ne => (ne.id.executionId = childExecution.id) - // ); - // mockNodeExecutions[0].closure.workflowNodeMetadata = { - // executionId: childExecution.id - // }; - // mockGetExecution.mockImplementation(async id => { - // if (isEqual(id, childExecution.id)) { - // return childExecution; - // } - // if (isEqual(id, mockExecution.id)) { - // return mockExecution; - // } - - // throw new Error( - // `Unexpected call to getExecution with execution id: ${obj( - // id - // )}` - // ); - // }); - // setExecutionChildren( - // { id: childExecution.id }, - // childNodeExecutions - // ); - // }); - - // it('correctly fetches children', async () => { - // const { getByText } = await renderTable(); - // await waitFor(() => getByText(mockNodeExecutions[0].id.nodeId)); - // expect(mockListNodeExecutions).toHaveBeenCalledWith( - // expect.objectContaining({ name: childExecution.id.name }), - // expect.anything() - // ); - // }); - - // it('correctly renders groups', async () => { - // // We returned a single WF execution child, so there should only - // // be one child group - // const { container } = await renderTable(); - // const childGroups = await expandParentNode(container); - // expect(childGroups).toHaveLength(1); - // }); - // }); - // }); - - // it('requests child node executions using configuration from context', async () => { - // const { taskExecutions } = createMockTaskExecutionsListResponse(1); - // taskExecutions[0].isParent = true; - // mockListTaskExecutions.mockResolvedValue({ entities: taskExecutions }); - // requestConfig.filter = [ - // { key: 'test', operation: FilterOperationName.EQ, value: 'test' } - // ]; - - // await renderTable(); - // await waitFor(() => - // expect(mockListTaskExecutionChildren).toHaveBeenCalled() - // ); - - // expect(mockListTaskExecutionChildren).toHaveBeenCalledWith( - // taskExecutions[0].id, - // expect.objectContaining(requestConfig) - // ); - // }); - - // describe('for task nodes with cache status', () => { - // let taskNodeMetadata: TaskNodeMetadata; - // let cachedNodeExecution: NodeExecution; - // beforeEach(() => { - // cachedNodeExecution = mockNodeExecutions[0]; - // taskNodeMetadata = { - // cacheStatus: Core.CatalogCacheStatus.CACHE_MISS, - // catalogKey: { - // datasetId: makeIdentifier({ - // resourceType: Core.ResourceType.DATASET - // }), - // sourceTaskExecution: { ...mockTaskExecution.id } - // } - // }; - // cachedNodeExecution.closure.taskNodeMetadata = taskNodeMetadata; - // }); - - // [ - // Core.CatalogCacheStatus.CACHE_HIT, - // Core.CatalogCacheStatus.CACHE_LOOKUP_FAILURE, - // Core.CatalogCacheStatus.CACHE_POPULATED, - // Core.CatalogCacheStatus.CACHE_PUT_FAILURE - // ].forEach(cacheStatusValue => - // it(`renders correct icon for ${Core.CatalogCacheStatus[cacheStatusValue]}`, async () => { - // taskNodeMetadata.cacheStatus = cacheStatusValue; - // const { getByTitle } = await renderTable(); - // await waitFor(() => - // getByTitle(cacheStatusMessages[cacheStatusValue]) - // ); - // }) - // ); - - // [ - // Core.CatalogCacheStatus.CACHE_DISABLED, - // Core.CatalogCacheStatus.CACHE_MISS - // ].forEach(cacheStatusValue => - // it(`renders no icon for ${Core.CatalogCacheStatus[cacheStatusValue]}`, async () => { - // taskNodeMetadata.cacheStatus = cacheStatusValue; - // const { getByText, queryByTitle } = await renderTable(); - // await waitFor(() => getByText(cachedNodeExecution.id.nodeId)); - // expect( - // queryByTitle(cacheStatusMessages[cacheStatusValue]) - // ).toBeNull(); - // }) - // ); - // }); - - // describe('when rendering the DetailsPanel', () => { - // beforeEach(() => { - // jest.useFakeTimers(); - // }); - // afterEach(() => { - // jest.clearAllTimers(); - // jest.useRealTimers(); - // }); - - // const selectFirstNode = async (container: HTMLElement) => { - // const { nodeId } = mockNodeExecutions[0].id; - // const nodeNameAnchor = await waitFor(() => - // getByText(container, nodeId) - // ); - // fireEvent.click(nodeNameAnchor); - // // Wait for Details Panel to render and then for the nodeId header - // const detailsPanel = await waitFor(() => - // screen.getByTestId('details-panel') - // ); - // await waitFor(() => getByText(detailsPanel, nodeId)); - // return detailsPanel; - // }; - - // it('should render updated state if selected nodeExecution object changes', async () => { - // mockNodeExecutions[0].closure.phase = NodeExecutionPhase.RUNNING; - // // Render table, click first node - // const { container, rerender } = await renderTable(); - // const detailsPanel = await selectFirstNode(container); - // await waitFor(() => getByText(detailsPanel, 'Running')); - - // mockNodeExecutions = cloneDeep(mockNodeExecutions); - // mockNodeExecutions[0].closure.phase = NodeExecutionPhase.FAILED; - // setExecutionChildren({ id: mockExecution.id }, mockNodeExecutions); - - // rerender(
); - // await waitFor(() => getByText(detailsPanel, 'Failed')); - // }); - - // describe('with child executions', () => { - // let parentNodeExecution: NodeExecution; - // let childNodeExecutions: NodeExecution[]; - // beforeEach(() => { - // parentNodeExecution = mockNodeExecutions[0]; - // const id = parentNodeExecution.id; - // const { nodeId } = id; - // childNodeExecutions = [ - // { - // ...parentNodeExecution, - // id: { ...id, nodeId: `${nodeId}-child1` }, - // metadata: { retryGroup: '0', specNodeId: nodeId } - // } - // ]; - // mockNodeExecutions[0].metadata = { isParentNode: true }; - // setExecutionChildren( - // { - // id: mockExecution.id, - // parentNodeId: parentNodeExecution.id.nodeId - // }, - // childNodeExecutions - // ); - // }); - - // it('should correctly render details for nested executions', async () => { - // const { container } = await renderTable(); - // const expander = await waitFor(() => - // getByTitle(container, titleStrings.expandRow) - // ); - // fireEvent.click(expander); - // const { nodeId } = childNodeExecutions[0].id; - // const nodeNameAnchor = await waitFor(() => - // getByText(container, nodeId) - // ); - // fireEvent.click(nodeNameAnchor); - // // Wait for Details Panel to render and then for the nodeId header - // const detailsPanel = await waitFor(() => - // screen.getByTestId('details-panel') - // ); - // await waitFor(() => getByText(detailsPanel, nodeId)); - // }); - // }); - // }); + describe('with a request filter', () => { + let fixture: ReturnType; + + beforeEach(() => { + fixture = oneFailedTaskWorkflow.generate(); + workflowExecution = fixture.workflowExecutions.top.data; + insertFixture(mockServer, fixture); + // Adding a request filter to only show failed NodeExecutions + requestConfig = { + filter: [ + { + key: 'phase', + operation: FilterOperationName.EQ, + value: NodeExecutionPhase[NodeExecutionPhase.FAILED] + } + ] + }; + const nodeExecutions = + fixture.workflowExecutions.top.nodeExecutions; + mockServer.insertNodeExecutionList( + workflowExecution.id, + [nodeExecutions.failedNode.data], + { filters: 'eq(phase,FAILED)' } + ); + + executionContext = { + execution: workflowExecution + }; + }); + + it('requests child node executions using configuration from context', async () => { + const { getByText, queryByText } = renderTable(); + const { nodeExecutions } = fixture.workflowExecutions.top; + + await waitFor(() => + expect(getByText(nodeExecutions.failedNode.data.id.nodeId)) + ); + + expect( + queryByText(nodeExecutions.pythonNode.data.id.nodeId) + ).toBeNull(); + }); + }); + + describe('when rendering the DetailsPanel', () => { + let nodeExecution: NodeExecution; + let fixture: ReturnType; + beforeEach(() => { + fixture = basicPythonWorkflow.generate(); + workflowExecution = fixture.workflowExecutions.top.data; + insertFixture(mockServer, fixture); + + executionContext = { + execution: workflowExecution + }; + nodeExecution = + fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + }); + + const updateNodeExecutions = (executions: NodeExecution[]) => { + executions.forEach(mockServer.insertNodeExecution); + mockServer.insertNodeExecutionList( + fixture.workflowExecutions.top.data.id, + executions + ); + }; + + it('should render updated state if selected nodeExecution object changes', async () => { + nodeExecution.closure.phase = NodeExecutionPhase.RUNNING; + updateNodeExecutions([nodeExecution]); + // Render table, click first node + const { container } = renderTable(); + const detailsPanel = await selectNode( + container, + nodeExecution.id.nodeId + ); + expect(getByText(detailsPanel, 'Running')).toBeInTheDocument(); + + const updatedExecution = cloneDeep(nodeExecution); + updatedExecution.closure.phase = NodeExecutionPhase.FAILED; + updateNodeExecutions([updatedExecution]); + await waitFor(() => expect(getByText(detailsPanel, 'Failed'))); + }); + + describe('with nested children', () => { + let fixture: ReturnType; + beforeEach(() => { + fixture = dynamicPythonNodeExecutionWorkflow.generate(); + workflowExecution = fixture.workflowExecutions.top.data; + insertFixture(mockServer, fixture); + executionContext = { execution: workflowExecution }; + }); + + it('should correctly render details for nested executions', async () => { + const childNodeExecution = + fixture.workflowExecutions.top.nodeExecutions.dynamicNode + .nodeExecutions.firstChild.data; + const { container } = renderTable(); + const dynamicTaskNameEl = await waitFor(() => + getByText(container, fixture.tasks.dynamic.id.name) + ); + const dynamicRowEl = findNearestAncestorByRole( + dynamicTaskNameEl, + 'listitem' + ); + await expandParentNode(dynamicRowEl); + await selectNode(container, childNodeExecution.id.nodeId); + + // Wait for Details Panel to render and then for the nodeId header + const detailsPanel = await waitFor(() => + screen.getByTestId('details-panel') + ); + await waitFor(() => expect(getByText(detailsPanel, childNodeExecution.id.nodeId))); + expect(getByText(detailsPanel, fixture.tasks.python.id.name)).toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx b/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx index 95df36577..9917eee10 100644 --- a/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx +++ b/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx @@ -12,6 +12,7 @@ import { TaskExecution } from 'models'; import * as React from 'react'; +import { useQueryClient } from 'react-query'; import { nodeExecutionIsTerminal } from '..'; import { NodeExecutionsRequestConfigContext } from '../contexts'; import { ExecutionFilters } from '../ExecutionFilters'; @@ -68,7 +69,7 @@ export const TaskExecutionNodes: React.FC = ({ taskExecutionIsTerminal(taskExecution); const nodeExecutionsQuery = useConditionalQuery( - makeTaskExecutionChildListQuery(taskExecution.id, requestConfig), + makeTaskExecutionChildListQuery(useQueryClient(), taskExecution.id, requestConfig), shouldEnableQuery ); diff --git a/src/components/Executions/nodeExecutionQueries.ts b/src/components/Executions/nodeExecutionQueries.ts index e545adbb5..4cbe25911 100644 --- a/src/components/Executions/nodeExecutionQueries.ts +++ b/src/components/Executions/nodeExecutionQueries.ts @@ -1,7 +1,7 @@ import { QueryType } from 'components/data/queries'; import { QueryInput } from 'components/data/types'; import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; -import { isEqual, some } from 'lodash'; +import { isEqual } from 'lodash'; import { endNodeId, getNodeExecution, @@ -51,14 +51,31 @@ export function fetchNodeExecution( return queryClient.fetchQuery(makeNodeExecutionQuery(id)); } +// On successful node execution list queries, extract and store all +// executions so they are individually fetchable from the cache. +function cacheNodeExecutions( + queryClient: QueryClient, + nodeExecutions: NodeExecution[] +) { + nodeExecutions.forEach(ne => + queryClient.setQueryData([QueryType.NodeExecution, ne.id], ne) + ); +} + export function makeNodeExecutionListQuery( + queryClient: QueryClient, id: WorkflowExecutionIdentifier, config?: RequestConfig ): QueryInput { return { queryKey: [QueryType.NodeExecutionList, id, config], - queryFn: async () => - removeSystemNodes((await listNodeExecutions(id, config)).entities) + queryFn: async () => { + const nodeExecutions = removeSystemNodes( + (await listNodeExecutions(id, config)).entities + ); + cacheNodeExecutions(queryClient, nodeExecutions); + return nodeExecutions; + } }; } @@ -67,30 +84,30 @@ export function fetchNodeExecutionList( id: WorkflowExecutionIdentifier, config?: RequestConfig ) { - return queryClient.fetchQuery(makeNodeExecutionListQuery(id, config)); -} - -export function useNodeExecutionListQuery( - id: WorkflowExecutionIdentifier, - config: RequestConfig -) { - return useConditionalQuery( - makeNodeExecutionListQuery(id, config), - // todo: Refresh node executions on interval while parent is non-terminal - () => true + return queryClient.fetchQuery( + makeNodeExecutionListQuery(queryClient, id, config) ); } export function makeTaskExecutionChildListQuery( + queryClient: QueryClient, id: TaskExecutionIdentifier, config?: RequestConfig ): QueryInput { return { queryKey: [QueryType.TaskExecutionChildList, id, config], - queryFn: async () => - removeSystemNodes( + queryFn: async () => { + const nodeExecutions = removeSystemNodes( (await listTaskExecutionChildren(id, config)).entities - ) + ); + cacheNodeExecutions(queryClient, nodeExecutions); + return nodeExecutions; + }, + onSuccess: nodeExecutions => { + nodeExecutions.forEach(ne => + queryClient.setQueryData([QueryType.NodeExecution, ne.id], ne) + ); + } }; } @@ -99,7 +116,9 @@ export function fetchTaskExecutionChildList( id: TaskExecutionIdentifier, config?: RequestConfig ) { - return queryClient.fetchQuery(makeTaskExecutionChildListQuery(id, config)); + return queryClient.fetchQuery( + makeTaskExecutionChildListQuery(queryClient, id, config) + ); } /** --- Queries for fetching children of a NodeExecution --- **/ @@ -263,8 +282,8 @@ export function useChildNodeExecutionGroupsQuery( if (!nodeExecutionIsTerminal(nodeExecution)) { return true; } - return some(groups, group => - some(group.nodeExecutions, ne => !nodeExecutionIsTerminal(ne)) + return groups.some( group => + group.nodeExecutions.some(ne => !nodeExecutionIsTerminal(ne)) ); }; diff --git a/src/components/Workflow/workflowQueries.ts b/src/components/Workflow/workflowQueries.ts index af2cf5c55..e5d935d54 100644 --- a/src/components/Workflow/workflowQueries.ts +++ b/src/components/Workflow/workflowQueries.ts @@ -1,12 +1,22 @@ import { QueryType } from 'components/data/queries'; import { QueryInput } from 'components/data/types'; +import { extractTaskTemplates } from 'components/hooks/utils'; import { getWorkflow, Workflow, WorkflowId } from 'models'; import { QueryClient } from 'react-query'; -export function makeWorkflowQuery(id: WorkflowId): QueryInput { +export function makeWorkflowQuery(queryClient: QueryClient, id: WorkflowId): QueryInput { return { queryKey: [QueryType.Workflow, id], - queryFn: () => getWorkflow(id), + queryFn: async () => { + const workflow = await getWorkflow(id); + // On successful workflow fetch, extract and cache all task templates + // stored on the workflow so that we don't need to fetch them separately + // if future queries reference them. + extractTaskTemplates(workflow).forEach(task => + queryClient.setQueryData([QueryType.TaskTemplate, task.id], task) + ); + return workflow; + }, // `Workflow` objects (individual versions) are immutable and safe to // cache indefinitely once retrieved in full staleTime: Infinity @@ -14,5 +24,5 @@ export function makeWorkflowQuery(id: WorkflowId): QueryInput { } export async function fetchWorkflow(queryClient: QueryClient, id: WorkflowId) { - return queryClient.fetchQuery(makeWorkflowQuery(id)); + return queryClient.fetchQuery(makeWorkflowQuery(queryClient, id)); } diff --git a/src/components/common/WaitForQuery.tsx b/src/components/common/WaitForQuery.tsx index 1b57883f7..d7d532f42 100644 --- a/src/components/common/WaitForQuery.tsx +++ b/src/components/common/WaitForQuery.tsx @@ -36,7 +36,6 @@ export const WaitForQuery = ({ return null; } case 'loading': { - // TODO: return null; } case 'success': { @@ -57,7 +56,7 @@ export const WaitForQuery = ({ const error = query.error || new Error('Unknown failure'); return ErrorComponent ? ( - ) : null; + ) : ; } default: log.error(`Unexpected query status value: ${status}`); diff --git a/src/components/data/queryCache.ts b/src/components/data/queryCache.ts index 6e2b69153..43d20a4d0 100644 --- a/src/components/data/queryCache.ts +++ b/src/components/data/queryCache.ts @@ -1,11 +1,11 @@ import { NotAuthorizedError, NotFoundError } from 'errors/fetchErrors'; import { + DefaultOptions, hashQueryKey, QueryCache, QueryClient, QueryKeyHashFunction } from 'react-query'; -import { attachQueryObservers } from './queryObservers'; import { normalizeQueryKey } from './utils'; const allowedFailures = 3; @@ -21,19 +21,17 @@ function isErrorRetryable(error: any) { const queryKeyHashFn: QueryKeyHashFunction = queryKey => hashQueryKey(normalizeQueryKey(queryKey)); -export function createQueryClient() { +export function createQueryClient(options?: Partial) { const queryCache = new QueryCache(); - return attachQueryObservers( - new QueryClient({ - queryCache, - defaultOptions: { - queries: { - queryKeyHashFn, - retry: (failureCount, error) => - failureCount < allowedFailures && - isErrorRetryable(error) - } - } - }) - ); + return new QueryClient({ + queryCache, + defaultOptions: { + queries: { + queryKeyHashFn, + retry: (failureCount, error) => + failureCount < allowedFailures && isErrorRetryable(error) + }, + ...options + } + }); } diff --git a/src/components/data/queryObservers.ts b/src/components/data/queryObservers.ts deleted file mode 100644 index d1133da0f..000000000 --- a/src/components/data/queryObservers.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { extractTaskTemplates } from 'components/hooks/utils'; -import { NodeExecution } from 'models/Execution/types'; -import { Workflow } from 'models/Workflow/types'; -import { Query, QueryClient, QueryKey } from 'react-query'; -import { QueryType } from './queries'; - -function isQueryType(queryKey: QueryKey, queryType: QueryType) { - return Array.isArray(queryKey) && queryKey[0] === queryType; -} - -function handleWorkflowQuery(query: Query, queryClient: QueryClient) { - if (query.state.status !== 'success' || query.state.data == null) { - return; - } - - extractTaskTemplates(query.state.data).forEach(task => - queryClient.setQueryData([QueryType.TaskTemplate, task.id], task) - ); -} - -function handleNodeExecutionListQuery( - query: Query, - queryClient: QueryClient -) { - if (query.state.status !== 'success' || query.state.data == null) { - return; - } - - // On successful node execution list queries, extract and store all - // executions so they are individually fetchable from the cache. - query.state.data.forEach(ne => - queryClient.setQueryData([QueryType.NodeExecution, ne.id], ne) - ); -} - -export function attachQueryObservers(queryClient: QueryClient): QueryClient { - queryClient.getQueryCache().subscribe(query => { - if (!query) { - return; - } - if (isQueryType(query.queryKey, QueryType.Workflow)) { - handleWorkflowQuery(query as Query, queryClient); - } - if ( - isQueryType(query.queryKey, QueryType.NodeExecutionList) || - isQueryType(query.queryKey, QueryType.TaskExecutionChildList) - ) { - handleNodeExecutionListQuery( - query as Query, - queryClient - ); - } - }); - - return queryClient; -} diff --git a/src/mocks/createAdminServer.ts b/src/mocks/createAdminServer.ts new file mode 100644 index 000000000..39da839f1 --- /dev/null +++ b/src/mocks/createAdminServer.ts @@ -0,0 +1,507 @@ +import { Admin } from 'flyteidl'; +import { + adminApiUrl, + EncodableType, + encodeProtoPayload, + Execution, + Identifier, + limits, + NodeExecution, + NodeExecutionIdentifier, + Project, + ResourceType, + Task, + TaskExecution, + TaskExecutionIdentifier, + Workflow, + WorkflowExecutionIdentifier +} from 'models'; +import { MockedRequest, RequestParams, ResponseResolver, rest } from 'msw'; +import { RestContext } from 'msw/lib/types/rest'; +import { RequestHandlersList } from 'msw/lib/types/setupWorker/glossary'; +import { DefaultRequestBodyType } from 'msw/lib/types/utils/handlers/requestHandler'; +import { obj } from 'test/utils'; +import { stableStringify } from './utils'; + +const taskExecutionPath = + '/task_executions/:project/:domain/:name/:nodeId/:taskProject/:taskDomain/:taskName/:taskVersion/:retryAttempt'; + +function isValidIdentifier(id: Partial): id is Identifier { + return !!id.project && !!id.domain && !!id.name && !!id.version; +} + +function nodeExecutionListQueryParams(params: QueryParamsMap): QueryParamsMap { + return { limit: `${limits.NONE}`, ...params }; +} + +function makeFullIdentifier( + id: Partial, + resourceType: ResourceType +): Required { + if (!isValidIdentifier(id)) { + throw new Error(`Received incomplete Identifier: ${id}`); + } + return { ...id, resourceType }; +} + +function workflowIdentifier(id: Partial) { + return makeFullIdentifier(id, ResourceType.WORKFLOW); +} + +function taskIdentifier(id: Partial) { + return makeFullIdentifier(id, ResourceType.TASK); +} + +function normalizeTaskExecutionIdentifier( + id: TaskExecutionIdentifier +): TaskExecutionIdentifier { + return { ...id, taskId: taskIdentifier(id.taskId) }; +} + +function protobufResponse( + ctx: RestContext, + data: unknown, + encodeType: EncodableType +) { + const buffer = encodeProtoPayload(data, encodeType); + const contentLength = buffer.byteLength.toString(); + return [ + ctx.set('Content-Type', 'application/octet-stream'), + ctx.set('Content-Length', contentLength), + ctx.body(buffer) + ]; +} + +function getItemKey(id: unknown) { + return stableStringify(id); +} + +enum RequestErrorType { + NotFound = 404, + Unexpected = 500 +} +class RequestError extends Error { + constructor(public type: RequestErrorType, message?: string) { + super(message); + } +} + +function notFoundError(id: unknown): RequestError { + return new RequestError( + RequestErrorType.NotFound, + `Couldn't find item: ${obj(id)}` + ); +} + +function unexpectedError(message: string): RequestError { + return new RequestError(RequestErrorType.Unexpected, message); +} + +function getItem(store: Map, id: unknown): ItemType { + const item = store.get(getItemKey(id)); + if (!item) { + throw notFoundError(id); + } + return item as ItemType; +} + +function insertItem(store: Map, id: unknown, value: unknown) { + return store.set(getItemKey(id), value); +} + +type RestRequest = MockedRequest; +type RestResolver = ResponseResolver< + MockedRequest, + RestContext, + any +>; +type QueryParamsMap = Record; + +function getQueryParams(req: RestRequest): QueryParamsMap { + return Array.from(req.url.searchParams.entries()).reduce( + (out, [key, value]) => ({ ...out, [key]: value }), + {} + ); +} + +function catchResponseErrors(resolver: RestResolver): RestResolver { + return (req, res, ctx) => { + try { + return resolver(req, res, ctx); + } catch (e) { + if (e instanceof RequestError) { + return res(ctx.status(e.type), ctx.text(e.message)); + } else + return res(ctx.status(500), ctx.text(`Unexpected error: ${e}`)); + } + }; +} + +interface AdminEntityHandlerConfig { + path: string; + getDataForRequest(req: RestRequest): DataType; + responseEncoder: EncodableType; +} +function adminEntityHandler({ + path, + getDataForRequest, + responseEncoder +}: AdminEntityHandlerConfig) { + return rest.get( + adminApiUrl(path), + catchResponseErrors((req, res, ctx) => + res( + ...protobufResponse( + ctx, + getDataForRequest(req), + responseEncoder + ) + ) + ) + ); +} + +type RequireIdField = Omit & + Pick, 'id'>; + +/** A mock implementation of the Admin API server. Contains functions for inserting + * mock data of various types. Paths for inserted entities are automatically determined + * based on the parameters provided (usually the `id` field). + */ +export interface AdminServer { + /** Debug utility which dumps the contents of the backing store. */ + printEntities(): void; + /** Insert a single NodeExecution. */ + insertNodeExecution(data: RequireIdField>): void; + /** Insert a list of NodeExecutions to be returned for a WorkflowExecution. + * Note: Does not add handlers for single NodeExecutions. Those must be inserted + * separately. + */ + insertNodeExecutionList( + id: WorkflowExecutionIdentifier, + data: RequireIdField>[], + query?: Record + ): void; + /** Insert the global list of projects. Overwrites the existing list. */ + insertProjects(data: RequireIdField>[]): void; + /** Insert a single `Task` record. */ + insertTask(data: RequireIdField>): void; + /** Insert a single `TaskExecution` record. */ + insertTaskExecution(data: RequireIdField>): void; + /** Insert a list of `TaskExecution` records to be returned for a parent + * `NodeExecution`. + * Note: Does not insert single `TaskExecution` records. Those must be inserted + * separately. + */ + insertTaskExecutionList( + id: NodeExecutionIdentifier, + data: RequireIdField>[] + ): void; + /** Inserts a list of `NodeExecution` records to be returned for a parent + * `TaskExecution`. + * Note: Does not insert single `NodeExecution` records. Those must be inserted + * separately. + */ + insertTaskExecutionChildList( + id: TaskExecutionIdentifier, + data: RequireIdField>[] + ): void; + /** Inserts a single `Workflow` record. */ + insertWorkflow(data: RequireIdField>): void; + /** Inserts a single `WorkflowExecution` record. */ + insertWorkflowExecution(data: RequireIdField>): void; +} + +export interface CreateAdminServerResult { + /** handlers to be inserted into mock-service-worker's `setupServer` function. */ + handlers: RequestHandlersList; + /** The resulting `AdminServer` object, used for inserting mock data. */ + server: AdminServer; +} + +enum EntityType { + ProjectList = 'projectList', + Workflow = 'workflow', + Task = 'task', + WorkflowExecution = 'workflowExecution', + NodeExecution = 'nodeExecution', + NodeExecutionList = 'nodeExecutionList', + TaskExecution = 'taskExecution', + TaskExecutionList = 'taskExecutionList', + TaskExecutionChildList = 'taskExecutionChildList' +} + +function taskExecutionIdFromParams({ + project, + domain, + name, + nodeId, + taskProject, + taskDomain, + taskName, + taskVersion, + retryAttempt +}: RequestParams): TaskExecutionIdentifier { + return { + retryAttempt: Number.parseInt(retryAttempt, 10), + nodeExecutionId: { + nodeId, + executionId: { project, domain, name } + }, + taskId: taskIdentifier({ + project: taskProject, + domain: taskDomain, + name: taskName, + version: taskVersion + }) + }; +} + +/** Creates an instance of an Admin API mock server with an empty backing store. */ +export function createAdminServer(): CreateAdminServerResult { + const entityMap: Map = new Map(); + const getWorkflowHandler = adminEntityHandler({ + path: '/workflows/:project/:domain/:name/:version', + getDataForRequest: req => { + const { project, domain, name, version } = req.params; + const id = workflowIdentifier({ + project, + domain, + name, + version + }); + return getItem(entityMap, [EntityType.Workflow, id]); + }, + responseEncoder: Admin.Workflow + }); + + const getTaskHandler = adminEntityHandler({ + path: '/tasks/:project/:domain/:name/:version', + getDataForRequest: req => { + const { project, domain, name, version } = req.params; + const id = taskIdentifier({ + project, + domain, + name, + version + }); + return getItem(entityMap, [EntityType.Task, id]); + }, + responseEncoder: Admin.Task + }); + + const getProjectListHandler = adminEntityHandler({ + path: '/projects', + getDataForRequest: () => { + const data = getItem(entityMap, EntityType.ProjectList); + return { projects: data.map(Admin.Project.create) }; + }, + responseEncoder: Admin.Projects + }); + + const getWorkflowExecutionHandler = adminEntityHandler({ + path: '/executions/:project/:domain/:name', + getDataForRequest: req => { + const { project, domain, name } = req.params; + const id: WorkflowExecutionIdentifier = { project, domain, name }; + return getItem(entityMap, [EntityType.WorkflowExecution, id]); + }, + responseEncoder: Admin.Execution + }); + + const getNodeExecutionListHandler = adminEntityHandler({ + path: '/node_executions/:project/:domain/:name', + getDataForRequest: req => { + const { project, domain, name } = req.params; + const id: WorkflowExecutionIdentifier = { project, domain, name }; + const data = getItem(entityMap, [ + EntityType.NodeExecutionList, + id, + nodeExecutionListQueryParams(getQueryParams(req)) + ]); + return { + nodeExecutions: data.map(nodeExecutionId => + Admin.NodeExecution.create( + getItem(entityMap, [ + EntityType.NodeExecution, + nodeExecutionId + ]) + ) + ) + }; + }, + responseEncoder: Admin.NodeExecutionList + }); + + const getNodeExecutionHandler = adminEntityHandler({ + path: '/node_executions/:project/:domain/:name/:nodeId', + getDataForRequest: req => { + const { project, domain, name, nodeId } = req.params; + const id: NodeExecutionIdentifier = { + nodeId, + executionId: { project, domain, name } + }; + return getItem(entityMap, [EntityType.NodeExecution, id]); + }, + responseEncoder: Admin.NodeExecution + }); + + const getTaskExecutionHandler = adminEntityHandler({ + path: taskExecutionPath, + getDataForRequest: req => { + const id = taskExecutionIdFromParams(req.params); + return getItem(entityMap, [EntityType.TaskExecution, id]); + }, + responseEncoder: Admin.TaskExecution + }); + + const getTaskExecutionListHandler = adminEntityHandler({ + path: '/task_executions/:project/:domain/:name/:nodeId', + getDataForRequest: req => { + const { project, domain, name, nodeId } = req.params; + const id: NodeExecutionIdentifier = { + executionId: { project, domain, name }, + nodeId + }; + const data = getItem(entityMap, [ + EntityType.TaskExecutionList, + id + ]); + return { + taskExecutions: data.map(taskExecutionId => { + try { + const execution = getItem( + entityMap, + [ + EntityType.TaskExecution, + normalizeTaskExecutionIdentifier( + taskExecutionId + ) + ] + ); + return Admin.TaskExecution.create(execution); + } catch (e) { + throw unexpectedError( + `Unexpected missing child item: ${obj( + taskExecutionId + )}` + ); + } + }) + }; + }, + responseEncoder: Admin.TaskExecutionList + }); + + const getTaskExecutionChildListHandler = adminEntityHandler({ + path: `/children${taskExecutionPath}`, + getDataForRequest: req => { + const id = taskExecutionIdFromParams(req.params); + const data = getItem(entityMap, [ + EntityType.TaskExecutionChildList, + id + ]); + return { + nodeExecutions: data.map(nodeExecutionId => { + try { + const execution = getItem( + entityMap, + [EntityType.NodeExecution, nodeExecutionId] + ); + return Admin.NodeExecution.create(execution); + } catch (e) { + throw unexpectedError( + `Unexpected missing child item: ${obj( + nodeExecutionId + )}` + ); + } + }) + }; + }, + responseEncoder: Admin.NodeExecutionList + }); + + return { + handlers: [ + getProjectListHandler, + getWorkflowHandler, + getTaskHandler, + getWorkflowExecutionHandler, + getNodeExecutionHandler, + getNodeExecutionListHandler, + getTaskExecutionHandler, + getTaskExecutionListHandler, + getTaskExecutionChildListHandler + ], + server: { + printEntities: () => console.log(Array.from(entityMap.entries())), + insertProjects: projects => + insertItem(entityMap, EntityType.ProjectList, projects), + insertTask: task => + insertItem( + entityMap, + [EntityType.Task, taskIdentifier(task.id)], + task + ), + insertWorkflow: workflow => + insertItem( + entityMap, + [EntityType.Workflow, workflowIdentifier(workflow.id)], + workflow + ), + insertWorkflowExecution: execution => + insertItem( + entityMap, + [EntityType.WorkflowExecution, execution.id], + execution + ), + insertNodeExecution: execution => + insertItem( + entityMap, + [EntityType.NodeExecution, execution.id], + execution + ), + insertNodeExecutionList: ( + parentExecutionId, + executions, + query: QueryParamsMap = {} + ) => + insertItem( + entityMap, + [ + EntityType.NodeExecutionList, + parentExecutionId, + nodeExecutionListQueryParams(query) + ], + executions.map(({ id }) => id) + ), + insertTaskExecution: execution => + insertItem( + entityMap, + [ + EntityType.TaskExecution, + normalizeTaskExecutionIdentifier(execution.id) + ], + execution + ), + insertTaskExecutionList: (parentExecutionId, executions) => + insertItem( + entityMap, + [EntityType.TaskExecutionList, parentExecutionId], + executions.map(({ id }) => + normalizeTaskExecutionIdentifier(id) + ) + ), + insertTaskExecutionChildList: (parentExecutionId, executions) => + insertItem( + entityMap, + [ + EntityType.TaskExecutionChildList, + normalizeTaskExecutionIdentifier(parentExecutionId) + ], + executions.map(({ id }) => id) + ) + } + }; +} diff --git a/src/mocks/data/constants.ts b/src/mocks/data/constants.ts index ae51186ac..47c27cfc1 100644 --- a/src/mocks/data/constants.ts +++ b/src/mocks/data/constants.ts @@ -1,6 +1,36 @@ +import { dateToTimestamp } from 'common/utils'; +import { timeStampOffset } from 'mocks/utils'; + // Arbitrary start date used as a basis for generating timestamps // to keep the mocked data consistent across runs export const mockStartDate = new Date('2020-11-15T02:32:19.610Z'); +// entities will be created an hour before any executions have run +export const entityCreationDate = timeStampOffset( + dateToTimestamp(mockStartDate), + -3600 +); + // Workflow Execution duration in milliseconds -export const defaultWorkflowExecutionDuration = 1000 * 60 * 60 * 1.251; +export const defaultExecutionDuration = 1000 * 60 * 60 * 1.251; + +export const dataUriPrefix = 's3://flytedata'; +export const emptyInputUri = `${dataUriPrefix}/empty/inputs.pb`; +export const emptyOutputUri = `${dataUriPrefix}/empty/outputs.pb`; + +export const testProject = 'flytetest'; +export const testDomain = 'development'; + +export const testVersions = { + v1: 'v0001', + v2: 'v0002' +}; + +export const variableNames = { + basicString: 'basicString' +}; + +export const nodeIds = { + dynamicTask: 'dynamicTask', + pythonTask: 'pythonTask' +}; diff --git a/src/mocks/data/fixtures/basicPythonWorkflow.ts b/src/mocks/data/fixtures/basicPythonWorkflow.ts new file mode 100644 index 000000000..bbb75f2e6 --- /dev/null +++ b/src/mocks/data/fixtures/basicPythonWorkflow.ts @@ -0,0 +1,127 @@ +import { cloneDeep } from 'lodash'; +import { startNodeId, endNodeId } from 'models'; +import { SimpleType } from 'models/Common/types'; +import { variableNames } from '../constants'; +import { + generateTask, + generateWorkflow, + generateExecutionForWorkflow, + generateNodeExecution, + generateTaskExecution +} from '../generators'; +import { taskNodeIds, bindingFromNode, makeDefaultLaunchPlan } from '../utils'; + +const workflowName = 'BasicPythonWorkflow'; +const taskName = `${workflowName}.PythonTask`; +const pythonNodeId = 'pythonNode'; + +function generate() { + const pythonTask = generateTask( + { name: taskName }, + { + template: { + type: 'python-task', + interface: { + inputs: { + variables: { + [variableNames.basicString]: { + description: + 'A string which will be echoed to output', + type: { simple: SimpleType.STRING } + } + } + }, + outputs: { + variables: { + [variableNames.basicString]: { + description: + 'A copy of the string provided to this task', + type: { simple: SimpleType.STRING } + } + } + } + } + } + } + ); + const workflow = generateWorkflow( + { name: workflowName }, + { + closure: { + compiledWorkflow: { + primary: { + connections: { + downstream: { + [startNodeId]: { ids: [pythonNodeId] }, + [pythonNodeId]: { ids: [endNodeId] } + }, + upstream: { + [pythonNodeId]: { ids: [startNodeId] }, + [endNodeId]: { ids: [pythonNodeId] } + } + }, + template: { + // This workflow has just one task, so the i/o will be those from + // the task + interface: cloneDeep( + pythonTask.closure.compiledTask.template + .interface + ), + nodes: [ + { + ...taskNodeIds(pythonNodeId, pythonTask), + inputs: [ + bindingFromNode( + variableNames.basicString, + startNodeId, + variableNames.basicString + ) + ] + } + ], + outputs: [ + bindingFromNode( + variableNames.basicString, + pythonNodeId, + variableNames.basicString + ) + ] + } + }, + tasks: [pythonTask.closure.compiledTask] + } + } + } + ); + + const launchPlan = makeDefaultLaunchPlan(workflow); + const execution = generateExecutionForWorkflow(workflow, launchPlan); + const pythonNodeExecution = generateNodeExecution(execution, pythonNodeId); + const pythonTaskExecution = generateTaskExecution( + pythonNodeExecution, + pythonTask + ); + return { + launchPlans: { top: launchPlan }, + tasks: { python: pythonTask }, + workflows: { top: workflow }, + workflowExecutions: { + top: { + data: execution, + nodeExecutions: { + pythonNode: { + data: pythonNodeExecution, + taskExecutions: { + firstAttempt: { data: pythonTaskExecution } + } + } + } + } + } + }; +} + +/** This workflow has a single python node which takes a string as input + * and copies it to the output. + */ +export const basicPythonWorkflow = { generate }; diff --git a/src/mocks/data/fixtures/dynamicExternalSubworkflow.ts b/src/mocks/data/fixtures/dynamicExternalSubworkflow.ts new file mode 100644 index 000000000..ca11ff6e3 --- /dev/null +++ b/src/mocks/data/fixtures/dynamicExternalSubworkflow.ts @@ -0,0 +1,204 @@ +import { cloneDeep } from 'lodash'; +import { endNodeId, startNodeId } from 'models'; +import { generateTask, generateWorkflow, generateExecutionForWorkflow, generateNodeExecution, generateTaskExecution } from '../generators'; +import { makeDefaultLaunchPlan, taskNodeIds } from '../utils'; + +const topWorkflowName = 'LaunchExternalSubWorkflow'; +const subWorkflowName = `${topWorkflowName}.SubWorkflow`; +const nodeIds = { + subWorkflow: 'subWorkflowNode', + python: 'pythonNode' +}; +const subWorkflowTaskName = `${topWorkflowName}.LaunchSubworkflowTask`; + +function generate() { + const launchSubWorkflowTask = generateTask( + { name: subWorkflowTaskName }, + { template: { type: 'dynamic-task' } } + ); + const topWorkflow = generateWorkflow( + { name: topWorkflowName }, + { + closure: { + compiledWorkflow: { + primary: { + connections: { + downstream: { + [startNodeId]: { + ids: [nodeIds.subWorkflow] + }, + [nodeIds.subWorkflow]: { ids: [endNodeId] } + }, + upstream: { + [nodeIds.subWorkflow]: { + ids: [startNodeId] + }, + [endNodeId]: { ids: [nodeIds.subWorkflow] } + } + }, + template: { + nodes: [ + { + ...taskNodeIds( + nodeIds.subWorkflow, + launchSubWorkflowTask + ), + inputs: [] + } + ] + } + }, + tasks: [ + cloneDeep(launchSubWorkflowTask.closure.compiledTask) + ] + } + } + } + ); + + const topWorkflowLaunchPlan = makeDefaultLaunchPlan(topWorkflow); + const topExecution = generateExecutionForWorkflow( + topWorkflow, + topWorkflowLaunchPlan + ); + + const topNodeExecution = generateNodeExecution( + topExecution, + 'dynamicWorkflowChild', + { + metadata: { + specNodeId: nodeIds.subWorkflow + } + } + ); + + const topTaskExecution = generateTaskExecution( + topNodeExecution, + launchSubWorkflowTask, + { isParent: true } + ); + + const subWorkflowPythonTask = generateTask( + { name: `${topWorkflowName}.PythonTask` }, + { template: { type: 'python-task' } } + ); + + const subWorkflow = generateWorkflow( + { name: subWorkflowName }, + { + closure: { + compiledWorkflow: { + primary: { + connections: { + downstream: { + [startNodeId]: { ids: [nodeIds.python] }, + [nodeIds.python]: { ids: [endNodeId] } + }, + upstream: { + [nodeIds.python]: { ids: [startNodeId] }, + [endNodeId]: { ids: [nodeIds.python] } + } + }, + template: { + nodes: [ + { + ...taskNodeIds( + nodeIds.python, + subWorkflowPythonTask + ), + inputs: [] + } + ] + } + }, + tasks: [subWorkflowPythonTask.closure.compiledTask] + } + } + } + ); + const subWorkflowLaunchPlan = makeDefaultLaunchPlan(subWorkflow); + + const subWorkflowExecution = generateExecutionForWorkflow( + subWorkflow, + subWorkflowLaunchPlan, + { spec: { metadata: { nesting: 1 } } } + ); + const pythonNodeExecution = generateNodeExecution( + subWorkflowExecution, + 'pythonNode', + { + metadata: { + specNodeId: nodeIds.python + } + } + ); + const pythonTaskExecution = generateTaskExecution( + pythonNodeExecution, + subWorkflowPythonTask + ); + + const launchSubWorkflowNodeExecution = generateNodeExecution( + topExecution, + 'launchSubWorkflow', + { + closure: { + workflowNodeMetadata: { + executionId: subWorkflowExecution.id + } + } + } + ); + + return { + launchPlans: { + top: topWorkflowLaunchPlan, + subWorkflow: subWorkflowLaunchPlan + }, + tasks: { + generateSubWorkflow: launchSubWorkflowTask, + pythonTask: subWorkflowPythonTask + }, + workflows: { + top: topWorkflow, + sub: subWorkflow + }, + workflowExecutions: { + top: { + data: topExecution, + nodeExecutions: { + dynamicWorkflowGenerator: { + data: topNodeExecution, + taskExecutions: { + firstAttempt: { + data: topTaskExecution, + nodeExecutions: { + launchSubWorkflow: { + data: launchSubWorkflowNodeExecution + } + } + } + } + } + } + }, + sub: { + data: subWorkflowExecution, + nodeExecutions: { + pythonNode: { + data: pythonNodeExecution, + taskExecutions: { + firstAttempt: { data: pythonTaskExecution } + } + } + } + } + } + }; +} + +/** + * A workflow with one dynamic task node which will yield an additional node at + * runtime. The child node will launch a separate workflow execution referencing + * our basic python workflow. + */ +export const dynamicExternalSubWorkflow = { generate }; diff --git a/src/mocks/data/fixtures/dynamicPythonWorkflow.ts b/src/mocks/data/fixtures/dynamicPythonWorkflow.ts new file mode 100644 index 000000000..45812ebb9 --- /dev/null +++ b/src/mocks/data/fixtures/dynamicPythonWorkflow.ts @@ -0,0 +1,221 @@ +import { endNodeId, startNodeId } from 'models'; +import { TaskExecutionPhase } from 'models/Execution/enums'; +import { nodeIds } from '../constants'; +import { + generateTask, + generateWorkflow, + generateExecutionForWorkflow, + generateNodeExecution, + generateTaskExecution +} from '../generators'; +import { makeDefaultLaunchPlan, taskNodeIds } from '../utils'; + +const workflowName = 'DynamicPythonTaskWorkflow'; +const pythonTaskName = `${workflowName}.PythonTask`; +const pythonNodeId = 'pythonNode'; +const dynamicTaskName = `${workflowName}.DynamicTask`; +const dynamicNodeId = 'dynamicNode'; + +function getSharedEntities() { + const pythonTask = generateTask( + { name: pythonTaskName }, + { + template: { + type: 'python-task' + } + } + ); + + const dynamicTask = generateTask( + { name: dynamicTaskName }, + { + template: { + type: 'dynamic-task' + } + } + ); + + const workflow = generateWorkflow( + { name: workflowName }, + { + closure: { + compiledWorkflow: { + primary: { + connections: { + downstream: { + [startNodeId]: { + ids: [dynamicNodeId] + }, + [nodeIds.dynamicTask]: { ids: [endNodeId] } + }, + upstream: { + [nodeIds.dynamicTask]: { ids: [startNodeId] }, + [endNodeId]: { + ids: [nodeIds.dynamicTask] + } + } + }, + template: { + nodes: [ + { + ...taskNodeIds(dynamicNodeId, dynamicTask), + inputs: [] + } + ], + outputs: [] + } + }, + tasks: [ + dynamicTask.closure.compiledTask, + pythonTask.closure.compiledTask + ] + } + } + } + ); + + const launchPlan = makeDefaultLaunchPlan(workflow); + const execution = generateExecutionForWorkflow(workflow, launchPlan); + return { pythonTask, dynamicTask, workflow, launchPlan, execution }; +} + +function generateWithDynamicTaskChild() { + const {dynamicTask, pythonTask, workflow, launchPlan, execution } = getSharedEntities(); + const pythonNodeExecution = generateNodeExecution(execution, pythonNodeId); + const pythonTaskExecutions = [ + generateTaskExecution(pythonNodeExecution, pythonTask, { + closure: { + phase: TaskExecutionPhase.FAILED, + error: { message: 'Something went wrong.' } + } + }), + generateTaskExecution(pythonNodeExecution, pythonTask, { + id: { retryAttempt: 1 } + }) + ]; + const dynamicNodeExecution = generateNodeExecution( + execution, + dynamicNodeId + ); + const dynamicTaskExecution = generateTaskExecution( + dynamicNodeExecution, + dynamicTask, + { isParent: true } + ); + return { + launchPlans: { top: launchPlan }, + tasks: { dynamic: dynamicTask, python: pythonTask }, + workflows: { top: workflow }, + workflowExecutions: { + top: { + data: execution, + nodeExecutions: { + dynamicNode: { + data: dynamicNodeExecution, + taskExecutions: { + firstAttempt: { + data: dynamicTaskExecution, + nodeExecutions: { + pythonNode: { + data: pythonNodeExecution, + taskExecutions: { + firstAttempt: { + data: pythonTaskExecutions[0] + }, + secondAttempt: { + data: pythonTaskExecutions[1] + } + } + } + } + } + } + } + } + } + } + }; +} + +function generateWithNodeExecutionChild() { + const {dynamicTask, pythonTask, workflow, launchPlan, execution } = getSharedEntities(); + const dynamicNodeExecution = generateNodeExecution( + execution, + dynamicNodeId, + { metadata: { isParentNode: true } } + ); + const dynamicTaskExecution = generateTaskExecution( + dynamicNodeExecution, + dynamicTask + ); + const pythonNodeExecutions = [ + generateNodeExecution(execution, `${pythonNodeId}-1`, { + metadata: { retryGroup: '0' } + }), + generateNodeExecution(execution, `${pythonNodeId}-2`, { + metadata: { retryGroup: '1' } + }) + ]; + const pythonTaskExecutions = [ + generateTaskExecution(pythonNodeExecutions[0], pythonTask, { + closure: { + phase: TaskExecutionPhase.FAILED, + error: { message: 'Something went wrong.' } + } + }), + generateTaskExecution(pythonNodeExecutions[1], pythonTask) + ]; + return { + launchPlans: { top: launchPlan }, + tasks: { + dynamic: dynamicTask, + python: pythonTask + }, + workflows: { top: workflow}, + workflowExecutions: { + top: { + data: execution, + nodeExecutions: { + dynamicNode: { + data: dynamicNodeExecution, + nodeExecutions: { + firstChild: { + data: pythonNodeExecutions[0], + taskExecutions: { + firstAttempt: { data: pythonTaskExecutions[0]} + } + }, + secondChild: { + data: pythonNodeExecutions[1], + taskExecutions: { + firstAttempt: { data: pythonTaskExecutions[1]} + } + } + }, + taskExecutions: { + firstAttempt: { + data: dynamicTaskExecution + } + } + } + } + } + } + }; +} + +/** This workflow has a single dynamic task node which will yield an additional + * python task node at runtime using the `TaskExecution.isParent` field. + * The nested python task has two attempts. + */ +export const dynamicPythonTaskWorkflow = { + generate: generateWithDynamicTaskChild +}; + +/** This workflow has a single dynamic task node which will yield an additional + * python task node at runtime using the `NodeExecution.metadata.isParentNode` field. + * The nested python task has two attempts. + */ +export const dynamicPythonNodeExecutionWorkflow = { + generate: generateWithNodeExecutionChild +}; diff --git a/src/mocks/data/fixtures/oneFailedTaskWorkflow.ts b/src/mocks/data/fixtures/oneFailedTaskWorkflow.ts new file mode 100644 index 000000000..1092e5535 --- /dev/null +++ b/src/mocks/data/fixtures/oneFailedTaskWorkflow.ts @@ -0,0 +1,130 @@ +import { endNodeId, startNodeId } from 'models'; +import { NodeExecutionPhase, TaskExecutionPhase } from 'models/Execution/enums'; +import { + generateExecutionForWorkflow, + generateNodeExecution, + generateTask, + generateTaskExecution, + generateWorkflow +} from '../generators'; +import { makeDefaultLaunchPlan, taskNodeIds } from '../utils'; + +const workflowName = 'OneFailedTaskWorkflow'; +const pythonTaskName = `${workflowName}.PythonTask`; +const failedTaskName = `${workflowName}.FailedTask`; +const pythonNodeId = 'pythonNode'; +const failedNodeId = 'failedNode'; + +function generate() { + const pythonTask = generateTask( + { name: pythonTaskName }, + { + template: { + type: 'python-task' + } + } + ); + const failedTask = generateTask( + { name: failedTaskName }, + { template: { type: 'python-task' } } + ); + + const workflow = generateWorkflow( + { name: workflowName }, + { + closure: { + compiledWorkflow: { + primary: { + connections: { + downstream: { + [startNodeId]: { + ids: [pythonNodeId, failedNodeId] + }, + [pythonNodeId]: { ids: [endNodeId] }, + [failedNodeId]: { ids: [endNodeId] } + }, + upstream: { + [failedNodeId]: { ids: [startNodeId] }, + [pythonNodeId]: { ids: [startNodeId] }, + [endNodeId]: { + ids: [pythonNodeId, failedNodeId] + } + } + }, + template: { + nodes: [ + { + ...taskNodeIds(pythonNodeId, pythonTask), + inputs: [] + }, + { + ...taskNodeIds(failedNodeId, pythonTask), + inputs: [] + } + ], + outputs: [] + } + }, + tasks: [ + pythonTask.closure.compiledTask, + failedTask.closure.compiledTask + ] + } + } + } + ); + + const launchPlan = makeDefaultLaunchPlan(workflow); + const execution = generateExecutionForWorkflow(workflow, launchPlan); + const pythonNodeExecution = generateNodeExecution(execution, pythonNodeId); + const pythonTaskExecution = generateTaskExecution( + pythonNodeExecution, + pythonTask + ); + const errorMessage = 'This task failed by design.'; + const failedNodeExecution = generateNodeExecution(execution, failedNodeId, { + closure: { + phase: NodeExecutionPhase.FAILED, + error: { message: errorMessage } + } + }); + const failedTaskExecution = generateTaskExecution( + failedNodeExecution, + failedTask, + { + closure: { + phase: TaskExecutionPhase.FAILED, + error: { message: errorMessage } + } + } + ); + return { + launchPlans: { top: launchPlan }, + tasks: { python: pythonTask, failed: failedTask }, + workflows: { top: workflow }, + workflowExecutions: { + top: { + data: execution, + nodeExecutions: { + pythonNode: { + data: pythonNodeExecution, + taskExecutions: { + firstAttempt: { data: pythonTaskExecution } + } + }, + failedNode: { + data: failedNodeExecution, + taskExecutions: { + firstAttempt: { data: failedTaskExecution } + } + } + } + } + } + }; +} + +/** This workflow has two python nodes with no inputs/outputs. One of them + * will always fail. + */ +export const oneFailedTaskWorkflow = { generate }; diff --git a/src/mocks/data/fixtures/types.ts b/src/mocks/data/fixtures/types.ts new file mode 100644 index 000000000..f921c4056 --- /dev/null +++ b/src/mocks/data/fixtures/types.ts @@ -0,0 +1,42 @@ +import { + TaskExecution, + NodeExecution, + Execution, + Task, + Workflow, + LaunchPlan +} from 'models'; + +/** Represents a TaskExecution and its associated children. */ +export interface MockTaskExecutionData { + data: TaskExecution; + // Legacy Dynamic Task executions may yield additional NodeExecutions at runtime. + nodeExecutions?: Record; +} + +/** Represents a NodeExecution record and its associated children. */ +export interface MockNodeExecutionData { + data: NodeExecution; + // NodeExecutions may be parents of other NodeExecutions + nodeExecutions?: Record; + // NodeExecutions which executed a Task will have child + // TaskExecutions. + taskExecutions?: Record; +} + +/** Represents a WorkflowExecution record and its associated children. */ +export interface MockWorkflowExecutionData { + data: Execution; + nodeExecutions: Record; +} + +/** Contains maps of objects to be inserted into an `AdminServer` instance. + * All are optional (i.e. you can create a fixture containing only a list + * of tasks). + */ +export interface MockDataFixture { + launchPlans?: Record; + tasks?: Record; + workflows?: Record; + workflowExecutions?: Record; +} diff --git a/src/mocks/data/generators.ts b/src/mocks/data/generators.ts new file mode 100644 index 000000000..4a13445a5 --- /dev/null +++ b/src/mocks/data/generators.ts @@ -0,0 +1,206 @@ +import { DeepPartial } from 'common/types'; +import { dateToTimestamp, millisecondsToDuration } from 'common/utils'; +import { Admin, Core } from 'flyteidl'; +import { merge } from 'lodash'; +import { timeStampOffset } from 'mocks/utils'; +import { + CompiledTask, + endNodeId, + Execution, + LaunchPlan, + NodeExecution, + startNodeId, + Task, + TaskExecution, + Workflow +} from 'models'; +import { Identifier, ResourceType } from 'models/Common/types'; +import { + NodeExecutionPhase, + TaskExecutionPhase, + WorkflowExecutionPhase +} from 'models/Execution/enums'; +import { + defaultExecutionDuration, + emptyInputUri, + emptyOutputUri, + entityCreationDate, + mockStartDate, + testDomain, + testProject, + testVersions +} from './constants'; +import { nodeExecutionId, sampleLogs, taskExecutionId } from './utils'; + +/** Wraps a `CompiledTask` in the necessary fields to create a `Task`. */ +export function taskFromCompiledTask(compiledTask: CompiledTask): Task { + return { + closure: { createdAt: { ...entityCreationDate }, compiledTask }, + id: compiledTask.template.id + }; +} + +/** Generate a new `Task` object based on a set of defaults. The base object + * returned when `compiledTaskOverrides` is omitted will be a valid `Task` + */ +export function generateTask( + idOverrides: Partial, + compiledTaskOverrides?: DeepPartial +): Task { + const id = { + resourceType: ResourceType.TASK, + project: testProject, + domain: testDomain, + name: '_base', + version: testVersions.v1, + ...idOverrides + }; + const base: CompiledTask = { + template: { + custom: {}, + container: {}, + metadata: {}, + type: 'unknown-type', + id, + interface: { + inputs: { + variables: {} + }, + outputs: { + variables: {} + } + } + } + }; + return taskFromCompiledTask(merge(base, compiledTaskOverrides)); +} + +/** Generate a new `Workflow` object based on a set of defaults. The base object + * returned when `overrides` is omitted will be a valid `Workflow`. + */ +export function generateWorkflow( + idOverrides: Partial, + overrides: DeepPartial +): Workflow { + const id = { + resourceType: Core.ResourceType.WORKFLOW, + project: testProject, + domain: testDomain, + name: '_base', + version: testVersions.v1, + ...idOverrides + }; + const base: Workflow = { + id, + closure: { + createdAt: { ...entityCreationDate }, + compiledWorkflow: { + primary: { + connections: { + downstream: {}, + upstream: {} + }, + template: { + metadata: {}, + metadataDefaults: {}, + id, + interface: {}, + nodes: [{ id: startNodeId }, { id: endNodeId }], + outputs: [] + } + }, + tasks: [] + } + } + }; + return merge(base, overrides); +} + +/** Generate an `Execution` for a given `Workflow` and `LaunchPlan`. The base object + * returned when `overrides` is omitted will be a valid `Execution`. + */ +export function generateExecutionForWorkflow( + workflow: Workflow, + launchPlan: LaunchPlan, + overrides?: DeepPartial +): Execution { + const executionStart = dateToTimestamp(mockStartDate); + const { id: workflowId } = workflow; + const id = { + project: testProject, + domain: testDomain, + name: `${workflowId.name}Execution` + }; + const base: Execution = { + id, + spec: { + launchPlan: { ...launchPlan.id }, + inputs: { literals: {} }, + metadata: { + mode: Admin.ExecutionMetadata.ExecutionMode.MANUAL, + principal: 'sdk', + nesting: 0 + }, + notifications: { + notifications: [] + } + }, + closure: { + workflowId, + computedInputs: { literals: {} }, + createdAt: executionStart, + duration: millisecondsToDuration(defaultExecutionDuration), + phase: WorkflowExecutionPhase.SUCCEEDED, + startedAt: executionStart + } + }; + return merge(base, overrides); +} + +/** Generate a `NodeExecution` for a nodeId that will be a child of the given `Execution`. + * The base object returned when `overrides` is omitted will be a valid `NodeExecution`. + */ +export function generateNodeExecution( + parentExecution: Execution, + nodeId: string, + overrides?: DeepPartial +): NodeExecution { + const base: NodeExecution = { + id: nodeExecutionId(parentExecution.id, nodeId), + metadata: { specNodeId: nodeId }, + closure: { + createdAt: timeStampOffset(parentExecution.closure.createdAt, 0), + startedAt: timeStampOffset(parentExecution.closure.createdAt, 0), + outputUri: emptyOutputUri, + phase: NodeExecutionPhase.SUCCEEDED, + duration: millisecondsToDuration(defaultExecutionDuration) + }, + inputUri: emptyInputUri + }; + return merge(base, overrides); +} + +/** Generate a `TaskExecution` for a `Task` that will be a child of the given `NodeExecution`. + * The base object returned when `overrides` is omitted will be a valid `TaskExecution`. + */ +export function generateTaskExecution( + nodeExecution: NodeExecution, + task: Task, + overrides?: DeepPartial +): TaskExecution { + const base: TaskExecution = { + id: taskExecutionId(nodeExecution, task, 0), + inputUri: emptyInputUri, + isParent: false, + closure: { + customInfo: {}, + phase: TaskExecutionPhase.SUCCEEDED, + duration: millisecondsToDuration(defaultExecutionDuration), + createdAt: timeStampOffset(nodeExecution.closure.createdAt, 0), + startedAt: timeStampOffset(nodeExecution.closure.createdAt, 0), + outputUri: emptyOutputUri, + logs: sampleLogs() + } + }; + return merge(base, overrides); +} diff --git a/src/mocks/data/insertFixture.ts b/src/mocks/data/insertFixture.ts new file mode 100644 index 000000000..1f0469a83 --- /dev/null +++ b/src/mocks/data/insertFixture.ts @@ -0,0 +1,80 @@ +import { MockServer } from 'mocks/server'; +import { nodeExecutionQueryParams } from 'models'; +import { + MockDataFixture, + MockNodeExecutionData, + MockTaskExecutionData, + MockWorkflowExecutionData +} from './fixtures/types'; + +function insertTaskExecutionData( + server: MockServer, + mock: MockTaskExecutionData +): void { + server.insertTaskExecution(mock.data); + if (mock.nodeExecutions) { + const nodeExecutions = Object.values(mock.nodeExecutions); + nodeExecutions.forEach(ne => insertNodeExecutionData(server, ne)); + server.insertTaskExecutionChildList( + mock.data.id, + nodeExecutions.map(({ data }) => data) + ); + } +} + +function insertNodeExecutionData( + server: MockServer, + mock: MockNodeExecutionData +): void { + server.insertNodeExecution(mock.data); + if (mock.taskExecutions) { + const taskExecutions = Object.values(mock.taskExecutions); + taskExecutions.forEach(te => insertTaskExecutionData(server, te)); + server.insertTaskExecutionList( + mock.data.id, + taskExecutions.map(({ data }) => data) + ); + } + + if (mock.nodeExecutions) { + const nodeExecutions = Object.values(mock.nodeExecutions); + nodeExecutions.forEach(ne => insertNodeExecutionData(server, ne)); + server.insertNodeExecutionList( + mock.data.id.executionId, + nodeExecutions.map(({ data }) => data), + { [nodeExecutionQueryParams.parentNodeId]: mock.data.id.nodeId } + ); + } +} + +function insertWorkflowExecutionData( + server: MockServer, + mock: MockWorkflowExecutionData +): void { + server.insertWorkflowExecution(mock.data); + const nodeExecutions = Object.values(mock.nodeExecutions); + nodeExecutions.forEach(ne => insertNodeExecutionData(server, ne)); + server.insertNodeExecutionList( + mock.data.id, + nodeExecutions.map(({ data }) => data) + ); +} + +/** Deep-inserts all entities from a generated `MockDataFixture`. */ +export function insertFixture( + server: MockServer, + { launchPlans, tasks, workflowExecutions, workflows }: MockDataFixture +): void { + // TODO: Insert Launch plans (requires support in mockServer) + if (tasks) { + Object.values(tasks).forEach(server.insertTask); + } + if (workflows) { + Object.values(workflows).forEach(server.insertWorkflow); + } + if (workflowExecutions) { + Object.values(workflowExecutions).forEach(execution => + insertWorkflowExecutionData(server, execution) + ); + } +} diff --git a/src/mocks/data/launchPlans.ts b/src/mocks/data/launchPlans.ts deleted file mode 100644 index d715617e5..000000000 --- a/src/mocks/data/launchPlans.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Core } from 'flyteidl'; -import { LaunchPlan } from 'models'; - -// TODO: -const basic: LaunchPlan = { - id: { - resourceType: Core.ResourceType.LAUNCH_PLAN, - project: 'flytetest', - domain: 'development', - name: 'Basic', - version: 'abc123' - } -} as LaunchPlan; - -export const launchPlans = { basic }; diff --git a/src/mocks/data/nodeExecutions.ts b/src/mocks/data/nodeExecutions.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/mocks/data/projects.ts b/src/mocks/data/projects.ts index da278bffb..bcf14cac4 100644 --- a/src/mocks/data/projects.ts +++ b/src/mocks/data/projects.ts @@ -1,3 +1,6 @@ +import { Project } from 'models/Project/types'; +import { testDomain, testProject } from './constants'; + export function emptyProject(id: string, name?: string) { return { id, @@ -6,3 +9,20 @@ export function emptyProject(id: string, name?: string) { description: '' }; } + +const flyteTest: Project = { + id: testProject, + name: testProject, + description: + 'An umbrella project with a single domain to contain all of the test data.', + domains: [ + { + id: testDomain, + name: testDomain + } + ] +}; + +export const projects = { + flyteTest +}; diff --git a/src/mocks/data/utils.ts b/src/mocks/data/utils.ts new file mode 100644 index 000000000..83e743174 --- /dev/null +++ b/src/mocks/data/utils.ts @@ -0,0 +1,139 @@ +import { + Task, + Binding, + Workflow, + ResourceType, + LaunchPlan, + NodeExecutionIdentifier, + WorkflowExecutionIdentifier, + NodeExecution, + TaskLog, + TaskExecutionIdentifier, + TaskNode +} from 'models'; +import { dataUriPrefix, testDomain, testProject } from './constants'; + +interface TaskNodeIdsResult { + id: string; + taskNode: Pick; +} +/** Helper for generating id fields used in a `Task` record. */ +export function taskNodeIds(id: string, task: Task): TaskNodeIdsResult { + return { + id, + taskNode: { referenceId: { ...task.id } } + }; +} + +/** Generates a binding indicating consumption of outputs from an upstream node. */ +export function bindingFromNode( + inputName: string, + upstreamNodeId: string, + upstreamInputName: string +): Binding { + return { + var: inputName, + binding: { + promise: { + nodeId: upstreamNodeId, + var: upstreamInputName + } + } + }; +} + +/** Generates a default `LaunchPlan` for a given `Workflow`. It will have an identical + * `name` and `version`, no default/fixed inputs and will use a dummy `role` value. + */ +export function makeDefaultLaunchPlan(workflow: Workflow): LaunchPlan { + return { + id: { + resourceType: ResourceType.LAUNCH_PLAN, + project: testProject, + domain: testDomain, + name: workflow.id.name, + version: workflow.id.version + }, + spec: { + defaultInputs: { parameters: {} }, + entityMetadata: { notifications: [], schedule: {} }, + fixedInputs: { literals: {} }, + role: 'defaultRole', + workflowId: { ...workflow.id } + } + }; +} + +/** Generates a consistent input URI for NodeExecution data. */ +export function makeNodeExecutionInputUri({ + executionId: { project, domain, name }, + nodeId +}: NodeExecutionIdentifier): string { + return `${dataUriPrefix}/${project}_${domain}_${name}_${nodeId}/inputs.pb`; +} + +/** Generates a consistent output URI for NodeExecution data. */ +export function makeNodeExecutionOutputUri({ + executionId: { project, domain, name }, + nodeId +}: NodeExecutionIdentifier): string { + return `${dataUriPrefix}/${project}_${domain}_${name}_${nodeId}/outputs.pb`; +} + +/** Combines parent `Execution` id and `nodeId` into a `NodeExecutionIdentifier` */ +export function nodeExecutionId( + executionId: WorkflowExecutionIdentifier, + nodeId: string +): NodeExecutionIdentifier { + return { + nodeId, + executionId: { ...executionId } + }; +} + +/** Generates a set of dummy log links for use in a `TaskExecution`. */ +export function sampleLogs(): TaskLog[] { + return [ + { name: 'Kubernetes Logs', uri: 'http://localhost/k8stasklog' }, + { name: 'User Logs', uri: 'http://localhost/containerlog' }, + { name: 'AWS Batch Logs', uri: 'http://localhost/awsbatchlog' }, + { name: 'Other Custom Logs', uri: 'http://localhost/customlog' } + ]; +} + +/** Combines the needed fields from a parent `NodeExecution`, `Task` and `retryAttempt` + * into a `TaskExecutionIdentifier`. + */ +export function taskExecutionId( + nodeExecution: NodeExecution, + task: Task, + retryAttempt = 0 +): TaskExecutionIdentifier { + return { + retryAttempt, + nodeExecutionId: { ...nodeExecution.id }, + taskId: { ...task.id } + }; +} + +/** Generates a consistent input URI for `TaskExecution` data. */ +export function makeTaskExecutionInputUri({ + nodeExecutionId: { + executionId: { project, domain, name }, + nodeId + }, + retryAttempt +}: TaskExecutionIdentifier): string { + return `${dataUriPrefix}/${project}_${domain}_${name}_${nodeId}_${retryAttempt}/inputs.pb`; +} + +/** Generates a consistent output URI for `TaskExecution` data. */ +export function makeTaskExecutionOutputUri({ + nodeExecutionId: { + executionId: { project, domain, name }, + nodeId + }, + retryAttempt +}: TaskExecutionIdentifier): string { + return `${dataUriPrefix}/${project}_${domain}_${name}_${nodeId}_${retryAttempt}/outputs.pb`; +} diff --git a/src/mocks/data/workflowExecutions.ts b/src/mocks/data/workflowExecutions.ts deleted file mode 100644 index 0d904142f..000000000 --- a/src/mocks/data/workflowExecutions.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { dateToTimestamp, millisecondsToDuration } from 'common/utils'; -import { Admin } from 'flyteidl'; -import { LiteralMap } from 'models/Common/types'; -import { WorkflowExecutionPhase } from 'models/Execution/enums'; -import { Execution, ExecutionMetadata } from 'models/Execution/types'; -import { defaultWorkflowExecutionDuration, mockStartDate } from './constants'; -import { launchPlans } from './launchPlans'; -import { workflows } from './workflows'; - -export function defaultWorkflowExecutionMetadata(): ExecutionMetadata { - return { - mode: Admin.ExecutionMetadata.ExecutionMode.MANUAL, - principal: 'sdk', - nesting: 0 - }; -} - -export function emptyLiteralMap(): LiteralMap { - return { literals: {} }; -} - -const basic: Execution = { - id: { - project: 'flytetest', - domain: 'development', - name: 'abc123' - }, - spec: { - launchPlan: { ...launchPlans.basic.id }, - inputs: emptyLiteralMap(), - metadata: defaultWorkflowExecutionMetadata(), - notifications: { - notifications: [] - } - }, - closure: { - computedInputs: emptyLiteralMap(), - createdAt: dateToTimestamp(mockStartDate), - duration: millisecondsToDuration(defaultWorkflowExecutionDuration), - phase: WorkflowExecutionPhase.SUCCEEDED, - startedAt: dateToTimestamp(mockStartDate), - workflowId: { ...workflows.basic.id } - } -}; - -export const workflowExecutions = { - basic -}; diff --git a/src/mocks/data/workflows.ts b/src/mocks/data/workflows.ts deleted file mode 100644 index cf0a74ab9..000000000 --- a/src/mocks/data/workflows.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Core } from 'flyteidl'; -import { Workflow } from 'models/Workflow/types'; - -// TODO: -const basic: Workflow = { - id: { - resourceType: Core.ResourceType.WORKFLOW, - project: 'flytetest', - domain: 'development', - name: 'Basic', - version: 'abc123' - } -}; - -export const workflows = { basic }; diff --git a/src/mocks/getDefaultData.ts b/src/mocks/getDefaultData.ts deleted file mode 100644 index 74e1c5c91..000000000 --- a/src/mocks/getDefaultData.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RequestHandlersList } from 'msw/lib/types/setupWorker/glossary'; -import { workflowExecutions } from './data/workflowExecutions'; -import { workflowExecutionHandler } from './handlers'; - -export function getDefaultData(): RequestHandlersList { - const workflowExecutionHandlers = Object.values(workflowExecutions).map( - workflowExecutionHandler - ); - return [...workflowExecutionHandlers]; -} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts deleted file mode 100644 index 0e7acc901..000000000 --- a/src/mocks/handlers.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Admin } from 'flyteidl'; -import { - EncodableType, - encodeProtoPayload, - Execution, - NameIdentifierScope, - NodeExecution, - Project, - Workflow -} from 'models'; -import { - makeExecutionPath, - makeNodeExecutionListPath, - makeNodeExecutionPath -} from 'models/Execution/utils'; -import { makeWorkflowPath } from 'models/Workflow/utils'; -import { ResponseResolver, rest } from 'msw'; -import { setupServer } from 'msw/lib/types/node'; -import { RestContext } from 'msw/lib/types/rest'; -import { apiPath } from './utils'; - -export function adminEntityResponder( - data: any, - encodeType: EncodableType -): ResponseResolver { - const buffer = encodeProtoPayload(data, encodeType); - const contentLength = buffer.byteLength.toString(); - return (_, res, ctx) => - res( - ctx.set('Content-Type', 'application/octet-stream'), - ctx.set('Content-Length', contentLength), - ctx.body(buffer) - ); -} - -export function workflowExecutionHandler(data: Partial) { - return rest.get( - apiPath(makeExecutionPath(data.id!)), - adminEntityResponder(data, Admin.Execution) - ); -} - -export function workflowHandler(data: Partial) { - return rest.get( - apiPath(makeWorkflowPath(data.id!)), - adminEntityResponder(data, Admin.Workflow) - ); -} - -export function nodeExecutionHandler(data: Partial) { - return rest.get( - apiPath(makeNodeExecutionPath(data.id!)), - adminEntityResponder(data, Admin.NodeExecution) - ); -} - -// TODO: pagination responder that respects limit/token? -export function nodeExecutionListHandler( - scope: NameIdentifierScope, - data: Partial[] -) { - return rest.get( - apiPath(makeNodeExecutionListPath(scope)), - adminEntityResponder( - { - nodeExecutions: data - }, - Admin.NodeExecutionList - ) - ); -} - -export function projectListHandler(data: Project[]) { - return rest.get( - apiPath('/projects'), - adminEntityResponder({ projects: data }, Admin.Projects) - ); -} - -export interface BoundAdminServer { - insertNodeExecution(data: Partial): void; - insertNodeExecutionList( - scope: NameIdentifierScope, - data: Partial[] - ): void; - insertProjects(data: Project[]): void; - insertWorkflow(data: Partial): void; - insertWorkflowExecution(data: Partial): void; -} - -export function bindHandlers({ - use -}: ReturnType): BoundAdminServer { - return { - insertNodeExecution: data => use(nodeExecutionHandler(data)), - insertNodeExecutionList: (scope, data) => - use(nodeExecutionListHandler(scope, data)), - insertProjects: data => use(projectListHandler(data)), - insertWorkflow: data => use(workflowHandler(data)), - insertWorkflowExecution: data => use(workflowExecutionHandler(data)) - }; -} diff --git a/src/mocks/insertDefaultData.ts b/src/mocks/insertDefaultData.ts new file mode 100644 index 000000000..6e5551f7f --- /dev/null +++ b/src/mocks/insertDefaultData.ts @@ -0,0 +1,9 @@ +import { projects } from './data/projects'; +import { MockServer } from './server'; + +/** Inserts default global mock data. This can be extended by inserting additional + * mock data fixtures. + */ +export function insertDefaultData(server: MockServer): void { + server.insertProjects([projects.flyteTest]); +} diff --git a/src/mocks/server.ts b/src/mocks/server.ts index 403cc2b75..aae4772d8 100644 --- a/src/mocks/server.ts +++ b/src/mocks/server.ts @@ -1,7 +1,6 @@ -import { setupServer } from 'msw/node'; -import { getDefaultData } from './getDefaultData'; -import { bindHandlers } from './handlers'; +import { setupServer, SetupServerApi } from 'msw/node'; +import { AdminServer, createAdminServer } from './createAdminServer'; -const server = setupServer(...getDefaultData()); -const handlers = bindHandlers(server); -export const mockServer = { ...server, ...handlers }; +const { handlers, server } = createAdminServer(); +export type MockServer = SetupServerApi & AdminServer; +export const mockServer: MockServer = { ...setupServer(...handlers), ...server }; diff --git a/src/mocks/utils.ts b/src/mocks/utils.ts index 4be4a6f5b..36cccd386 100644 --- a/src/mocks/utils.ts +++ b/src/mocks/utils.ts @@ -1,5 +1,42 @@ -import { apiPrefix } from 'models/AdminEntity/constants'; +import { Protobuf } from 'flyteidl'; +import { isObject, isPlainObject } from 'lodash'; -export function apiPath(path: string) { - return `${apiPrefix}${path}`; +/** Offsets a given `Protobuf.ITimestamp` by a value in seconds. Useful + * for creating start time relationships between parent/child or sibling executions. + */ +export function timeStampOffset( + timeStamp: Protobuf.ITimestamp, + offsetSeconds: number +): Protobuf.Timestamp { + const output = new Protobuf.Timestamp(timeStamp); + output.seconds = + offsetSeconds < 0 + ? output.seconds.subtract(offsetSeconds) + : output.seconds.add(offsetSeconds); + return output; +} + +function stableStringifyReplacer(_key: string, value: unknown): unknown { + if (typeof value === 'function') { + throw new Error('Encountered function() during serialization'); + } + + if (isObject(value)) { + const plainObject: any = isPlainObject(value) ? value : { ...value }; + return Object.keys(plainObject) + .sort() + .reduce((result, key) => { + result[key] = plainObject[key]; + return result; + }, {} as any); + } + + return value; +} + +/** A copy of the hash function from react-query, useful for generating a unique + * key for mapping a Request to its associated data based on URL/type/query params/etc. + */ +export function stableStringify(value: unknown): string { + return JSON.stringify(value, stableStringifyReplacer); } diff --git a/src/models/AdminEntity/test/AdminEntity.spec.ts b/src/models/AdminEntity/test/AdminEntity.spec.ts index c8267430c..26fc1fc97 100644 --- a/src/models/AdminEntity/test/AdminEntity.spec.ts +++ b/src/models/AdminEntity/test/AdminEntity.spec.ts @@ -1,9 +1,9 @@ import { NotAuthorizedError, NotFoundError } from 'errors'; import { Admin } from 'flyteidl'; import { mockServer } from 'mocks/server'; -import { apiPath } from 'mocks/utils'; import { rest } from 'msw'; import { getAdminEntity } from '../AdminEntity'; +import { adminApiUrl } from '../utils'; describe('getAdminEntity', () => { const messageType = Admin.Workflow; @@ -15,7 +15,7 @@ describe('getAdminEntity', () => { it('Returns a NotFoundError for 404 responses', async () => { mockServer.use( - rest.get(apiPath(path), (_, res, ctx) => { + rest.get(adminApiUrl(path), (_, res, ctx) => { return res(ctx.status(404)); }) ); @@ -27,7 +27,7 @@ describe('getAdminEntity', () => { it('Returns a NotAuthorizedError for 401 responses', async () => { mockServer.use( - rest.get(apiPath(path), (_, res, ctx) => { + rest.get(adminApiUrl(path), (_, res, ctx) => { return res(ctx.status(401)); }) ); diff --git a/src/models/Task/api.ts b/src/models/Task/api.ts index 0b795f75c..8a98f1863 100644 --- a/src/models/Task/api.ts +++ b/src/models/Task/api.ts @@ -1,20 +1,15 @@ import { Admin } from 'flyteidl'; import { defaultPaginationConfig, RequestConfig } from 'models/AdminEntity'; import { getAdminEntity } from 'models/AdminEntity/AdminEntity'; -import { - endpointPrefixes, - Identifier, - IdentifierScope, - makeIdentifierPath -} from 'models/Common'; +import { Identifier, IdentifierScope } from 'models/Common'; import { Task } from './types'; -import { taskListTransformer } from './utils'; +import { makeTaskPath, taskListTransformer } from './utils'; /** Fetches a list of `Task` records matching the provided `scope` */ export const listTasks = (scope: IdentifierScope, config?: RequestConfig) => getAdminEntity( { - path: makeIdentifierPath(endpointPrefixes.task, scope), + path: makeTaskPath(scope), messageType: Admin.TaskList, transform: taskListTransformer }, @@ -25,7 +20,7 @@ export const listTasks = (scope: IdentifierScope, config?: RequestConfig) => export const getTask = (id: Identifier, config?: RequestConfig) => getAdminEntity( { - path: makeIdentifierPath(endpointPrefixes.task, id), + path: makeTaskPath(id), messageType: Admin.Task }, config diff --git a/src/models/Task/utils.ts b/src/models/Task/utils.ts index 6f14229bb..0c49cd8b0 100644 --- a/src/models/Task/utils.ts +++ b/src/models/Task/utils.ts @@ -1,8 +1,18 @@ import { Admin } from 'flyteidl'; import { createPaginationTransformer } from 'models/AdminEntity'; +import { endpointPrefixes } from 'models/Common/constants'; +import { IdentifierScope } from 'models/Common/types'; +import { makeIdentifierPath } from 'models/Common/utils'; import { Task } from './types'; +/** Generate the correct path for retrieving a task or list of tasks based on the + * given scope. + */ +export function makeTaskPath(scope: IdentifierScope) { + return makeIdentifierPath(endpointPrefixes.task, scope); +} + /** Transformer to coerce an `Admin.TaskList` into a standard shape */ export const taskListTransformer = createPaginationTransformer< Task, diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts index 7875f4caa..d1cbc8478 100644 --- a/src/test/setupTests.ts +++ b/src/test/setupTests.ts @@ -1,9 +1,15 @@ import '@testing-library/jest-dom'; +import { insertDefaultData } from 'mocks/insertDefaultData'; import { mockServer } from 'mocks/server'; +import { obj } from './utils'; beforeAll(() => { + insertDefaultData(mockServer); mockServer.listen({ - onUnhandledRequest: 'error' + onUnhandledRequest: (req) => { + const message = `Unexpected request: ${obj(req)}`; + throw new Error(message); + } }); }); diff --git a/src/test/utils.ts b/src/test/utils.ts index 4f107091b..67c6738ef 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -1,16 +1,30 @@ +import { prettyDOM } from '@testing-library/react'; +import { createQueryClient } from 'components/data/queryCache'; import * as Long from 'long'; +/** Shorthand for creating a `Long` from a `Number`. */ export const long = (val: number) => Long.fromNumber(val); +/** Stringifies the argument with formatting. */ export const obj = (val: any) => JSON.stringify(val, null, 2); +/** Returns a promise which will never resolve. */ export function pendingPromise(): Promise { return new Promise(() => {}); } +/** Creates a version of `QueryClient` suitable for use in tests (will not perform + * any retries). */ +export const createTestQueryClient = () => + createQueryClient({ + queries: { retry: false } + }); + export interface DelayedPromiseResult { promise: Promise; resolve: (value: T) => void; reject: (value: any) => void; } + +/** Returns a promise which can be manually resolved/rejected. */ export function delayedPromise(): DelayedPromiseResult { let resolve: (value: any) => void; let reject: (value: any) => void; @@ -33,3 +47,17 @@ export function waitFor(timeMS: number) { setTimeout(resolve, timeMS); }); } + +/** Starting from the given `element` search upwards for the first ancestor with + * a `role` attribute equal to the given value. Throws if no matching element is found. + */ +export function findNearestAncestorByRole(element: HTMLElement, role: string): HTMLElement { + let parent: HTMLElement | null = element; + while(parent !== null) { + if (parent.getAttribute('role') === role) { + return parent; + } + parent = parent.parentElement; + } + throw new Error(`Failed to find element with role ${role} in ancestor tree.\n${prettyDOM(document.body)}`); +} diff --git a/yarn.lock b/yarn.lock index 9cf3978da..16f97af5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12219,6 +12219,14 @@ match-sorter@^4.1.0: "@babel/runtime" "^7.10.5" remove-accents "0.4.2" +match-sorter@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.0.2.tgz#91bbab14c28a87f4a67755b7a194c0d11dedc080" + integrity sha512-SDRLNlWof9GnAUEyhKP0O5525MMGXUGt+ep4MrrqQ2StAh3zjvICVZseiwg7Zijn3GazpJDiwuRr/mFDHd92NQ== + dependencies: + "@babel/runtime" "^7.12.5" + remove-accents "0.4.2" + matchmediaquery@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.2.1.tgz#223c7005793de03e47ce92b13285a72c44ada2cf" @@ -14927,12 +14935,13 @@ react-query-devtools@^3.0.0-beta: dependencies: match-sorter "^4.1.0" -react-query@^3.2.0-beta: - version "3.2.0-beta.30" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.2.0-beta.30.tgz#b5be7790e00911a2126c1dadc7e07dbfe768bd13" - integrity sha512-OaKvcXUz8nwxsgs4AYjwC0h4Z9Ku4K124HAiqTBLPN6/Vv/p6L//JdGZa+fz618jE2lnc1HsR2RRpkmO2L1KzA== +react-query@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.3.0.tgz#a52ea140134295531b6f04cd52975e4e49244423" + integrity sha512-vuYoAs8qFjROk6BDR7TZKR4DJwF0ZuA09xA+cCgBokRtd9zTfZ6ql0Zqbr0POgVJofQFuf7IfrcP4j5G/kujwQ== dependencies: "@babel/runtime" "^7.5.5" + match-sorter "^6.0.2" react-refresh@^0.8.3: version "0.8.3"