Skip to content

Commit

Permalink
PipelineDetails page tests (#380)
Browse files Browse the repository at this point in the history
* wip pipeline details tests

* pipeline details tests

* safe load template

* add close panel test

* pr comments
  • Loading branch information
yebrahim authored and k8s-ci-robot committed Nov 28, 2018
1 parent 2a3ec15 commit db772d5
Show file tree
Hide file tree
Showing 5 changed files with 1,191 additions and 44 deletions.
5 changes: 4 additions & 1 deletion frontend/src/components/Graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,14 @@ interface GraphProps {
}

export default class Graph extends React.Component<GraphProps> {
public render(): JSX.Element {
public render(): JSX.Element | null {
const { graph } = this.props;
const displayEdges: Edge[] = [];
const displayEdgeStartPoints: number[][] = [];

if (!graph.nodes().length) {
return null;
}
dagre.layout(graph);

// Creates the lines that constitute the edges connecting the graph.
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/components/__snapshots__/Graph.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,7 @@ exports[`Graph gracefully renders a graph with a selected node id that does not
</div>
`;

exports[`Graph handles an empty graph 1`] = `
<div
className="root"
/>
`;
exports[`Graph handles an empty graph 1`] = `""`;

exports[`Graph renders a complex graph with six nodes and seven edges 1`] = `
<div
Expand Down
332 changes: 332 additions & 0 deletions frontend/src/pages/PipelineDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
/*
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as React from 'react';
import * as StaticGraphParser from '../lib/StaticGraphParser';
import PipelineDetails, { css } from './PipelineDetails';
import TestUtils from '../TestUtils';
import { ApiPipeline } from '../apis/pipeline';
import { Apis } from '../lib/Apis';
import { PageProps } from './Page';
import { QUERY_PARAMS } from '../lib/URLParser';
import { RouteParams, RoutePage } from '../components/Router';
import { graphlib } from 'dagre';
import { shallow, mount, ShallowWrapper, ReactWrapper } from 'enzyme';

describe('PipelineDetails', () => {
const updateBannerSpy = jest.fn();
const updateDialogSpy = jest.fn();
const updateSnackbarSpy = jest.fn();
const updateToolbarSpy = jest.fn();
const historyPushSpy = jest.fn();
const getPipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipeline');
const deletePipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipeline');
const getTemplateSpy = jest.spyOn(Apis.pipelineServiceApi, 'getTemplate');
const createGraphSpy = jest.spyOn(StaticGraphParser, 'createGraph');

let tree: ShallowWrapper | ReactWrapper;
let testPipeline: ApiPipeline = {};

function generateProps(): PageProps {
// getInitialToolbarState relies on page props having been populated, so fill those first
const pageProps: PageProps = {
history: { push: historyPushSpy } as any,
location: '' as any,
match: { params: { [RouteParams.pipelineId]: testPipeline.id }, isExact: true, path: '', url: '' },
toolbarProps: { actions: [], breadcrumbs: [], pageTitle: '' },
updateBanner: updateBannerSpy,
updateDialog: updateDialogSpy,
updateSnackbar: updateSnackbarSpy,
updateToolbar: updateToolbarSpy,
};
return Object.assign(pageProps, {
toolbarProps: new PipelineDetails(pageProps).getInitialToolbarState(),
});
}

beforeAll(() => jest.spyOn(console, 'error').mockImplementation());
afterAll(() => jest.resetAllMocks());

beforeEach(() => {
testPipeline = {
created_at: new Date(2018, 8, 5, 4, 3, 2),
description: 'test job description',
enabled: true,
id: 'test-job-id',
max_concurrency: '50',
name: 'test job',
pipeline_spec: {
parameters: [{ name: 'param1', value: 'value1' }],
pipeline_id: 'some-pipeline-id',
},
trigger: {
periodic_schedule: {
end_time: new Date(2018, 10, 9, 8, 7, 6),
interval_second: '3600',
start_time: new Date(2018, 9, 8, 7, 6),
}
},
} as ApiPipeline;

historyPushSpy.mockClear();
updateBannerSpy.mockClear();
updateDialogSpy.mockClear();
updateSnackbarSpy.mockClear();
updateToolbarSpy.mockClear();
deletePipelineSpy.mockReset();
getPipelineSpy.mockImplementation(() => Promise.resolve(testPipeline));
getPipelineSpy.mockClear();
getTemplateSpy.mockImplementation(() => Promise.resolve('{}'));
getTemplateSpy.mockClear();
createGraphSpy.mockImplementation(() => new graphlib.Graph());
createGraphSpy.mockClear();
});

afterEach(() => tree.unmount());

it('shows empty pipeline details with no graph', async () => {
TestUtils.makeErrorResponseOnce(createGraphSpy, 'bad graph');
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
expect(tree).toMatchSnapshot();
});

it('shows load error banner when failing to get pipeline', async () => {
TestUtils.makeErrorResponseOnce(getPipelineSpy, 'woops');
tree = shallow(<PipelineDetails {...generateProps()} />);
await getPipelineSpy;
await TestUtils.flushPromises();
expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear banner, once to show error
expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({
additionalInfo: 'woops',
message: 'Cannot retrieve pipeline details. Click Details for more information.',
mode: 'error',
}));
});

it('shows load error banner when failing to get pipeline template', async () => {
TestUtils.makeErrorResponseOnce(getTemplateSpy, 'woops');
tree = shallow(<PipelineDetails {...generateProps()} />);
await getPipelineSpy;
await TestUtils.flushPromises();
expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear banner, once to show error
expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({
additionalInfo: 'woops',
message: 'Cannot retrieve pipeline template. Click Details for more information.',
mode: 'error',
}));
});

it('shows no graph error banner when failing to parse graph', async () => {
TestUtils.makeErrorResponseOnce(createGraphSpy, 'bad graph');
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear banner, once to show error
expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({
additionalInfo: 'bad graph',
message: 'Error: failed to generate Pipeline graph. Click Details for more information.',
mode: 'error',
}));
});

it('shows empty pipeline details with empty graph', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
expect(tree).toMatchSnapshot();
});

it('sets summary shown state to false when clicking the Hide button', async () => {
tree = mount(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
tree.update();
expect(tree.state('summaryShown')).toBe(true);
tree.find('Paper Button').simulate('click');
expect(tree.state('summaryShown')).toBe(false);
});

it('collapses summary card when summary shown state is false', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
tree.setState({ summaryShown: false });
expect(tree).toMatchSnapshot();
});

it('shows the summary card when clicking Show button', async () => {
tree = mount(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
tree.setState({ summaryShown: false });
tree.find(`.${css.footer} Button`).simulate('click');
expect(tree.state('summaryShown')).toBe(true);
});

it('has a new experiment button', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
const instance = tree.instance() as PipelineDetails;
const newExperimentBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Start an experiment');
expect(newExperimentBtn).toBeDefined();
});

it('clicking new experiment button navigates to new experiment page', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
const instance = tree.instance() as PipelineDetails;
const newExperimentBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Start an experiment');
await newExperimentBtn!.action();
expect(historyPushSpy).toHaveBeenCalledTimes(1);
expect(historyPushSpy).toHaveBeenLastCalledWith(
RoutePage.NEW_EXPERIMENT + `?${QUERY_PARAMS.pipelineId}=${testPipeline.id}`);
});

it('has a delete button', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
const instance = tree.instance() as PipelineDetails;
const deleteBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Delete');
expect(deleteBtn).toBeDefined();
});

it('shows delete confirmation dialog when delete buttin is clicked', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
const deleteBtn = (tree.instance() as PipelineDetails)
.getInitialToolbarState().actions.find(b => b.title === 'Delete');
await deleteBtn!.action();
expect(updateDialogSpy).toHaveBeenCalledTimes(1);
expect(updateDialogSpy).toHaveBeenLastCalledWith(expect.objectContaining({
title: 'Delete this pipeline?',
}));
});

it('does not call delete API for selected pipeline when delete dialog is canceled', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
const deleteBtn = (tree.instance() as PipelineDetails)
.getInitialToolbarState().actions.find(b => b.title === 'Delete');
await deleteBtn!.action();
const call = updateDialogSpy.mock.calls[0][0];
const cancelBtn = call.buttons.find((b: any) => b.text === 'Cancel');
await cancelBtn.onClick();
expect(deletePipelineSpy).not.toHaveBeenCalled();
});

it('calls delete API when delete dialog is confirmed', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
const deleteBtn = (tree.instance() as PipelineDetails)
.getInitialToolbarState().actions.find(b => b.title === 'Delete');
await deleteBtn!.action();
const call = updateDialogSpy.mock.calls[0][0];
const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
await confirmBtn.onClick();
expect(deletePipelineSpy).toHaveBeenCalledTimes(1);
expect(deletePipelineSpy).toHaveBeenLastCalledWith(testPipeline.id);
});

it('shows error dialog if deletion fails', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
TestUtils.makeErrorResponseOnce(deletePipelineSpy, 'woops');
await getTemplateSpy;
await TestUtils.flushPromises();
const deleteBtn = (tree.instance() as PipelineDetails)
.getInitialToolbarState().actions.find(b => b.title === 'Delete');
await deleteBtn!.action();
const call = updateDialogSpy.mock.calls[0][0];
const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
await confirmBtn.onClick();
expect(updateDialogSpy).toHaveBeenCalledTimes(2); // Delete dialog + error dialog
expect(updateDialogSpy).toHaveBeenLastCalledWith(expect.objectContaining({
content: 'woops',
title: 'Failed to delete pipeline',
}));
});

it('shows success snackbar if deletion succeeds', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
const deleteBtn = (tree.instance() as PipelineDetails)
.getInitialToolbarState().actions.find(b => b.title === 'Delete');
await deleteBtn!.action();
const call = updateDialogSpy.mock.calls[0][0];
const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
await confirmBtn.onClick();
expect(updateSnackbarSpy).toHaveBeenCalledTimes(1);
expect(updateSnackbarSpy).toHaveBeenLastCalledWith(expect.objectContaining({
message: 'Successfully deleted pipeline: ' + testPipeline.name,
open: true,
}));
});

it('opens side panel on clicked node, shows message when node is not found in graph', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
tree.find('Graph').simulate('click', 'some-node-id');
expect(tree.state('selectedNodeId')).toBe('some-node-id');
expect(tree).toMatchSnapshot();
});

it('shows clicked node info in the side panel if it is in the graph', async () => {
const g = new graphlib.Graph();
const info = new StaticGraphParser.SelectedNodeInfo();
info.args = ['test arg', 'test arg2'];
info.command = ['test command', 'test command 2'];
info.condition = 'test condition';
info.image = 'test image';
info.inputs = [['key1', 'val1'], ['key2', 'val2']];
info.outputs = [['key3', 'val3'], ['key4', 'val4']];
info.nodeType = 'container';
g.setNode('node1', { info, label: 'node1' });
createGraphSpy.mockImplementation(() => g);

tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
tree.find('Graph').simulate('click', 'node1');
expect(tree).toMatchSnapshot();
});

it('shows pipeline source code when config tab is clicked', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
tree.find('MD2Tabs').simulate('switch', 1);
expect(tree.state('selectedTab')).toBe(1);
expect(tree).toMatchSnapshot();
});

it('closes side panel when close button is clicked', async () => {
tree = shallow(<PipelineDetails {...generateProps()} />);
await getTemplateSpy;
await TestUtils.flushPromises();
tree.setState({ selectedNodeId: 'some-node-id' });
tree.find('SidePanel').simulate('close');
expect(tree.state('selectedNodeId')).toBe('');
expect(tree).toMatchSnapshot();
});
});
Loading

0 comments on commit db772d5

Please sign in to comment.