Adds test coverage to the workflow output and workflow output graph components

This commit is contained in:
mabashian 2020-01-30 11:22:37 -05:00
parent fc3f19bd2b
commit ef854aabb7
8 changed files with 403 additions and 7 deletions

View File

@ -33,6 +33,7 @@ const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
page_size: 200,
page: pageNo,
});
if (data.next) {
return fetchWorkflowNodes(jobId, pageNo + 1, nodes.concat(data.results));
}

View File

@ -0,0 +1,152 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { WorkflowJobsAPI } from '@api';
import WorkflowOutput from './WorkflowOutput';
jest.mock('@api');
const job = {
id: 1,
name: 'Foo JT',
status: 'successful',
};
const mockWorkflowJobNodes = [
{
id: 8,
success_nodes: [10],
failure_nodes: [],
always_nodes: [9],
summary_fields: {
job: {
elapsed: 10,
id: 14,
name: 'A Playbook',
status: 'successful',
type: 'job',
},
},
},
{
id: 9,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
summary_fields: {
job: {
elapsed: 10,
id: 14,
name: 'A Project Update',
status: 'successful',
type: 'project_update',
},
},
},
{
id: 10,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
summary_fields: {
job: {
elapsed: 10,
id: 14,
name: 'An Inventory Source Sync',
status: 'successful',
type: 'inventory_update',
},
},
},
{
id: 11,
success_nodes: [9],
failure_nodes: [],
always_nodes: [],
summary_fields: {
job: {
elapsed: 10,
id: 14,
name: 'Pause',
status: 'successful',
type: 'workflow_approval',
},
},
},
];
describe('WorkflowOutput', () => {
let wrapper;
beforeEach(() => {
WorkflowJobsAPI.readNodes.mockResolvedValue({
data: {
count: mockWorkflowJobNodes.length,
results: mockWorkflowJobNodes,
},
});
window.SVGElement.prototype.height = {
baseVal: {
value: 100,
},
};
window.SVGElement.prototype.width = {
baseVal: {
value: 100,
},
};
window.SVGElement.prototype.getBBox = () => ({
x: 0,
y: 0,
width: 500,
height: 250,
});
window.SVGElement.prototype.getBoundingClientRect = () => ({
x: 303,
y: 252.359375,
width: 1329,
height: 259.640625,
top: 252.359375,
right: 1632,
bottom: 512,
left: 303,
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
delete window.SVGElement.prototype.getBBox;
delete window.SVGElement.prototype.getBoundingClientRect;
delete window.SVGElement.prototype.height;
delete window.SVGElement.prototype.width;
});
test('renders successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<svg>
<WorkflowOutput job={job} />
</svg>
);
});
wrapper.update();
expect(wrapper.find('ContentError')).toHaveLength(0);
expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
});
test('error shown to user when error thrown fetching workflow job nodes', async () => {
WorkflowJobsAPI.readNodes.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<svg>
<WorkflowOutput job={job} />
</svg>
);
});
wrapper.update();
expect(wrapper.find('ContentError')).toHaveLength(1);
});
});

View File

@ -173,7 +173,8 @@ function WorkflowOutputGraph() {
<WorkflowOutputLink
key={`link-${link.source.id}-${link.target.id}`}
link={link}
onUpdateLinkHelp={setLinkHelp}
mouseEnter={() => setLinkHelp(link)}
mouseLeave={() => setLinkHelp(null)}
/>
)),
nodes.map(node => {

View File

@ -0,0 +1,235 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { WorkflowStateContext } from '@contexts/Workflow';
import WorkflowOutputGraph from './WorkflowOutputGraph';
const workflowContext = {
links: [
{
source: {
id: 2,
},
target: {
id: 4,
},
linkType: 'success',
type: 'link',
},
{
source: {
id: 2,
},
target: {
id: 3,
},
linkType: 'always',
type: 'link',
},
{
source: {
id: 5,
},
target: {
id: 3,
},
linkType: 'success',
type: 'link',
},
{
source: {
id: 1,
},
target: {
id: 2,
},
linkType: 'always',
type: 'link',
},
{
source: {
id: 1,
},
target: {
id: 5,
},
linkType: 'success',
type: 'link',
},
],
nodePositions: {
1: { label: '', width: 72, height: 40, x: 36, y: 85 },
2: { label: '', width: 180, height: 60, x: 282, y: 40 },
3: { label: '', width: 180, height: 60, x: 582, y: 130 },
4: { label: '', width: 180, height: 60, x: 582, y: 30 },
5: { label: '', width: 180, height: 60, x: 282, y: 140 },
},
nodes: [
{
id: 1,
type: 'node',
},
{
id: 2,
type: 'node',
job: {
name: 'Foo JT',
type: 'job',
status: 'successful',
elapsed: 60,
},
},
{
id: 3,
type: 'node',
},
{
id: 4,
type: 'node',
},
{
id: 5,
type: 'node',
},
],
showLegend: false,
showTools: false,
};
describe('WorkflowOutputGraph', () => {
beforeEach(() => {
window.SVGElement.prototype.height = {
baseVal: {
value: 100,
},
};
window.SVGElement.prototype.width = {
baseVal: {
value: 100,
},
};
window.SVGElement.prototype.getBBox = () => ({
x: 0,
y: 0,
width: 500,
height: 250,
});
window.SVGElement.prototype.getBoundingClientRect = () => ({
x: 303,
y: 252.359375,
width: 1329,
height: 259.640625,
top: 252.359375,
right: 1632,
bottom: 512,
left: 303,
});
});
afterEach(() => {
delete window.SVGElement.prototype.getBBox;
delete window.SVGElement.prototype.getBoundingClientRect;
delete window.SVGElement.prototype.height;
delete window.SVGElement.prototype.width;
});
test('mounts successfully', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider value={workflowContext}>
<WorkflowOutputGraph />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper).toHaveLength(1);
});
test('tools and legend are shown when flags are true', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider
value={{ ...workflowContext, showLegend: true, showTools: true }}
>
<WorkflowOutputGraph />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.find('WorkflowLegend')).toHaveLength(1);
expect(wrapper.find('WorkflowTools')).toHaveLength(1);
});
test('nodes and links are properly rendered', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider value={workflowContext}>
<WorkflowOutputGraph />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
expect(wrapper.find('#link-2-4')).toHaveLength(1);
expect(wrapper.find('#link-2-3')).toHaveLength(1);
expect(wrapper.find('#link-5-3')).toHaveLength(1);
expect(wrapper.find('#link-1-2')).toHaveLength(1);
expect(wrapper.find('#link-1-5')).toHaveLength(1);
});
test('proper help text is shown when hovering over links and nodes', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider value={workflowContext}>
<WorkflowOutputGraph />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
wrapper.find('g#node-2').simulate('mouseenter');
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(1);
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Name</b>)).toEqual(
true
);
expect(
wrapper.find('WorkflowNodeHelp').containsMatchingElement(<dd>Foo JT</dd>)
).toEqual(true);
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Type</b>)).toEqual(
true
);
expect(
wrapper
.find('WorkflowNodeHelp')
.containsMatchingElement(<dd>Job Template</dd>)
).toEqual(true);
expect(
wrapper.find('WorkflowNodeHelp').contains(<b>Job Status</b>)
).toEqual(true);
expect(
wrapper
.find('WorkflowNodeHelp')
.containsMatchingElement(<dd>Successful</dd>)
).toEqual(true);
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Elapsed</b>)).toEqual(
true
);
expect(
wrapper
.find('WorkflowNodeHelp')
.containsMatchingElement(<dd>00:01:00</dd>)
).toEqual(true);
wrapper.find('g#node-2').simulate('mouseleave');
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
wrapper.find('g#link-2-3').simulate('mouseenter');
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(1);
expect(wrapper.find('WorkflowLinkHelp').contains(<b>Run</b>)).toEqual(true);
expect(
wrapper.find('WorkflowLinkHelp').containsMatchingElement(<dd>Always</dd>)
).toEqual(true);
wrapper.find('g#link-2-3').simulate('mouseleave');
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
});
});

View File

@ -1,13 +1,13 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import { shape } from 'prop-types';
import { func, shape } from 'prop-types';
import {
generateLine,
getLinePoints,
getLinkOverlayPoints,
} from '@components/Workflow/WorkflowUtils';
function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
function WorkflowOutputLink({ link, mouseEnter, mouseLeave }) {
const ref = useRef(null);
const [hovering, setHovering] = useState(false);
const [pathD, setPathD] = useState();
@ -17,11 +17,13 @@ function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
const handleLinkMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current);
setHovering(true);
mouseEnter();
};
const handleLinkMouseLeave = () => {
ref.current.parentNode.prepend(ref.current);
setHovering(null);
mouseLeave();
};
useEffect(() => {
@ -56,8 +58,8 @@ function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
/>
<path d={pathD} stroke={pathStroke} strokeWidth="2px" />
<polygon
onMouseEnter={() => onUpdateLinkHelp(link)}
onMouseLeave={() => onUpdateLinkHelp(null)}
onMouseEnter={() => mouseEnter()}
onMouseLeave={() => mouseLeave()}
opacity="0"
points={getLinkOverlayPoints(link, nodePositions)}
/>
@ -67,6 +69,8 @@ function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
WorkflowOutputLink.propTypes = {
link: shape().isRequired,
mouseEnter: func.isRequired,
mouseLeave: func.isRequired,
};
export default WorkflowOutputLink;

View File

@ -35,7 +35,8 @@ describe('WorkflowOutputLink', () => {
<WorkflowOutputLink
link={link}
nodePositions={nodePositions}
onUpdateLinkHelp={() => {}}
mouseEnter={() => {}}
mouseLeave={() => {}}
/>
</WorkflowStateContext.Provider>
</svg>

View File

@ -103,7 +103,7 @@ function WorkflowOutputNode({ history, i18n, mouseEnter, mouseLeave, node }) {
{node.job ? (
<>
<JobTopLine>
<StatusIcon status={node.job.status} />
{node.job.status && <StatusIcon status={node.job.status} />}
<p>{node.job.name}</p>
</JobTopLine>
<Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed>

View File

@ -76,6 +76,7 @@ function WorkflowOutputToolbar({ i18n, job }) {
<VerticalSeparator />
<Tooltip content={i18n._(t`Toggle Legend`)} position="bottom">
<ActionButton
id="workflow-output-legend-button"
isActive={showLegend}
onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })}
variant="plain"
@ -85,6 +86,7 @@ function WorkflowOutputToolbar({ i18n, job }) {
</Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton
id="workflow-output-tools-button"
isActive={showTools}
onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })}
variant="plain"