enzyme#ShallowWrapper TypeScript Examples

The following examples show how to use enzyme#ShallowWrapper. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: test-utils.tsx    From react-component-library with BSD 3-Clause "New" or "Revised" License 6 votes vote down vote up
findByTestId = (id: string, wrapper: ShallowWrapper | ReactWrapper): any =>
    wrapper.find(`[data-test="${id}"]`).hostNodes()
Example #2
Source File: LaunchpadButton.spec.tsx    From next-basics with GNU General Public License v3.0 6 votes vote down vote up
describe("LaunchpadButton", () => {
  let wrapper: ShallowWrapper;
  const getPortal = (): ShallowWrapper<LaunchpadPortalProps> =>
    wrapper.find(LaunchpadPortal);

  beforeEach(() => {
    wrapper = shallow(<LaunchpadButton />);
  });

  afterEach(() => {
    spyOnToggleLaunchpadEffect.mockClear();
  });

  it("should work", () => {
    expect(wrapper).toMatchSnapshot();
  });

  it("should open launchpad", async () => {
    //await (global as any).flushPromises();
    expect(getPortal().prop("visible")).toBe(false);
    wrapper.find("a").simulate("click");
    await (global as any).flushPromises();
    expect(getPortal().prop("visible")).toBe(true);
  });

  it("should toggle filter of blur to false", () => {
    getPortal().invoke("onWillClose")();
    expect(spyOnToggleLaunchpadEffect).toBeCalledWith(false);
  });

  it("should set visible to false", () => {
    getPortal().invoke("onClose")();
    expect(getPortal().prop("visible")).toBe(false);
  });
});
Example #3
Source File: search-bar.spec.tsx    From bitpay-browser-extension with MIT License 6 votes vote down vote up
describe('Search Bar', () => {
  const setWrapperProps = (wrapper: ShallowWrapper): void => {
    wrapper.setProps({
      output: (val: string) => wrapper.setProps({ value: val }),
      tracking: {
        trackEvent: (): void => undefined
      }
    });
  };
  it('should create the component', () => {
    const wrapper = shallow(<SearchBar />);
    expect(wrapper.exists()).toBeTruthy();
  });

  it('should change icons based on input value', () => {
    const wrapper = shallow(<SearchBar />);
    setWrapperProps(wrapper);
    expect(wrapper.find('#searchClearIcon').exists()).toBeFalsy();
    expect(wrapper.find('#searchIcon').exists()).toBeTruthy();
    wrapper.find('input').simulate('change', { currentTarget: { value: 'amazon' } });
    expect(wrapper.find('#searchClearIcon').exists()).toBeTruthy();
    expect(wrapper.find('#searchIcon').exists()).toBeFalsy();
    wrapper.find('button').simulate('click');
    expect(wrapper.find('#searchClearIcon').exists()).toBeFalsy();
    expect(wrapper.find('#searchIcon').exists()).toBeTruthy();
  });

  it('should clear input value on clicking clearSearchButton ', () => {
    const wrapper = shallow(<SearchBar />);
    setWrapperProps(wrapper);
    wrapper.find('input').simulate('change', { currentTarget: { value: 'amazon' } });
    expect(wrapper.find('input').prop('value')).toBe('amazon');
    wrapper.find('button').simulate('click');
    expect(wrapper.find('input').prop('value')).toBe('');
  });
});
Example #4
Source File: ConfirmButton.test.tsx    From grafana-chinese with Apache License 2.0 6 votes vote down vote up
describe('ConfirmButton', () => {
  let wrapper: any;
  let deleted: any;

  beforeAll(() => {
    deleted = false;

    function deleteItem() {
      deleted = true;
    }

    wrapper = mount(
      <ConfirmButton confirmText="Confirm delete" onConfirm={() => deleteItem()}>
        Delete
      </ConfirmButton>
    );
  });

  it('should show confirm delete when clicked', () => {
    expect(deleted).toBe(false);
    wrapper
      .find(Button)
      .findWhere((n: ShallowWrapper) => {
        return n.text() === 'Confirm delete' && n.type() === Button;
      })
      .simulate('click');
    expect(deleted).toBe(true);
  });
});
Example #5
Source File: PageHeader.test.tsx    From grafana-chinese with Apache License 2.0 5 votes vote down vote up
describe('PageHeader', () => {
  let wrapper: ShallowWrapper<PageHeader>;

  describe('when the nav tree has a node with a title', () => {
    beforeAll(() => {
      const nav = {
        main: {
          icon: 'fa fa-folder-open',
          id: 'node',
          subTitle: 'node subtitle',
          url: '',
          text: 'node',
        },
        node: {},
      };
      wrapper = shallow(<PageHeader model={nav as any} />);
    });

    it('should render the title', () => {
      const title = wrapper.find('.page-header__title');
      expect(title.text()).toBe('node');
    });
  });

  describe('when the nav tree has a node with breadcrumbs and a title', () => {
    beforeAll(() => {
      const nav = {
        main: {
          icon: 'fa fa-folder-open',
          id: 'child',
          subTitle: 'child subtitle',
          url: '',
          text: 'child',
          breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }],
        },
        node: {},
      };
      wrapper = shallow(<PageHeader model={nav as any} />);
    });

    it('should render the title with breadcrumbs first and then title last', () => {
      const title = wrapper.find('.page-header__title');
      expect(title.text()).toBe('Parent / child');

      const parentLink = wrapper.find('.page-header__title > a.text-link');
      expect(parentLink.prop('href')).toBe('parentUrl');
    });
  });
});
Example #6
Source File: TestComponent.tsx    From use-long-press with MIT License 5 votes vote down vote up
export function createShallowTestComponent<Target = Element>(
  props: TestComponentProps
): ShallowWrapper<Required<TestComponentProps & HTMLAttributes<Target>>> {
  return shallow<Component<Required<TestComponentProps & HTMLAttributes<Target>>>>(<TestComponent {...props} />);
}
Example #7
Source File: RunDetails.test.tsx    From kfp-tekton-backend with Apache License 2.0 5 votes vote down vote up
function clickGraphNode(wrapper: ShallowWrapper, nodeId: string) {
  // TODO: use dom events instead
  wrapper
    .find('EnhancedGraph')
    .dive()
    .dive()
    .simulate('click', nodeId);
}
Example #8
Source File: PipelineDetails.test.tsx    From kfp-tekton-backend with Apache License 2.0 5 votes vote down vote up
function clickGraphNode(wrapper: ShallowWrapper, nodeId: string) {
  // TODO: use dom events instead
  wrapper
    .find('EnhancedGraph')
    .dive()
    .dive()
    .simulate('click', nodeId);
}
Example #9
Source File: ArchivedExperiments.test.tsx    From kfp-tekton-backend with Apache License 2.0 5 votes vote down vote up
describe('ArchivedExperiemnts', () => {
  const updateBannerSpy = jest.fn();
  const updateToolbarSpy = jest.fn();
  const historyPushSpy = jest.fn();
  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  let tree: ShallowWrapper;

  function generateProps(): PageProps {
    return TestUtils.generatePageProps(
      ArchivedExperiments,
      {} as any,
      {} as any,
      historyPushSpy,
      updateBannerSpy,
      updateDialogSpy,
      updateToolbarSpy,
      updateSnackbarSpy,
    );
  }

  beforeEach(() => {
    jest.clearAllMocks();
  });

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

  it('renders archived experiments', () => {
    tree = shallow(<ArchivedExperiments {...generateProps()} />);
    expect(tree).toMatchSnapshot();
  });

  it('removes error banner on unmount', () => {
    tree = shallow(<ArchivedExperiments {...generateProps()} />);
    tree.unmount();
    expect(updateBannerSpy).toHaveBeenCalledWith({});
  });

  it('refreshes the experiment list when refresh button is clicked', async () => {
    tree = shallow(<ArchivedExperiments {...generateProps()} />);
    const spy = jest.fn();
    (tree.instance() as any)._experimentlistRef = { current: { refresh: spy } };
    await TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.REFRESH).action();
    expect(spy).toHaveBeenLastCalledWith();
  });

  it('shows a list of archived experiments', () => {
    tree = shallow(<ArchivedExperiments {...generateProps()} />);
    expect(tree.find('ExperimentList').prop('storageState')).toBe(
      ExperimentStorageState.ARCHIVED.toString(),
    );
  });
});
Example #10
Source File: SideNav.test.tsx    From kfp-tekton-backend with Apache License 2.0 5 votes vote down vote up
isCollapsed = (tree: ShallowWrapper<any>) =>
  tree.find('WithStyles(IconButton)').hasClass(css.collapsedChevron)
Example #11
Source File: PipelineList.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('PipelineList', () => {
  let tree: ReactWrapper | ShallowWrapper;

  let updateBannerSpy: jest.Mock<{}>;
  let updateDialogSpy: jest.Mock<{}>;
  let updateSnackbarSpy: jest.Mock<{}>;
  let updateToolbarSpy: jest.Mock<{}>;
  let listPipelinesSpy: jest.SpyInstance<{}>;
  let listPipelineVersionsSpy: jest.SpyInstance<{}>;
  let deletePipelineSpy: jest.SpyInstance<{}>;
  let deletePipelineVersionSpy: jest.SpyInstance<{}>;

  function spyInit() {
    updateBannerSpy = jest.fn();
    updateDialogSpy = jest.fn();
    updateSnackbarSpy = jest.fn();
    updateToolbarSpy = jest.fn();
    listPipelinesSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelines');
    listPipelineVersionsSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelineVersions');
    deletePipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipeline');
    deletePipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipelineVersion');
  }

  function generateProps(): PageProps {
    return TestUtils.generatePageProps(
      PipelineList,
      '' as any,
      '' as any,
      null,
      updateBannerSpy,
      updateDialogSpy,
      updateToolbarSpy,
      updateSnackbarSpy,
    );
  }

  async function mountWithNPipelines(n: number): Promise<ReactWrapper> {
    listPipelinesSpy.mockImplementation(() => ({
      pipelines: range(n).map(i => ({
        id: 'test-pipeline-id' + i,
        name: 'test pipeline name' + i,
        defaultVersion: {
          id: 'test-pipeline-id' + i + '_default_version',
          name: 'test-pipeline-id' + i + '_default_version_name',
        },
      })),
    }));
    tree = TestUtils.mountWithRouter(<PipelineList {...generateProps()} />);
    await listPipelinesSpy;
    await TestUtils.flushPromises();
    tree.update(); // Make sure the tree is updated before returning it
    return tree;
  }

  beforeEach(() => {
    spyInit();
    jest.clearAllMocks();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    await tree.unmount();
    jest.restoreAllMocks();
  });

  it('renders an empty list with empty state message', () => {
    tree = shallow(<PipelineList {...generateProps()} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one pipeline', async () => {
    tree = shallow(<PipelineList {...generateProps()} />);
    tree.setState({
      displayPipelines: [
        {
          created_at: new Date(2018, 8, 22, 11, 5, 48),
          description: 'test pipeline description',
          name: 'pipeline1',
          parameters: [],
        },
      ],
    });
    await listPipelinesSpy;
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one pipeline with no description or created date', async () => {
    tree = shallow(<PipelineList {...generateProps()} />);
    tree.setState({
      displayPipelines: [
        {
          name: 'pipeline1',
          parameters: [],
        },
      ],
    });
    await listPipelinesSpy;
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one pipeline with error', async () => {
    tree = shallow(<PipelineList {...generateProps()} />);
    tree.setState({
      displayPipelines: [
        {
          created_at: new Date(2018, 8, 22, 11, 5, 48),
          description: 'test pipeline description',
          error: 'oops! could not load pipeline',
          name: 'pipeline1',
          parameters: [],
        },
      ],
    });
    await listPipelinesSpy;
    expect(tree).toMatchSnapshot();
  });

  it('calls Apis to list pipelines, sorted by creation time in descending order', async () => {
    listPipelinesSpy.mockImplementationOnce(() => ({ pipelines: [{ name: 'pipeline1' }] }));
    tree = TestUtils.mountWithRouter(<PipelineList {...generateProps()} />);
    await listPipelinesSpy;
    expect(listPipelinesSpy).toHaveBeenLastCalledWith('', 10, 'created_at desc', '');
    expect(tree.state()).toHaveProperty('displayPipelines', [
      { expandState: 0, name: 'pipeline1' },
    ]);
  });

  it('has a Refresh button, clicking it refreshes the pipeline list', async () => {
    tree = await mountWithNPipelines(1);
    const instance = tree.instance() as PipelineList;
    expect(listPipelinesSpy.mock.calls.length).toBe(1);
    const refreshBtn = instance.getInitialToolbarState().actions[ButtonKeys.REFRESH];
    expect(refreshBtn).toBeDefined();
    await refreshBtn!.action();
    expect(listPipelinesSpy.mock.calls.length).toBe(2);
    expect(listPipelinesSpy).toHaveBeenLastCalledWith('', 10, 'created_at desc', '');
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

  it('shows error banner when listing pipelines fails', async () => {
    TestUtils.makeErrorResponseOnce(listPipelinesSpy, 'bad stuff happened');
    tree = TestUtils.mountWithRouter(<PipelineList {...generateProps()} />);
    await listPipelinesSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'bad stuff happened',
        message: 'Error: failed to retrieve list of pipelines. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('shows error banner when listing pipelines fails after refresh', async () => {
    tree = TestUtils.mountWithRouter(<PipelineList {...generateProps()} />);
    const instance = tree.instance() as PipelineList;
    const refreshBtn = instance.getInitialToolbarState().actions[ButtonKeys.REFRESH];
    expect(refreshBtn).toBeDefined();
    TestUtils.makeErrorResponseOnce(listPipelinesSpy, 'bad stuff happened');
    await refreshBtn!.action();
    expect(listPipelinesSpy.mock.calls.length).toBe(2);
    expect(listPipelinesSpy).toHaveBeenLastCalledWith('', 10, 'created_at desc', '');
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'bad stuff happened',
        message: 'Error: failed to retrieve list of pipelines. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('hides error banner when listing pipelines fails then succeeds', async () => {
    TestUtils.makeErrorResponseOnce(listPipelinesSpy, 'bad stuff happened');
    tree = TestUtils.mountWithRouter(<PipelineList {...generateProps()} />);
    const instance = tree.instance() as PipelineList;
    await listPipelinesSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'bad stuff happened',
        message: 'Error: failed to retrieve list of pipelines. Click Details for more information.',
        mode: 'error',
      }),
    );
    updateBannerSpy.mockReset();

    const refreshBtn = instance.getInitialToolbarState().actions[ButtonKeys.REFRESH];
    listPipelinesSpy.mockImplementationOnce(() => ({ pipelines: [{ name: 'pipeline1' }] }));
    await refreshBtn!.action();
    expect(listPipelinesSpy.mock.calls.length).toBe(2);
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

  it('renders pipeline names as links to their details pages', async () => {
    tree = await mountWithNPipelines(1);
    const link = tree.find('a[children="test pipeline name0"]');
    expect(link).toHaveLength(1);
    expect(link.prop('href')).toBe(
      RoutePage.PIPELINE_DETAILS_NO_VERSION.replace(
        ':' + RouteParams.pipelineId + '?',
        'test-pipeline-id0',
      ),
    );
  });

  it('always has upload pipeline button enabled', async () => {
    tree = await mountWithNPipelines(1);
    const calls = updateToolbarSpy.mock.calls[0];
    expect(calls[0].actions[ButtonKeys.NEW_PIPELINE_VERSION]).not.toHaveProperty('disabled');
  });

  it('enables delete button when one pipeline is selected', async () => {
    tree = await mountWithNPipelines(1);
    tree.find('.tableRow').simulate('click');
    expect(updateToolbarSpy.mock.calls).toHaveLength(2); // Initial call, then selection update
    const calls = updateToolbarSpy.mock.calls[1];
    expect(calls[0].actions[ButtonKeys.DELETE_RUN]).toHaveProperty('disabled', false);
  });

  it('enables delete button when two pipelines are selected', async () => {
    tree = await mountWithNPipelines(2);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(1)
      .simulate('click');
    expect(updateToolbarSpy.mock.calls).toHaveLength(3); // Initial call, then selection updates
    const calls = updateToolbarSpy.mock.calls[2];
    expect(calls[0].actions[ButtonKeys.DELETE_RUN]).toHaveProperty('disabled', false);
  });

  it('re-disables delete button pipelines are unselected', async () => {
    tree = await mountWithNPipelines(1);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    expect(updateToolbarSpy.mock.calls).toHaveLength(3); // Initial call, then selection updates
    const calls = updateToolbarSpy.mock.calls[2];
    expect(calls[0].actions[ButtonKeys.DELETE_RUN]).toHaveProperty('disabled', true);
  });

  it('shows delete dialog when delete button is clicked', async () => {
    tree = await mountWithNPipelines(1);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    expect(call).toHaveProperty('title', 'Delete 1 pipeline?');
  });

  it('shows delete dialog when delete button is clicked, indicating several pipelines to delete', async () => {
    tree = await mountWithNPipelines(5);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(2)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(3)
      .simulate('click');
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    expect(call).toHaveProperty('title', 'Delete 3 pipelines?');
  });

  it('does not call delete API for selected pipeline when delete dialog is canceled', async () => {
    tree = await mountWithNPipelines(1);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    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 for selected pipeline after delete dialog is confirmed', async () => {
    tree = await mountWithNPipelines(1);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    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).toHaveBeenLastCalledWith('test-pipeline-id0');
  });

  it('updates the selected indices after a pipeline is deleted', async () => {
    tree = await mountWithNPipelines(5);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    expect(tree.state()).toHaveProperty('selectedIds', ['test-pipeline-id0']);
    deletePipelineSpy.mockImplementation(() => Promise.resolve());
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    expect(tree.state()).toHaveProperty('selectedIds', []);
  });

  it('updates the selected indices after multiple pipelines are deleted', async () => {
    tree = await mountWithNPipelines(5);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(3)
      .simulate('click');
    expect(tree.state()).toHaveProperty('selectedIds', ['test-pipeline-id0', 'test-pipeline-id3']);
    deletePipelineSpy.mockImplementation(() => Promise.resolve());
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    expect(tree.state()).toHaveProperty('selectedIds', []);
  });

  it('calls delete API for all selected pipelines after delete dialog is confirmed', async () => {
    tree = await mountWithNPipelines(5);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(1)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(4)
      .simulate('click');
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    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(3);
    expect(deletePipelineSpy).toHaveBeenCalledWith('test-pipeline-id0');
    expect(deletePipelineSpy).toHaveBeenCalledWith('test-pipeline-id1');
    expect(deletePipelineSpy).toHaveBeenCalledWith('test-pipeline-id4');
  });

  it('shows snackbar confirmation after pipeline is deleted', async () => {
    tree = await mountWithNPipelines(1);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    deletePipelineSpy.mockImplementation(() => Promise.resolve());
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    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).toHaveBeenLastCalledWith({
      message: 'Deletion succeeded for 1 pipeline',
      open: true,
    });
  });

  it('shows error dialog when pipeline deletion fails', async () => {
    tree = await mountWithNPipelines(1);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    TestUtils.makeErrorResponseOnce(deletePipelineSpy, 'woops, failed');
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    const lastCall = updateDialogSpy.mock.calls[1][0];
    expect(lastCall).toMatchObject({
      content: 'Failed to delete pipeline: test-pipeline-id0 with error: "woops, failed"',
      title: 'Failed to delete some pipelines and/or some pipeline versions',
    });
  });

  it('shows error dialog when multiple pipeline deletions fail', async () => {
    tree = await mountWithNPipelines(5);
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(2)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(1)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(3)
      .simulate('click');
    deletePipelineSpy.mockImplementation(id => {
      if (id.indexOf(3) === -1 && id.indexOf(2) === -1) {
        // eslint-disable-next-line no-throw-literal
        throw {
          text: () => Promise.resolve('woops, failed!'),
        };
      }
    });
    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    // Should show only one error dialog for both pipelines (plus once for confirmation)
    expect(updateDialogSpy).toHaveBeenCalledTimes(2);
    const lastCall = updateDialogSpy.mock.calls[1][0];
    expect(lastCall).toMatchObject({
      content:
        'Failed to delete pipeline: test-pipeline-id0 with error: "woops, failed!"\n\n' +
        'Failed to delete pipeline: test-pipeline-id1 with error: "woops, failed!"',
      title: 'Failed to delete some pipelines and/or some pipeline versions',
    });

    // Should show snackbar for the one successful deletion
    expect(updateSnackbarSpy).toHaveBeenLastCalledWith({
      message: 'Deletion succeeded for 2 pipelines',
      open: true,
    });
  });

  it("delete a pipeline and some other pipeline's version together", async () => {
    deletePipelineSpy.mockImplementation(() => Promise.resolve());
    deletePipelineVersionSpy.mockImplementation(() => Promise.resolve());
    listPipelineVersionsSpy.mockImplementation(() => ({
      versions: [
        {
          id: 'test-pipeline-id1_default_version',
          name: 'test-pipeline-id1_default_version_name',
        },
      ],
    }));

    tree = await mountWithNPipelines(2);
    tree
      .find('button[aria-label="Expand"]')
      .at(1)
      .simulate('click');
    await listPipelineVersionsSpy;
    tree.update();

    // select pipeline of id 'test-pipeline-id0'
    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    // select pipeline version of id 'test-pipeline-id1_default_version' under pipeline 'test-pipeline-id1'
    tree
      .find('.tableRow')
      .at(2)
      .simulate('click');

    expect(tree.state()).toHaveProperty('selectedIds', ['test-pipeline-id0']);
    expect(tree.state()).toHaveProperty('selectedVersionIds', {
      'test-pipeline-id1': ['test-pipeline-id1_default_version'],
    });

    const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();

    await deletePipelineSpy;
    await deletePipelineVersionSpy;

    expect(deletePipelineSpy).toHaveBeenCalledTimes(1);
    expect(deletePipelineSpy).toHaveBeenCalledWith('test-pipeline-id0');

    expect(deletePipelineVersionSpy).toHaveBeenCalledTimes(1);
    expect(deletePipelineVersionSpy).toHaveBeenCalledWith('test-pipeline-id1_default_version');

    expect(tree.state()).toHaveProperty('selectedIds', []);
    expect(tree.state()).toHaveProperty('selectedVersionIds', { 'test-pipeline-id1': [] });

    // Should show snackbar for the one successful deletion
    expect(updateSnackbarSpy).toHaveBeenLastCalledWith({
      message: 'Deletion succeeded for 1 pipeline and 1 pipeline version',
      open: true,
    });
  });
});
Example #12
Source File: easy-view.editor.spec.tsx    From next-basics with GNU General Public License v3.0 4 votes vote down vote up
describe("EasyViewEditor", () => {
  it("should work with gridAreas", () => {
    mockUseBuilderParentNode.mockReturnValueOnce(mockUseBuilderParentNodeValue);
    mockUseBuilderNode.mockReturnValueOnce({
      type: "brick",
      id: "B-001",
      brick: "easy-view",
      alias: "my-brick",
      $$parsedProperties: {
        gridAreas: {
          a: [1, 1, 2, 13],
          c: [2, 1, 3, 5],
          d: [2, 5, 3, 13],
        },
        gridTemplateColumns: "repeat(12, 1fr)",
        gridTemplateRows: "100px 200px",
        containerStyle: { height: "100%", gap: "10px" },
      },
    });
    mockUseOutlineEnabled.mockReturnValueOnce(false);
    mockUseBuilderGroupedChildNodes.mockReturnValueOnce([
      {
        mountPoint: "a",
        childNodes: [],
      },
      {
        mountPoint: "c",
        childNodes: [
          {
            id: "B-001",
            type: "brick",
            brick: "my-brick",
          },
        ],
      },
    ]);
    const wrapper = shallow(<EasyViewEditor nodeUid={1} />);
    const container = wrapper.find(".wrapper");
    expect(container.hasClass("outlineEnabled")).toBe(false);
    expect(container.hasClass("empty")).toBe(false);
    expect(container.prop("style")).toEqual({
      gridTemplateAreas: undefined,
      gridTemplateColumns: "repeat(12, 1fr)",
      gridTemplateRows: "100px 200px",
      gap: "10px",
    });
    expect(container.children().length).toBe(3);

    const findSlotContainerByName = (slotName: string): ShallowWrapper =>
      container
        .findWhere((child) => child.prop("slotName") === slotName)
        .parent();

    const slotA = findSlotContainerByName("a");
    expect(slotA.prop("style")).toEqual({
      gridArea: "1 / 1 / 2 / 13",
    });
    expect(slotA.hasClass("empty")).toBe(true);

    const slotC = findSlotContainerByName("c");
    expect(slotC.prop("style")).toEqual({
      gridArea: "2 / 1 / 3 / 5",
    });
    expect(slotC.hasClass("empty")).toBe(false);

    const slotD = findSlotContainerByName("d");
    expect(slotD.prop("style")).toEqual({
      gridArea: "2 / 5 / 3 / 13",
    });
    expect(slotD.hasClass("empty")).toBe(true);
  });

  it("should work with gridTemplateAreas", () => {
    mockUseBuilderParentNode.mockReturnValueOnce(mockUseBuilderParentNodeValue);
    mockUseBuilderNode.mockReturnValueOnce({
      type: "brick",
      id: "B-001",
      brick: "easy-view",
      alias: "my-brick",
      $$parsedProperties: {
        gridTemplateAreas: [
          ["a", "a", "a"],
          ["c", ".", "d"],
        ],
        gridTemplateColumns: ["4fr", "2fr", "6fr"],
        gridTemplateRows: ["100px", "200px"],
        containerStyle: { gridGap: "10px" },
        styleByAreas: {
          a: {
            justifySelf: "center",
          },
        },
      },
    });
    mockUseOutlineEnabled.mockReturnValueOnce(true);
    mockUseBuilderGroupedChildNodes.mockReturnValueOnce([]);
    const wrapper = shallow(<EasyViewEditor nodeUid={1} />);
    const container = wrapper.find(".wrapper");
    expect(container.hasClass("outlineEnabled")).toBe(true);
    expect(container.hasClass("empty")).toBe(false);
    expect(container.prop("style")).toEqual({
      gridTemplateAreas: '"a a a" "c . d"',
      gridTemplateColumns: "4fr 2fr 6fr",
      gridTemplateRows: "100px 200px",
      gridGap: "10px",
    });
    expect(container.children().length).toBe(3);

    const findSlotContainerStyleByName = (slotName: string): ShallowWrapper =>
      container
        .findWhere((child) => child.prop("slotName") === slotName)
        .parent()
        .prop("style");
    expect(findSlotContainerStyleByName("a")).toEqual({
      gridArea: "a",
      justifySelf: "center",
    });
    expect(findSlotContainerStyleByName("c")).toEqual({
      gridArea: "c",
    });
    expect(findSlotContainerStyleByName("d")).toEqual({
      gridArea: "d",
    });
  });

  it("should work with no areas", () => {
    mockUseBuilderParentNode.mockReturnValueOnce(mockUseBuilderParentNodeValue);
    mockUseBuilderNode.mockReturnValueOnce({
      type: "brick",
      id: "B-001",
      brick: "easy-view",
      alias: "my-brick",
      $$parsedProperties: {},
    });
    mockUseOutlineEnabled.mockReturnValueOnce(false);
    mockUseBuilderGroupedChildNodes.mockReturnValueOnce([]);
    const wrapper = shallow(<EasyViewEditor nodeUid={1} />);
    const container = wrapper.find(".wrapper");
    expect(container.hasClass("empty")).toBe(true);
    expect(container.prop("style")).toEqual({});
    expect(container.children().length).toBe(0);
  });

  it("should work while editor was wrapper", () => {
    mockUseBuilderParentNode.mockReturnValueOnce({
      $$uid: 1,
      layoutType: "wrapper",
    } as any);
    mockUseOutlineEnabled.mockReturnValueOnce(false);
    mockUseBuilderGroupedChildNodes.mockReturnValueOnce([]);
    mockUseBuilderNode.mockReturnValueOnce({
      type: "brick",
      id: "B-001",
      brick: "easy-view",
      alias: "my-brick",
      $$parsedProperties: {},
      $$uid: 2,
    });

    const wrapper = shallow(<EasyViewEditor nodeUid={2} />);
    expect(wrapper.at(0).prop("editorBodyStyle")).toStrictEqual({
      display: "flex",
      flexDirection: "column",
      height: "100%",
      minHeight: `calc(100vh - var(--editor-brick-overlay-padding) * 2 - var(--page-card-gap) * 4 - 20px - var(--editor-brick-toolbar-height))`,
    });
  });
});
Example #13
Source File: ExperimentList.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('ExperimentList', () => {
  let tree: ShallowWrapper | ReactWrapper;

  const onErrorSpy = jest.fn();
  const listExperimentsSpy = jest.spyOn(Apis.experimentServiceApi, 'listExperiment');
  const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment');
  // We mock this because it uses toLocaleDateString, which causes mismatches between local and CI
  // test enviroments
  const formatDateStringSpy = jest.spyOn(Utils, 'formatDateString');
  const listRunsSpy = jest.spyOn(Apis.runServiceApi, 'listRuns');

  function generateProps(): ExperimentListProps {
    return {
      history: {} as any,
      location: { search: '' } as any,
      match: '' as any,
      onError: onErrorSpy,
    };
  }

  function mockNExperiments(n: number): void {
    getExperimentSpy.mockImplementation(id =>
      Promise.resolve({
        id: 'testexperiment' + id,
        name: 'experiment with id: testexperiment' + id,
      }),
    );
    listExperimentsSpy.mockImplementation(() =>
      Promise.resolve({
        experiments: range(1, n + 1).map(i => {
          return {
            id: 'testexperiment' + i,
            name: 'experiment with id: testexperiment' + i,
          };
        }),
      }),
    );
  }

  beforeEach(() => {
    formatDateStringSpy.mockImplementation((date?: Date) => {
      return date ? '1/2/2019, 12:34:56 PM' : '-';
    });
    onErrorSpy.mockClear();
    listExperimentsSpy.mockClear();
    getExperimentSpy.mockClear();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    if (tree) {
      await tree.unmount();
    }
    jest.resetAllMocks();
  });

  it('renders the empty experience', () => {
    expect(shallow(<ExperimentList {...generateProps()} />)).toMatchSnapshot();
  });

  it('renders the empty experience in ARCHIVED state', () => {
    const props = generateProps();
    props.storageState = ExperimentStorageState.ARCHIVED;
    expect(shallow(<ExperimentList {...props} />)).toMatchSnapshot();
  });

  it('loads experiments whose storage state is not ARCHIVED when storage state equals AVAILABLE', async () => {
    mockNExperiments(1);
    const props = generateProps();
    props.storageState = ExperimentStorageState.AVAILABLE;
    tree = shallow(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({});
    expect(Apis.experimentServiceApi.listExperiment).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      encodeURIComponent(
        JSON.stringify({
          predicates: [
            {
              key: 'storage_state',
              op: PredicateOp.NOTEQUALS,
              string_value: ExperimentStorageState.ARCHIVED.toString(),
            },
          ],
        } as ApiFilter),
      ),
      undefined,
      undefined,
    );
  });

  it('loads experiments whose storage state is ARCHIVED when storage state equals ARCHIVED', async () => {
    mockNExperiments(1);
    const props = generateProps();
    props.storageState = ExperimentStorageState.ARCHIVED;
    tree = shallow(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({});
    expect(Apis.experimentServiceApi.listExperiment).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      encodeURIComponent(
        JSON.stringify({
          predicates: [
            {
              key: 'storage_state',
              op: PredicateOp.EQUALS,
              string_value: ExperimentStorageState.ARCHIVED.toString(),
            },
          ],
        } as ApiFilter),
      ),
      undefined,
      undefined,
    );
  });

  it('augments request filter with storage state predicates', async () => {
    mockNExperiments(1);
    const props = generateProps();
    props.storageState = ExperimentStorageState.ARCHIVED;
    tree = shallow(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({
      filter: encodeURIComponent(
        JSON.stringify({
          predicates: [{ key: 'k', op: 'op', string_value: 'val' }],
        }),
      ),
    });
    expect(Apis.experimentServiceApi.listExperiment).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      encodeURIComponent(
        JSON.stringify({
          predicates: [
            {
              key: 'k',
              op: 'op',
              string_value: 'val',
            },
            {
              key: 'storage_state',
              op: PredicateOp.EQUALS,
              string_value: ExperimentStorageState.ARCHIVED.toString(),
            },
          ],
        } as ApiFilter),
      ),
      undefined,
      undefined,
    );
  });

  it('loads one experiment', async () => {
    mockNExperiments(1);
    const props = generateProps();
    tree = shallow(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({});
    expect(Apis.experimentServiceApi.listExperiment).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      undefined,
      undefined,
      undefined,
    );
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('reloads the experiment when refresh is called', async () => {
    mockNExperiments(0);
    const props = generateProps();
    tree = TestUtils.mountWithRouter(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentList).refresh();
    tree.update();
    expect(Apis.experimentServiceApi.listExperiment).toHaveBeenCalledTimes(2);
    expect(Apis.experimentServiceApi.listExperiment).toHaveBeenLastCalledWith(
      '',
      10,
      ExperimentSortKeys.CREATED_AT + ' desc',
      '',
      undefined,
      undefined,
    );
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('loads multiple experiments', async () => {
    mockNExperiments(5);
    const props = generateProps();
    tree = shallow(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('calls error callback when loading experiment fails', async () => {
    TestUtils.makeErrorResponseOnce(
      jest.spyOn(Apis.experimentServiceApi, 'listExperiment'),
      'bad stuff happened',
    );
    const props = generateProps();
    tree = shallow(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({});
    expect(props.onError).toHaveBeenLastCalledWith(
      'Error: failed to list experiments: ',
      new Error('bad stuff happened'),
    );
  });

  it('loads runs for a given experiment id when it is expanded', async () => {
    listRunsSpy.mockImplementation(() => {});
    mockNExperiments(1);
    const props = generateProps();
    tree = TestUtils.mountWithRouter(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({});
    tree.update();
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree.state()).toHaveProperty('displayExperiments', [
      {
        expandState: ExpandState.COLLAPSED,
        id: 'testexperiment1',
        name: 'experiment with id: testexperiment1',
      },
    ]);
    // Expand the first experiment
    tree
      .find('button[aria-label="Expand"]')
      .at(0)
      .simulate('click');
    await listRunsSpy;
    tree.update();
    expect(tree.state()).toHaveProperty('displayExperiments', [
      {
        expandState: ExpandState.EXPANDED,
        id: 'testexperiment1',
        name: 'experiment with id: testexperiment1',
      },
    ]);
    expect(Apis.runServiceApi.listRuns).toHaveBeenCalledTimes(1);
    expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
      '',
      10,
      'created_at desc',
      'EXPERIMENT',
      'testexperiment1',
      encodeURIComponent(
        JSON.stringify({
          predicates: [
            {
              key: 'storage_state',
              op: PredicateOp.NOTEQUALS,
              string_value: RunStorageState.ARCHIVED.toString(),
            },
          ],
        } as ApiFilter),
      ),
    );
  });

  it('loads runs for a given experiment id with augumented storage state when it is expanded', async () => {
    listRunsSpy.mockImplementation(() => {});
    mockNExperiments(1);
    const props = generateProps();
    props.storageState = ExperimentStorageState.ARCHIVED;
    tree = TestUtils.mountWithRouter(<ExperimentList {...props} />);
    await (tree.instance() as ExperimentListTest)._loadExperiments({});
    tree.update();
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree.state()).toHaveProperty('displayExperiments', [
      {
        expandState: ExpandState.COLLAPSED,
        id: 'testexperiment1',
        name: 'experiment with id: testexperiment1',
      },
    ]);
    // Expand the first experiment
    tree
      .find('button[aria-label="Expand"]')
      .at(0)
      .simulate('click');
    await listRunsSpy;
    tree.update();
    expect(tree.state()).toHaveProperty('displayExperiments', [
      {
        expandState: ExpandState.EXPANDED,
        id: 'testexperiment1',
        name: 'experiment with id: testexperiment1',
      },
    ]);
    expect(Apis.runServiceApi.listRuns).toHaveBeenCalledTimes(1);
    expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
      '',
      10,
      'created_at desc',
      'EXPERIMENT',
      'testexperiment1',
      encodeURIComponent(
        JSON.stringify({
          predicates: [
            {
              key: 'storage_state',
              op: PredicateOp.EQUALS,
              string_value: RunStorageState.ARCHIVED.toString(),
            },
          ],
        } as ApiFilter),
      ),
    );
  });
});
Example #14
Source File: App.test.tsx    From nautilus with MIT License 4 votes vote down vote up
describe('Testing App Stateful Component', () => {
  let wrapper: ShallowWrapper<{}, State, App>;
  beforeEach(() => {
    wrapper = shallow(<App />);
  });

  it('renders correctly', () => {
    const tree = renderer.create(<App />).toJSON();
    expect(tree).toMatchSnapshot();
  });

  describe('render()', () => {
    it('should render child components', () => {
      expect(wrapper.find(LeftNav)).toHaveLength(1);
      expect(wrapper.find(OptionBar)).toHaveLength(1);
      expect(wrapper.find(D3Wrapper)).toHaveLength(1);
    });
  });

  describe('setSelectedContainer()', () => {
    it('should updated selectedContainer in state', () => {
      wrapper.instance().setSelectedContainer('tester-service');
      expect(wrapper.state().selectedContainer).toBe('tester-service');
      wrapper.instance().setSelectedContainer('api-service');
      expect(wrapper.state().selectedContainer).toBe('api-service');
    });
  });

  describe('updateView()', () => {
    it('should update view in state', () => {
      wrapper.instance().updateView('depends_on');
      expect(wrapper.state().view).toBe('depends_on');
    });
    it('should clear selectedNetwork in state', () => {
      wrapper.state().selectedNetwork = 'dummy-network';
      wrapper.instance().updateView('depends_on');
      expect(wrapper.state().selectedNetwork).toBe('');
    });
  });

  describe('updateOption()', () => {
    beforeEach(() => {
      wrapper.state().options = {
        ports: false,
        volumes: false,
        selectAll: false,
      };
    });
    it('should toggle ports option', () => {
      wrapper.instance().updateOption('ports');
      expect(wrapper.state().options.ports).toBe(true);
      wrapper.instance().updateOption('ports');
      expect(wrapper.state().options.ports).toBe(false);
    });
    it('should toggle volumes option', () => {
      wrapper.instance().updateOption('volumes');
      expect(wrapper.state().options.volumes).toBe(true);
      wrapper.instance().updateOption('volumes');
      expect(wrapper.state().options.volumes).toBe(false);
    });
    it('should toggle selectAll option', () => {
      wrapper.instance().updateOption('selectAll');
      expect(wrapper.state().options.selectAll).toBe(true);
      wrapper.instance().updateOption('selectAll');
      expect(wrapper.state().options.selectAll).toBe(false);
    });
    it('selectAll should toggle ports and options', () => {
      wrapper.instance().updateOption('selectAll');
      expect(wrapper.state().options.ports).toBe(true);
      expect(wrapper.state().options.volumes).toBe(true);
      wrapper.instance().updateOption('selectAll');
      expect(wrapper.state().options.ports).toBe(false);
      expect(wrapper.state().options.volumes).toBe(false);
    });
    it('selectAll should should reflect all being selected or not', () => {
      wrapper.instance().updateOption('ports');
      wrapper.instance().updateOption('volumes');
      expect(wrapper.state().options.selectAll).toBe(true);
      wrapper.instance().updateOption('ports');
      expect(wrapper.state().options.selectAll).toBe(false);
    });
  });

  describe('selectNetwork()', () => {
    it('should update view to "networks"', () => {
      wrapper.instance().selectNetwork('dummy-network');
      expect(wrapper.state().view).toBe('networks');
    });
    it('should updated selectedNetwork to passed in string', () => {
      expect(wrapper.state().selectedNetwork).toBe('dummy-network');
    });
  });

  describe('convertAndStoreYamlJSON()', () => {
    let yamlText: string;

    beforeAll(() => {
      yamlText = fs
        .readFileSync(path.resolve(__dirname, '../samples/docker-compose1.yml'))
        .toString();
      wrapper.instance().convertAndStoreYamlJSON(yamlText);
    });

    afterAll(() => {
      delete window.d3State;
      localStorage.removeItem('state');
    });

    it('should update d3State of window', () => {
      expect(window.d3State).toBeTruthy();
      expect(window.d3State.treeDepth).toBe(2);
      expect(window.d3State.simulation).toBeTruthy();
      expect(window.d3State.serviceGraph.nodes).toBeTruthy();
      expect(window.d3State.serviceGraph.links).toBeTruthy();
    });
    it('should set the localstorage with item key "state"', () => {
      expect(localStorage.getItem('state')).toBeTruthy();
    });
    it('should update services in state', () => {
      const serviceNames = Object.keys(wrapper.state().services);
      expect(serviceNames.length).toBe(5);
      const check = serviceNames.filter(
        (n) =>
          n === 'vote' ||
          n === 'result' ||
          n === 'worker' ||
          n === 'redis' ||
          n === 'db',
      );
      expect(check.length).toBe(5);
    });
    it('should update volumes in state', () => {
      const volumes = wrapper.state().volumes;
      expect(volumes.hasOwnProperty('db-data')).toBe(true);
    });
    it('should update bindmounts in state', () => {
      const bindMounts = wrapper.state().bindMounts;
      expect(bindMounts.length).toBe(2);
      const check = bindMounts.filter(
        (n) => n === './vote' || n === './result',
      );
      expect(check.length).toBe(2);
    });
    it('should update networks in state', () => {
      const networks = Object.keys(wrapper.state().networks);
      expect(networks.length).toBe(2);
      const check = networks.filter(
        (n) => n === 'front-tier' || n === 'back-tier',
      );
      expect(check.length).toBe(2);
    });
    it('should set fileOpened to true', () => {
      expect(wrapper.state().fileOpened).toBe(true);
    });
  });

  describe('componentDidMount()', () => {
    let state = {
      fileOpened: true,
      services: {
        db: {
          image: 'postgres',
          environment: [
            'POSTGRES_MULTIPLE_DATABASES=dpc_attribution,dpc_queue,dpc_auth,dpc_consent',
            'POSTGRES_USER=postgres',
            'POSTGRES_PASSWORD=dpc-safe',
          ],
          ports: ['5432:5432'],
          volumes: ['./docker/postgres:/docker-entrypoint-initdb.d'],
        },
        aggregation: {
          image:
            '${ECR_HOST:-755619740999.dkr.ecr.us-east-1.amazonaws.com/dpc-aggregation}:latest',
          ports: ['9901:9900'],
          env_file: ['./ops/config/decrypted/local.env'],
          environment: ['ENV=local', 'JACOCO=${REPORT_COVERAGE}'],
          depends_on: ['db'],
          volumes: [
            'export-volume:/app/data',
            './jacocoReport/dpc-aggregation:/jacoco-report',
          ],
        },
        attribution: {
          image:
            '${ECR_HOST:-755619740999.dkr.ecr.us-east-1.amazonaws.com/dpc-attribution}:latest',
          depends_on: ['db'],
          environment: ['ENV=local', 'JACOCO=${REPORT_COVERAGE}'],
          ports: ['3500:8080', '9902:9900'],
          volumes: ['./jacocoReport/dpc-attribution:/jacoco-report'],
        },
        api: {
          image:
            '${ECR_HOST:-755619740999.dkr.ecr.us-east-1.amazonaws.com/dpc-api}:latest',
          ports: ['3002:3002', '9903:9900'],
          environment: [
            'attributionURL=http://attribution:8080/v1/',
            'ENV=local',
            'JACOCO=${REPORT_COVERAGE}',
            'exportPath=/app/data',
            'JVM_FLAGS=-Ddpc.api.authenticationDisabled=${AUTH_DISABLED:-false}',
          ],
          depends_on: ['attribution'],
          volumes: [
            'export-volume:/app/data',
            './jacocoReport/dpc-api:/jacoco-report',
          ],
        },
        consent: {
          image:
            '${ECR_HOST:-755619740999.dkr.ecr.us-east-1.amazonaws.com/dpc-consent}:latest',
          depends_on: ['db'],
          environment: ['ENV=local', 'JACOCO=${REPORT_COVERAGE}'],
          ports: ['3600:3600', '9004:9900'],
          volumes: ['./jacocoReport/dpc-consent:/jacoco-report'],
        },
        start_core_dependencies: {
          image: 'dadarek/wait-for-dependencies',
          depends_on: ['db'],
          command: 'db:5432',
        },
        start_api_dependencies: {
          image: 'dadarek/wait-for-dependencies',
          depends_on: ['attribution', 'aggregation'],
          command: 'attribution:8080 aggregation:9900',
        },
        start_api: {
          image: 'dadarek/wait-for-dependencies',
          depends_on: ['api'],
          command: 'api:3002',
        },
      },
      volumes: {
        'export-volume': {
          driver: 'local',
          driver_opts: { type: 'none', device: '/tmp', o: 'bind' },
        },
      },
      networks: {},
      bindMounts: [
        './docker/postgres',
        './jacocoReport/dpc-aggregation',
        './jacocoReport/dpc-attribution',
        './jacocoReport/dpc-api',
        './jacocoReport/dpc-consent',
      ],
    };
    beforeAll(() => {
      localStorage.setItem('state', JSON.stringify(state));
      wrapper = shallow(<App />);
    });
    afterAll(() => {
      delete window.d3State;
      localStorage.removeItem('state');
    });

    it('should set state with state from local storage', () => {
      const appState = wrapper.state();
      expect(appState.services).toEqual(state.services);
      expect(appState.volumes).toEqual(state.volumes);
      expect(appState.fileOpened).toBe(true);
      expect(appState.bindMounts).toEqual(state.bindMounts);
    });

    it('should set d3State on the window', () => {
      expect(window.d3State).toBeTruthy();
      expect(window.d3State.treeDepth).toBe(4);
      expect(window.d3State.simulation).toBeTruthy();
      expect(window.d3State.serviceGraph.nodes).toBeTruthy();
      expect(window.d3State.serviceGraph.links).toBeTruthy();
    });
  });
});
Example #15
Source File: Metric.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('Metric', () => {
  let tree: ShallowWrapper | ReactWrapper;

  const onErrorSpy = jest.fn();

  beforeEach(() => {
    onErrorSpy.mockClear();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    if (tree) {
      await tree.unmount();
    }
    jest.resetAllMocks();
  });

  it('renders an empty metric when there is no metric', () => {
    tree = shallow(<Metric />);
    expect(tree).toMatchSnapshot();
  });

  it('renders an empty metric when metric has no value', () => {
    tree = shallow(<Metric metric={{}} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders a metric when metric has value and percentage format', () => {
    tree = shallow(<Metric metric={{ format: RunMetricFormat.PERCENTAGE, number_value: 0.54 }} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders an empty metric when metric has no metadata and unspecified format', () => {
    tree = shallow(<Metric metric={{ format: RunMetricFormat.UNSPECIFIED, number_value: 0.54 }} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders an empty metric when metric has no metadata and raw format', () => {
    tree = shallow(<Metric metric={{ format: RunMetricFormat.RAW, number_value: 0.54 }} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders a metric when metric has max and min value of 0', () => {
    tree = shallow(
      <Metric
        metadata={{ name: 'some metric', count: 1, maxValue: 0, minValue: 0 }}
        metric={{ format: RunMetricFormat.RAW, number_value: 0.54 }}
      />,
    );
    expect(tree).toMatchSnapshot();
  });

  it('renders a metric and does not log an error when metric is between max and min value', () => {
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
    tree = shallow(
      <Metric
        metadata={{ name: 'some metric', count: 1, maxValue: 1, minValue: 0 }}
        metric={{ format: RunMetricFormat.RAW, number_value: 0.54 }}
      />,
    );
    expect(consoleSpy).toHaveBeenCalledTimes(0);
    expect(tree).toMatchSnapshot();
  });

  it('renders a metric and logs an error when metric has value less than min value', () => {
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
    tree = shallow(
      <Metric
        metadata={{ name: 'some metric', count: 1, maxValue: 1, minValue: 0 }}
        metric={{ format: RunMetricFormat.RAW, number_value: -0.54 }}
      />,
    );
    expect(consoleSpy).toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('renders a metric and logs an error when metric has value greater than max value', () => {
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
    tree = shallow(
      <Metric
        metadata={{ name: 'some metric', count: 1, maxValue: 1, minValue: 0 }}
        metric={{ format: RunMetricFormat.RAW, number_value: 2 }}
      />,
    );
    expect(consoleSpy).toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });
});
Example #16
Source File: RunList.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('RunList', () => {
  let tree: ShallowWrapper | ReactWrapper;

  const onErrorSpy = jest.fn();
  const listRunsSpy = jest.spyOn(Apis.runServiceApi, 'listRuns');
  const getRunSpy = jest.spyOn(Apis.runServiceApi, 'getRun');
  const getPipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipeline');
  const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment');
  // We mock this because it uses toLocaleDateString, which causes mismatches between local and CI
  // test enviroments
  const formatDateStringSpy = jest.spyOn(Utils, 'formatDateString');

  function generateProps(): RunListProps {
    return {
      history: {} as any,
      location: { search: '' } as any,
      match: '' as any,
      onError: onErrorSpy,
    };
  }

  function mockNRuns(n: number, runTemplate: Partial<ApiRunDetail>): void {
    getRunSpy.mockImplementation(id =>
      Promise.resolve(
        produce(runTemplate, draft => {
          draft.run = draft.run || {};
          draft.run.id = id;
          draft.run.name = 'run with id: ' + id;
        }),
      ),
    );

    listRunsSpy.mockImplementation(() =>
      Promise.resolve({
        runs: range(1, n + 1).map(i => {
          if (runTemplate.run) {
            return produce(runTemplate.run as Partial<ApiRun>, draft => {
              draft.id = 'testrun' + i;
              draft.name = 'run with id: testrun' + i;
            });
          }
          return {
            id: 'testrun' + i,
            name: 'run with id: testrun' + i,
          } as ApiRun;
        }),
      }),
    );

    getPipelineSpy.mockImplementation(() => ({ name: 'some pipeline' }));
    getExperimentSpy.mockImplementation(() => ({ name: 'some experiment' }));
  }

  function getMountedInstance(): RunList {
    tree = TestUtils.mountWithRouter(<RunList {...generateProps()} />);
    return tree.instance() as RunList;
  }

  function getShallowInstance(): RunList {
    tree = shallow(<RunList {...generateProps()} />);
    return tree.instance() as RunList;
  }

  beforeEach(() => {
    formatDateStringSpy.mockImplementation((date?: Date) => {
      return date ? '1/2/2019, 12:34:56 PM' : '-';
    });
    onErrorSpy.mockClear();
    listRunsSpy.mockClear();
    getRunSpy.mockClear();
    getPipelineSpy.mockClear();
    getExperimentSpy.mockClear();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    if (tree) {
      await tree.unmount();
    }
    jest.resetAllMocks();
  });

  it('renders the empty experience', () => {
    expect(shallow(<RunList {...generateProps()} />)).toMatchSnapshot();
  });

  describe('in archived state', () => {
    it('renders the empty experience', () => {
      const props = generateProps();
      props.storageState = RunStorageState.ARCHIVED;
      expect(shallow(<RunList {...props} />)).toMatchSnapshot();
    });

    it('loads runs whose storage state is not ARCHIVED when storage state equals AVAILABLE', async () => {
      mockNRuns(1, {});
      const props = generateProps();
      props.storageState = RunStorageState.AVAILABLE;
      tree = shallow(<RunList {...props} />);
      await (tree.instance() as RunListTest)._loadRuns({});
      expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
        undefined,
        undefined,
        undefined,
        undefined,
        undefined,
        encodeURIComponent(
          JSON.stringify({
            predicates: [
              {
                key: 'storage_state',
                op: PredicateOp.NOTEQUALS,
                string_value: RunStorageState.ARCHIVED.toString(),
              },
            ],
          } as ApiFilter),
        ),
      );
    });

    it('loads runs whose storage state is ARCHIVED when storage state equals ARCHIVED', async () => {
      mockNRuns(1, {});
      const props = generateProps();
      props.storageState = RunStorageState.ARCHIVED;
      tree = shallow(<RunList {...props} />);
      await (tree.instance() as RunListTest)._loadRuns({});
      expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
        undefined,
        undefined,
        undefined,
        undefined,
        undefined,
        encodeURIComponent(
          JSON.stringify({
            predicates: [
              {
                key: 'storage_state',
                op: PredicateOp.EQUALS,
                string_value: RunStorageState.ARCHIVED.toString(),
              },
            ],
          } as ApiFilter),
        ),
      );
    });

    it('augments request filter with storage state predicates', async () => {
      mockNRuns(1, {});
      const props = generateProps();
      props.storageState = RunStorageState.ARCHIVED;
      tree = shallow(<RunList {...props} />);
      await (tree.instance() as RunListTest)._loadRuns({
        filter: encodeURIComponent(
          JSON.stringify({
            predicates: [{ key: 'k', op: 'op', string_value: 'val' }],
          }),
        ),
      });
      expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
        undefined,
        undefined,
        undefined,
        undefined,
        undefined,
        encodeURIComponent(
          JSON.stringify({
            predicates: [
              {
                key: 'k',
                op: 'op',
                string_value: 'val',
              },
              {
                key: 'storage_state',
                op: PredicateOp.EQUALS,
                string_value: RunStorageState.ARCHIVED.toString(),
              },
            ],
          } as ApiFilter),
        ),
      );
    });
  });

  it('loads one run', async () => {
    mockNRuns(1, {});
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      undefined,
      undefined,
      undefined,
    );
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('reloads the run when refresh is called', async () => {
    mockNRuns(0, {});
    const props = generateProps();
    tree = TestUtils.mountWithRouter(<RunList {...props} />);
    await (tree.instance() as RunList).refresh();
    tree.update();
    expect(Apis.runServiceApi.listRuns).toHaveBeenCalledTimes(2);
    expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
      '',
      10,
      RunSortKeys.CREATED_AT + ' desc',
      undefined,
      undefined,
      '',
    );
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('loads multiple runs', async () => {
    mockNRuns(5, {});
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('calls error callback when loading runs fails', async () => {
    TestUtils.makeErrorResponseOnce(
      jest.spyOn(Apis.runServiceApi, 'listRuns'),
      'bad stuff happened',
    );
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).toHaveBeenLastCalledWith(
      'Error: failed to fetch runs.',
      new Error('bad stuff happened'),
    );
  });

  it('displays error in run row if pipeline could not be fetched', async () => {
    mockNRuns(1, { run: { pipeline_spec: { pipeline_id: 'test-pipeline-id' } } });
    TestUtils.makeErrorResponseOnce(getPipelineSpy, 'bad stuff happened');
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(tree).toMatchSnapshot();
  });

  it('displays error in run row if experiment could not be fetched', async () => {
    mockNRuns(1, {
      run: {
        resource_references: [
          {
            key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT },
          },
        ],
      },
    });
    TestUtils.makeErrorResponseOnce(getExperimentSpy, 'bad stuff happened');
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(tree).toMatchSnapshot();
  });

  it('displays error in run row if it failed to parse (run list mask)', async () => {
    TestUtils.makeErrorResponseOnce(jest.spyOn(Apis.runServiceApi, 'getRun'), 'bad stuff happened');
    const props = generateProps();
    props.runIdListMask = ['testrun1', 'testrun2'];
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(tree).toMatchSnapshot();
  });

  it('shows run time for each run', async () => {
    mockNRuns(1, {
      run: {
        created_at: new Date(2018, 10, 10, 10, 10, 10),
        finished_at: new Date(2018, 10, 10, 11, 11, 11),
        status: 'Succeeded',
      },
    });
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('loads runs for a given experiment id', async () => {
    mockNRuns(1, {});
    const props = generateProps();
    props.experimentIdMask = 'experiment1';
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      'EXPERIMENT',
      'experiment1',
      undefined,
    );
  });

  it('loads runs for a given namespace', async () => {
    mockNRuns(1, {});
    const props = generateProps();
    props.namespaceMask = 'namespace1';
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(Apis.runServiceApi.listRuns).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      'NAMESPACE',
      'namespace1',
      undefined,
    );
  });

  it('loads given list of runs only', async () => {
    mockNRuns(5, {});
    const props = generateProps();
    props.runIdListMask = ['run1', 'run2'];
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(Apis.runServiceApi.listRuns).not.toHaveBeenCalled();
    expect(Apis.runServiceApi.getRun).toHaveBeenCalledTimes(2);
    expect(Apis.runServiceApi.getRun).toHaveBeenCalledWith('run1');
    expect(Apis.runServiceApi.getRun).toHaveBeenCalledWith('run2');
  });

  it('adds metrics columns', async () => {
    mockNRuns(2, {
      run: {
        metrics: [
          { name: 'metric1', number_value: 5 },
          { name: 'metric2', number_value: 10 },
        ],
        status: 'Succeeded',
      },
    });
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('shows pipeline name', async () => {
    mockNRuns(1, {
      run: { pipeline_spec: { pipeline_id: 'test-pipeline-id', pipeline_name: 'pipeline name' } },
    });
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('retrieves pipeline from backend to display name if not in spec', async () => {
    mockNRuns(1, {
      run: { pipeline_spec: { pipeline_id: 'test-pipeline-id' /* no pipeline_name */ } },
    });
    getPipelineSpy.mockImplementationOnce(() => ({ name: 'test pipeline' }));
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('shows link to recurring run config', async () => {
    mockNRuns(1, {
      run: {
        resource_references: [
          {
            key: { id: 'test-recurring-run-id', type: ApiResourceType.JOB },
          },
        ],
      },
    });
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('shows experiment name', async () => {
    mockNRuns(1, {
      run: {
        resource_references: [
          {
            key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT },
          },
        ],
      },
    });
    getExperimentSpy.mockImplementationOnce(() => ({ name: 'test experiment' }));
    const props = generateProps();
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('hides experiment name if instructed', async () => {
    mockNRuns(1, {
      run: {
        resource_references: [
          {
            key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT },
          },
        ],
      },
    });
    getExperimentSpy.mockImplementationOnce(() => ({ name: 'test experiment' }));
    const props = generateProps();
    props.hideExperimentColumn = true;
    tree = shallow(<RunList {...props} />);
    await (tree.instance() as RunListTest)._loadRuns({});
    expect(props.onError).not.toHaveBeenCalled();
    expect(tree).toMatchSnapshot();
  });

  it('renders run name as link to its details page', () => {
    expect(
      getMountedInstance()._nameCustomRenderer({ value: 'test run', id: 'run-id' }),
    ).toMatchSnapshot();
  });

  it('renders pipeline name as link to its details page', () => {
    expect(
      getMountedInstance()._pipelineVersionCustomRenderer({
        id: 'run-id',
        value: { displayName: 'test pipeline', pipelineId: 'pipeline-id', usePlaceholder: false },
      }),
    ).toMatchSnapshot();
  });

  it('handles no pipeline id given', () => {
    expect(
      getMountedInstance()._pipelineVersionCustomRenderer({
        id: 'run-id',
        value: { displayName: 'test pipeline', usePlaceholder: false },
      }),
    ).toMatchSnapshot();
  });

  it('shows "View pipeline" button if pipeline is embedded in run', () => {
    expect(
      getMountedInstance()._pipelineVersionCustomRenderer({
        id: 'run-id',
        value: { displayName: 'test pipeline', pipelineId: 'pipeline-id', usePlaceholder: true },
      }),
    ).toMatchSnapshot();
  });

  it('handles no pipeline name', () => {
    expect(
      getMountedInstance()._pipelineVersionCustomRenderer({
        id: 'run-id',
        value: { /* no displayName */ usePlaceholder: true },
      }),
    ).toMatchSnapshot();
  });

  it('renders pipeline name as link to its details page', () => {
    expect(
      getMountedInstance()._recurringRunCustomRenderer({
        id: 'run-id',
        value: { id: 'recurring-run-id' },
      }),
    ).toMatchSnapshot();
  });

  it('renders experiment name as link to its details page', () => {
    expect(
      getMountedInstance()._experimentCustomRenderer({
        id: 'run-id',
        value: { displayName: 'test experiment', id: 'experiment-id' },
      }),
    ).toMatchSnapshot();
  });

  it('renders no experiment name', () => {
    expect(
      getMountedInstance()._experimentCustomRenderer({
        id: 'run-id',
        value: { /* no displayName */ id: 'experiment-id' },
      }),
    ).toMatchSnapshot();
  });

  it('renders status as icon', () => {
    expect(
      getShallowInstance()._statusCustomRenderer({ value: NodePhase.SUCCEEDED, id: 'run-id' }),
    ).toMatchSnapshot();
  });

  it('renders metric buffer', () => {
    expect(
      getShallowInstance()._metricBufferCustomRenderer({ value: {}, id: 'run-id' }),
    ).toMatchSnapshot();
  });

  it('renders an empty metric when there is no metric', () => {
    expect(
      getShallowInstance()._metricCustomRenderer({ value: undefined, id: 'run-id' }),
    ).toMatchSnapshot();
  });

  it('renders an empty metric when metric is empty', () => {
    expect(
      getShallowInstance()._metricCustomRenderer({ value: {}, id: 'run-id' }),
    ).toMatchSnapshot();
  });

  it('renders an empty metric when metric value is empty', () => {
    expect(
      getShallowInstance()._metricCustomRenderer({ value: { metric: {} }, id: 'run-id' }),
    ).toMatchSnapshot();
  });

  it('renders percentage metric', () => {
    expect(
      getShallowInstance()._metricCustomRenderer({
        id: 'run-id',
        value: { metric: { number_value: 0.3, format: RunMetricFormat.PERCENTAGE } },
      }),
    ).toMatchSnapshot();
  });

  it('renders raw metric', () => {
    expect(
      getShallowInstance()._metricCustomRenderer({
        id: 'run-id',
        value: {
          metadata: { count: 1, maxValue: 100, minValue: 10 } as MetricMetadata,
          metric: { number_value: 55 } as ApiRunMetric,
        },
      }),
    ).toMatchSnapshot();
  });

  it('renders pipeline version name as link to its details page', () => {
    expect(
      getMountedInstance()._pipelineVersionCustomRenderer({
        id: 'run-id',
        value: {
          displayName: 'test pipeline version',
          pipelineId: 'pipeline-id',
          usePlaceholder: false,
          versionId: 'version-id',
        },
      }),
    ).toMatchSnapshot();
  });
});
Example #17
Source File: RunDetails.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('RunDetails', () => {
  let updateBannerSpy: any;
  let updateDialogSpy: any;
  let updateSnackbarSpy: any;
  let updateToolbarSpy: any;
  let historyPushSpy: any;
  let getRunSpy: any;
  let getExperimentSpy: any;
  let isCustomVisualizationsAllowedSpy: any;
  let getPodLogsSpy: any;
  let pathsParser: any;
  let pathsWithStepsParser: any;
  let loaderSpy: any;
  let retryRunSpy: any;
  let terminateRunSpy: any;
  let artifactTypesSpy: any;
  let formatDateStringSpy: any;

  let testRun: ApiRunDetail = {};
  let tree: ShallowWrapper | ReactWrapper;

  function generateProps(): RunDetailsInternalProps & PageProps {
    const pageProps: PageProps = {
      history: { push: historyPushSpy } as any,
      location: '' as any,
      match: { params: { [RouteParams.runId]: testRun.run!.id }, isExact: true, path: '', url: '' },
      toolbarProps: { actions: {}, breadcrumbs: [], pageTitle: '' },
      updateBanner: updateBannerSpy,
      updateDialog: updateDialogSpy,
      updateSnackbar: updateSnackbarSpy,
      updateToolbar: updateToolbarSpy,
    };
    return Object.assign(pageProps, {
      toolbarProps: new RunDetails(pageProps).getInitialToolbarState(),
      gkeMetadata: {},
    });
  }

  beforeEach(() => {
    // The RunDetails page uses timers to periodically refresh
    jest.useFakeTimers();
    // TODO: mute error only for tests that are expected to have error
    jest.spyOn(console, 'error').mockImplementation(() => null);

    testRun = {
      pipeline_runtime: {
        workflow_manifest: '{}',
      },
      run: {
        created_at: new Date(2018, 8, 5, 4, 3, 2),
        description: 'test run description',
        id: 'test-run-id',
        name: 'test run',
        pipeline_spec: {
          parameters: [{ name: 'param1', value: 'value1' }],
          pipeline_id: 'some-pipeline-id',
        },
        status: 'Succeeded',
      },
    };
    updateBannerSpy = jest.fn();
    updateDialogSpy = jest.fn();
    updateSnackbarSpy = jest.fn();
    updateToolbarSpy = jest.fn();
    historyPushSpy = jest.fn();
    getRunSpy = jest.spyOn(Apis.runServiceApi, 'getRun');
    getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment');
    isCustomVisualizationsAllowedSpy = jest.spyOn(Apis, 'areCustomVisualizationsAllowed');
    getPodLogsSpy = jest.spyOn(Apis, 'getPodLogs');
    pathsParser = jest.spyOn(WorkflowParser, 'loadNodeOutputPaths');
    pathsWithStepsParser = jest.spyOn(WorkflowParser, 'loadAllOutputPathsWithStepNames');
    loaderSpy = jest.spyOn(OutputArtifactLoader, 'load');
    retryRunSpy = jest.spyOn(Apis.runServiceApi, 'retryRun');
    terminateRunSpy = jest.spyOn(Apis.runServiceApi, 'terminateRun');
    artifactTypesSpy = jest.spyOn(Api.getInstance().metadataStoreService, 'getArtifactTypes');
    // We mock this because it uses toLocaleDateString, which causes mismatches between local and CI
    // test environments
    formatDateStringSpy = jest.spyOn(Utils, 'formatDateString');

    getRunSpy.mockImplementation(() => Promise.resolve(testRun));
    getExperimentSpy.mockImplementation(() =>
      Promise.resolve({ id: 'some-experiment-id', name: 'some experiment' }),
    );
    isCustomVisualizationsAllowedSpy.mockImplementation(() => Promise.resolve(false));
    getPodLogsSpy.mockImplementation(() => 'test logs');
    pathsParser.mockImplementation(() => []);
    pathsWithStepsParser.mockImplementation(() => []);
    loaderSpy.mockImplementation(() => Promise.resolve([]));
    formatDateStringSpy.mockImplementation(() => '1/2/2019, 12:34:56 PM');
    artifactTypesSpy.mockImplementation(() => {
      // TODO: This is temporary workaround to let tfx artifact resolving logic fail early.
      // We should add proper testing for those cases later too.
      const response = new GetArtifactTypesResponse();
      response.setArtifactTypesList([]);
      return response;
    });
  });

  afterEach(async () => {
    if (tree && tree.exists()) {
      // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
      // depends on mocks/spies
      await tree.unmount();
    }
    jest.resetAllMocks();
    jest.restoreAllMocks();
  });

  it('shows success run status in page title', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    const lastCall = updateToolbarSpy.mock.calls[2][0];
    expect(lastCall.pageTitle).toMatchSnapshot();
  });

  it('shows failure run status in page title', async () => {
    testRun.run!.status = 'Failed';
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    const lastCall = updateToolbarSpy.mock.calls[2][0];
    expect(lastCall.pageTitle).toMatchSnapshot();
  });

  it('has a clone button, clicking it navigates to new run page', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as RunDetails;
    const cloneBtn = instance.getInitialToolbarState().actions[ButtonKeys.CLONE_RUN];
    expect(cloneBtn).toBeDefined();
    await cloneBtn!.action();
    expect(historyPushSpy).toHaveBeenCalledTimes(1);
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      RoutePage.NEW_RUN + `?${QUERY_PARAMS.cloneFromRun}=${testRun.run!.id}`,
    );
  });

  it('clicking the clone button when the page is half-loaded navigates to new run page with run id', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    // Intentionally don't wait until all network requests finish.
    const instance = tree.instance() as RunDetails;
    const cloneBtn = instance.getInitialToolbarState().actions[ButtonKeys.CLONE_RUN];
    expect(cloneBtn).toBeDefined();
    await cloneBtn!.action();
    expect(historyPushSpy).toHaveBeenCalledTimes(1);
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      RoutePage.NEW_RUN + `?${QUERY_PARAMS.cloneFromRun}=${testRun.run!.id}`,
    );
  });

  it('has a retry button', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as RunDetails;
    const retryBtn = instance.getInitialToolbarState().actions[ButtonKeys.RETRY];
    expect(retryBtn).toBeDefined();
  });

  it('shows retry confirmation dialog when retry button is clicked', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    const instance = tree.instance() as RunDetails;
    const retryBtn = instance.getInitialToolbarState().actions[ButtonKeys.RETRY];
    await retryBtn!.action();
    expect(updateDialogSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        title: 'Retry this run?',
      }),
    );
  });

  it('does not call retry API for selected run when retry dialog is canceled', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    const instance = tree.instance() as RunDetails;
    const retryBtn = instance.getInitialToolbarState().actions[ButtonKeys.RETRY];
    await retryBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const cancelBtn = call.buttons.find((b: any) => b.text === 'Cancel');
    await cancelBtn.onClick();
    expect(retryRunSpy).not.toHaveBeenCalled();
  });

  it('calls retry API when retry dialog is confirmed', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as RunDetails;
    const retryBtn = instance.getInitialToolbarState().actions[ButtonKeys.RETRY];
    await retryBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Retry');
    await confirmBtn.onClick();
    expect(retryRunSpy).toHaveBeenCalledTimes(1);
    expect(retryRunSpy).toHaveBeenLastCalledWith(testRun.run!.id);
  });

  it('calls retry API when retry dialog is confirmed and page is half-loaded', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    // Intentionally don't wait until all network requests finish.
    const instance = tree.instance() as RunDetails;
    const retryBtn = instance.getInitialToolbarState().actions[ButtonKeys.RETRY];
    await retryBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Retry');
    await confirmBtn.onClick();
    expect(retryRunSpy).toHaveBeenCalledTimes(1);
    expect(retryRunSpy).toHaveBeenLastCalledWith(testRun.run!.id);
  });

  it('shows an error dialog when retry API fails', async () => {
    retryRunSpy.mockImplementation(() => Promise.reject('mocked error'));

    tree = mount(<RunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RunDetails;
    const retryBtn = instance.getInitialToolbarState().actions[ButtonKeys.RETRY];
    await retryBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Retry');
    await confirmBtn.onClick();
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content: 'Failed to retry run: test-run-id with error: ""mocked error""',
      }),
    );
    // There shouldn't be a snackbar on error.
    expect(updateSnackbarSpy).not.toHaveBeenCalled();
  });

  it('has a terminate button', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as RunDetails;
    const terminateBtn = instance.getInitialToolbarState().actions[ButtonKeys.TERMINATE_RUN];
    expect(terminateBtn).toBeDefined();
  });

  it('shows terminate confirmation dialog when terminate button is clicked', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    const instance = tree.instance() as RunDetails;
    const terminateBtn = instance.getInitialToolbarState().actions[ButtonKeys.TERMINATE_RUN];
    await terminateBtn!.action();
    expect(updateDialogSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        title: 'Terminate this run?',
      }),
    );
  });

  it('does not call terminate API for selected run when terminate dialog is canceled', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    const instance = tree.instance() as RunDetails;
    const terminateBtn = instance.getInitialToolbarState().actions[ButtonKeys.TERMINATE_RUN];
    await terminateBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const cancelBtn = call.buttons.find((b: any) => b.text === 'Cancel');
    await cancelBtn.onClick();
    expect(terminateRunSpy).not.toHaveBeenCalled();
  });

  it('calls terminate API when terminate dialog is confirmed', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as RunDetails;
    const terminateBtn = instance.getInitialToolbarState().actions[ButtonKeys.TERMINATE_RUN];
    await terminateBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Terminate');
    await confirmBtn.onClick();
    expect(terminateRunSpy).toHaveBeenCalledTimes(1);
    expect(terminateRunSpy).toHaveBeenLastCalledWith(testRun.run!.id);
  });

  it('calls terminate API when terminate dialog is confirmed and page is half-loaded', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    // Intentionally don't wait until all network requests finish.
    const instance = tree.instance() as RunDetails;
    const terminateBtn = instance.getInitialToolbarState().actions[ButtonKeys.TERMINATE_RUN];
    await terminateBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Terminate');
    await confirmBtn.onClick();
    expect(terminateRunSpy).toHaveBeenCalledTimes(1);
    expect(terminateRunSpy).toHaveBeenLastCalledWith(testRun.run!.id);
  });

  it('has an Archive button if the run is not archived', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ARCHIVE)).toBeDefined();
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.RESTORE)).toBeUndefined();
  });

  it('shows "All runs" in breadcrumbs if the run is not archived', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        breadcrumbs: [{ displayName: 'All runs', href: RoutePage.RUNS }],
      }),
    );
  });

  it('shows experiment name in breadcrumbs if the run is not archived', async () => {
    testRun.run!.resource_references = [
      { key: { id: 'some-experiment-id', type: ApiResourceType.EXPERIMENT } },
    ];
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        breadcrumbs: [
          { displayName: 'Experiments', href: RoutePage.EXPERIMENTS },
          {
            displayName: 'some experiment',
            href: RoutePage.EXPERIMENT_DETAILS.replace(
              ':' + RouteParams.experimentId,
              'some-experiment-id',
            ),
          },
        ],
      }),
    );
  });

  it('has a Restore button if the run is archived', async () => {
    testRun.run!.storage_state = RunStorageState.ARCHIVED;
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.RESTORE)).toBeDefined();
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ARCHIVE)).toBeUndefined();
  });

  it('shows Archive in breadcrumbs if the run is archived', async () => {
    testRun.run!.storage_state = RunStorageState.ARCHIVED;
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        breadcrumbs: [{ displayName: 'Archive', href: RoutePage.ARCHIVED_RUNS }],
      }),
    );
  });

  it('renders an empty run', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('calls the get run API once to load it', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(getRunSpy).toHaveBeenCalledTimes(1);
    expect(getRunSpy).toHaveBeenLastCalledWith(testRun.run!.id);
  });

  it('shows an error banner if get run API fails', async () => {
    TestUtils.makeErrorResponseOnce(getRunSpy, 'woops');
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once initially to clear
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'woops',
        message:
          'Error: failed to retrieve run: ' +
          testRun.run!.id +
          '. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('shows an error banner if get experiment API fails', async () => {
    testRun.run!.resource_references = [
      { key: { id: 'experiment1', type: ApiResourceType.EXPERIMENT } },
    ];
    TestUtils.makeErrorResponseOnce(getExperimentSpy, 'woops');
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once initially to clear
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'woops',
        message:
          'Error: failed to retrieve run: ' +
          testRun.run!.id +
          '. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('calls the get experiment API once to load it if the run has its reference', async () => {
    testRun.run!.resource_references = [
      { key: { id: 'experiment1', type: ApiResourceType.EXPERIMENT } },
    ];
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(getRunSpy).toHaveBeenCalledTimes(1);
    expect(getRunSpy).toHaveBeenLastCalledWith(testRun.run!.id);
    expect(getExperimentSpy).toHaveBeenCalledTimes(1);
    expect(getExperimentSpy).toHaveBeenLastCalledWith('experiment1');
  });

  it('shows workflow errors as page error', async () => {
    jest
      .spyOn(WorkflowParser, 'getWorkflowError')
      .mockImplementationOnce(() => 'some error message');
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear on init, once for error
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'some error message',
        message:
          'Error: found errors when executing run: ' +
          testRun.run!.id +
          '. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('switches to run output tab, shows empty message', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    tree.find('MD2Tabs').simulate('switch', 1);
    expect(tree.state('selectedTab')).toBe(1);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it("loads the run's outputs in the output tab", async () => {
    pathsWithStepsParser.mockImplementation(() => [
      { stepName: 'step1', path: { source: 'gcs', bucket: 'somebucket', key: 'somekey' } },
    ]);
    pathsParser.mockImplementation(() => [{ source: 'gcs', bucket: 'somebucket', key: 'somekey' }]);
    loaderSpy.mockImplementation(() =>
      Promise.resolve([{ type: PlotType.TENSORBOARD, url: 'some url' }]),
    );
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    tree.find('MD2Tabs').simulate('switch', 1);
    expect(tree.state('selectedTab')).toBe(1);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('switches to config tab', async () => {
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    tree.find('MD2Tabs').simulate('switch', 2);
    expect(tree.state('selectedTab')).toBe(2);
    expect(tree).toMatchSnapshot();
  });

  it('shows run config fields', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      metadata: {
        creationTimestamp: new Date(2018, 6, 5, 4, 3, 2).toISOString(),
      },
      spec: {
        arguments: {
          parameters: [
            {
              name: 'param1',
              value: 'value1',
            },
            {
              name: 'param2',
              value: 'value2',
            },
          ],
        },
      },
      status: {
        finishedAt: new Date(2018, 6, 6, 5, 4, 3).toISOString(),
        phase: 'Skipped',
        startedAt: new Date(2018, 6, 5, 4, 3, 2).toISOString(),
      },
    } as Workflow);
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    tree.find('MD2Tabs').simulate('switch', 2);
    expect(tree.state('selectedTab')).toBe(2);
    expect(tree).toMatchSnapshot();
  });

  it('shows run config fields - handles no description', async () => {
    delete testRun.run!.description;
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      metadata: {
        creationTimestamp: new Date(2018, 6, 5, 4, 3, 2).toISOString(),
      },
      status: {
        finishedAt: new Date(2018, 6, 6, 5, 4, 3).toISOString(),
        phase: 'Skipped',
        startedAt: new Date(2018, 6, 5, 4, 3, 2).toISOString(),
      },
    } as Workflow);
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    tree.find('MD2Tabs').simulate('switch', 2);
    expect(tree.state('selectedTab')).toBe(2);
    expect(tree).toMatchSnapshot();
  });

  it('shows run config fields - handles no metadata', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: {
        finishedAt: new Date(2018, 6, 6, 5, 4, 3).toISOString(),
        phase: 'Skipped',
        startedAt: new Date(2018, 6, 5, 4, 3, 2).toISOString(),
      },
    } as Workflow);
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    tree.find('MD2Tabs').simulate('switch', 2);
    expect(tree.state('selectedTab')).toBe(2);
    expect(tree).toMatchSnapshot();
  });

  it('shows a one-node graph', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('opens side panel when graph node is clicked', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
    expect(tree).toMatchSnapshot();
  });

  it('shows clicked node message in side panel', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: {
        nodes: {
          node1: {
            id: 'node1',
            message: 'some test message',
            phase: 'Succeeded',
          },
        },
      },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    expect(tree.state('selectedNodeDetails')).toHaveProperty(
      'phaseMessage',
      'This step is in ' + testRun.run!.status + ' state with this message: some test message',
    );
    expect(tree.find('Banner')).toMatchInlineSnapshot(`
      <Banner
        message="This step is in Succeeded state with this message: some test message"
        mode="warning"
      />
    `);
  });

  it('shows clicked node output in side pane', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    pathsWithStepsParser.mockImplementation(() => [
      { stepName: 'step1', path: { source: 'gcs', bucket: 'somebucket', key: 'somekey' } },
    ]);
    pathsParser.mockImplementation(() => [{ source: 'gcs', bucket: 'somebucket', key: 'somekey' }]);
    loaderSpy.mockImplementation(() =>
      Promise.resolve([{ type: PlotType.TENSORBOARD, url: 'some url' }]),
    );
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    await pathsParser;
    await pathsWithStepsParser;
    await loaderSpy;
    await artifactTypesSpy;
    await TestUtils.flushPromises();

    // TODO: fix this test and write additional tests for the ArtifactTabContent component.
    // expect(pathsWithStepsParser).toHaveBeenCalledTimes(1); // Loading output list
    // expect(pathsParser).toHaveBeenCalledTimes(1);
    // expect(pathsParser).toHaveBeenLastCalledWith({ id: 'node1' });
    // expect(loaderSpy).toHaveBeenCalledTimes(2);
    // expect(loaderSpy).toHaveBeenLastCalledWith({
    //   bucket: 'somebucket',
    //   key: 'somekey',
    //   source: 'gcs',
    // });
    // expect(tree.state('selectedNodeDetails')).toMatchObject({ id: 'node1' });
    // expect(tree).toMatchSnapshot();
  });

  it('switches to inputs/outputs tab in side pane', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: {
        nodes: {
          node1: {
            id: 'node1',
            inputs: {
              parameters: [{ name: 'input1', value: 'val1' }],
            },
            name: 'node1',
            outputs: {
              parameters: [
                { name: 'output1', value: 'val1' },
                { name: 'output2', value: 'value2' },
              ],
            },
            phase: 'Succeeded',
          },
        },
      },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.INPUT_OUTPUT);
    await TestUtils.flushPromises();
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.INPUT_OUTPUT);
    expect(tree).toMatchSnapshot();
  });

  it('switches to volumes tab in side pane', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.VOLUMES);
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.VOLUMES);
    expect(tree).toMatchSnapshot();
  });

  it('switches to manifest tab in side pane', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.MANIFEST);
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.MANIFEST);
    expect(tree).toMatchSnapshot();
  });

  it('closes side panel when close button is clicked', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    await TestUtils.flushPromises();
    expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
    tree.find('SidePanel').simulate('close');
    expect(tree.state('selectedNodeDetails')).toBeNull();
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('keeps side pane open and on same tab when page is refreshed', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.LOGS);
    expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.LOGS);

    await (tree.instance() as RunDetails).refresh();
    expect(getRunSpy).toHaveBeenCalledTimes(2);
    expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.LOGS);
  });

  it('keeps side pane open and on same tab when more nodes are added after refresh', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: {
        nodes: {
          node1: { id: 'node1' },
          node2: { id: 'node2' },
        },
      },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.LOGS);
    expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.LOGS);

    await (tree.instance() as RunDetails).refresh();
    expect(getRunSpy).toHaveBeenCalledTimes(2);
    expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.LOGS);
  });

  it('keeps side pane open and on same tab when run status changes, shows new status', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.LOGS);
    expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
    expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.LOGS);
    expect(updateToolbarSpy).toHaveBeenCalledTimes(3);

    const thirdCall = updateToolbarSpy.mock.calls[2][0];
    expect(thirdCall.pageTitle).toMatchSnapshot();

    testRun.run!.status = 'Failed';
    await (tree.instance() as RunDetails).refresh();
    const fourthCall = updateToolbarSpy.mock.calls[3][0];
    expect(fourthCall.pageTitle).toMatchSnapshot();
  });

  it('shows node message banner if node receives message after refresh', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1', phase: 'Succeeded', message: '' } } },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.LOGS);
    expect(tree.state('selectedNodeDetails')).toHaveProperty('phaseMessage', undefined);

    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: {
        nodes: { node1: { id: 'node1', phase: 'Succeeded', message: 'some node message' } },
      },
    });
    await (tree.instance() as RunDetails).refresh();
    expect(tree.state('selectedNodeDetails')).toHaveProperty(
      'phaseMessage',
      'This step is in Succeeded state with this message: some node message',
    );
  });

  it('dismisses node message banner if node loses message after refresh', async () => {
    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: {
        nodes: { node1: { id: 'node1', phase: 'Succeeded', message: 'some node message' } },
      },
    });
    tree = shallow(<RunDetails {...generateProps()} />);
    await getRunSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    tree
      .find('MD2Tabs')
      .at(1)
      .simulate('switch', STEP_TABS.LOGS);
    expect(tree.state('selectedNodeDetails')).toHaveProperty(
      'phaseMessage',
      'This step is in Succeeded state with this message: some node message',
    );

    testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
      status: { nodes: { node1: { id: 'node1' } } },
    });
    await (tree.instance() as RunDetails).refresh();
    expect(tree.state('selectedNodeDetails')).toHaveProperty('phaseMessage', undefined);
  });

  [NodePhase.RUNNING, NodePhase.PENDING, NodePhase.UNKNOWN].forEach(unfinishedStatus => {
    it(`displays a spinner if graph is not defined and run has status: ${unfinishedStatus}`, async () => {
      const unfinishedRun = {
        pipeline_runtime: {
          // No graph
          workflow_manifest: '{}',
        },
        run: {
          id: 'test-run-id',
          name: 'test run',
          status: unfinishedStatus,
        },
      };
      getRunSpy.mockImplementationOnce(() => Promise.resolve(unfinishedRun));

      tree = shallow(<RunDetails {...generateProps()} />);
      await getRunSpy;
      await TestUtils.flushPromises();

      expect(tree).toMatchSnapshot();
    });
  });

  [NodePhase.ERROR, NodePhase.FAILED, NodePhase.SUCCEEDED, NodePhase.SKIPPED].forEach(
    finishedStatus => {
      it(`displays a message indicating there is no graph if graph is not defined and run has status: ${finishedStatus}`, async () => {
        const unfinishedRun = {
          pipeline_runtime: {
            // No graph
            workflow_manifest: '{}',
          },
          run: {
            id: 'test-run-id',
            name: 'test run',
            status: finishedStatus,
          },
        };
        getRunSpy.mockImplementationOnce(() => Promise.resolve(unfinishedRun));

        tree = shallow(<RunDetails {...generateProps()} />);
        await getRunSpy;
        await TestUtils.flushPromises();

        expect(tree).toMatchSnapshot();
      });
    },
  );

  it('shows an error banner if the custom visualizations state API fails', async () => {
    TestUtils.makeErrorResponseOnce(isCustomVisualizationsAllowedSpy, 'woops');
    tree = shallow(<RunDetails {...generateProps()} />);
    await isCustomVisualizationsAllowedSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once initially to clear
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'woops',
        message:
          'Error: Unable to enable custom visualizations. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  describe('logs tab', () => {
    it('switches to logs tab in side pane', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: { nodes: { node1: { id: 'node1' } } },
      });
      tree = shallow(<RunDetails {...generateProps()} />);
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.LOGS);
      expect(tree).toMatchSnapshot();
    });

    it('loads and shows logs in side pane', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: { nodes: { node1: { id: 'node1' } } },
      });
      tree = shallow(<RunDetails {...generateProps()} />);
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      await getPodLogsSpy;
      expect(getPodLogsSpy).toHaveBeenCalledTimes(1);
      expect(getPodLogsSpy).toHaveBeenLastCalledWith('node1', undefined);
      expect(tree).toMatchSnapshot();
    });

    it('shows stackdriver link next to logs in GKE', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: { nodes: { node1: { id: 'node1' } } },
      });
      tree = shallow(
        <RunDetails
          {...generateProps()}
          gkeMetadata={{ projectId: 'test-project-id', clusterName: 'test-cluster-name' }}
        />,
      );
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      await getPodLogsSpy;
      await TestUtils.flushPromises();
      expect(tree.find(NODE_DETAILS_SELECTOR)).toMatchInlineSnapshot(`
        <div
          className="page"
          data-testid="run-details-node-details"
        >
          <div
            className="page"
          >
            <div
              className=""
            >
              Logs can also be viewed in
               
              <a
                className="link unstyled"
                href="https://console.cloud.google.com/logs/viewer?project=test-project-id&interval=NO_LIMIT&advancedFilter=resource.type%3D\\"k8s_container\\"%0Aresource.labels.cluster_name:\\"test-cluster-name\\"%0Aresource.labels.pod_name:\\"node1\\""
                rel="noopener noreferrer"
                target="_blank"
              >
                Stackdriver Kubernetes Monitoring
              </a>
              .
            </div>
            <div
              className="pageOverflowHidden"
            >
              <LogViewer
                logLines={
                  Array [
                    "test logs",
                  ]
                }
              />
            </div>
          </div>
        </div>
      `);
    });

    it("loads logs in run's namespace", async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        metadata: { namespace: 'username' },
        status: { nodes: { node1: { id: 'node1' } } },
      });
      tree = shallow(<RunDetails {...generateProps()} />);
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      await getPodLogsSpy;
      expect(getPodLogsSpy).toHaveBeenCalledTimes(1);
      expect(getPodLogsSpy).toHaveBeenLastCalledWith('node1', 'username');
    });

    it('shows warning banner and link to Stackdriver in logs area if fetching logs failed and cluster is in GKE', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: { nodes: { node1: { id: 'node1' } } },
      });
      TestUtils.makeErrorResponseOnce(getPodLogsSpy, 'getting logs failed');
      tree = shallow(
        <RunDetails
          {...generateProps()}
          gkeMetadata={{ projectId: 'test-project-id', clusterName: 'test-cluster-name' }}
        />,
      );
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      await getPodLogsSpy;
      await TestUtils.flushPromises();
      expect(tree.find(NODE_DETAILS_SELECTOR)).toMatchInlineSnapshot(`
        <div
          className="page"
          data-testid="run-details-node-details"
        >
          <div
            className="page"
          >
            <Banner
              additionalInfo="getting logs failed"
              message="Warning: failed to retrieve pod logs. Possible reasons include cluster autoscaling or pod preemption"
              mode="warning"
              refresh={[Function]}
            />
            <div
              className=""
            >
              Logs can also be viewed in
               
              <a
                className="link unstyled"
                href="https://console.cloud.google.com/logs/viewer?project=test-project-id&interval=NO_LIMIT&advancedFilter=resource.type%3D\\"k8s_container\\"%0Aresource.labels.cluster_name:\\"test-cluster-name\\"%0Aresource.labels.pod_name:\\"node1\\""
                rel="noopener noreferrer"
                target="_blank"
              >
                Stackdriver Kubernetes Monitoring
              </a>
              .
            </div>
          </div>
        </div>
      `);
    });

    it('shows warning banner without stackdriver link in logs area if fetching logs failed and cluster is not in GKE', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: { nodes: { node1: { id: 'node1' } } },
      });
      TestUtils.makeErrorResponseOnce(getPodLogsSpy, 'getting logs failed');
      tree = shallow(<RunDetails {...generateProps()} gkeMetadata={{}} />);
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      await getPodLogsSpy;
      await TestUtils.flushPromises();
      expect(tree.find('[data-testid="run-details-node-details"]')).toMatchInlineSnapshot(`
        <div
          className="page"
          data-testid="run-details-node-details"
        >
          <div
            className="page"
          >
            <Banner
              additionalInfo="getting logs failed"
              message="Warning: failed to retrieve pod logs. Possible reasons include cluster autoscaling or pod preemption"
              mode="warning"
              refresh={[Function]}
            />
          </div>
        </div>
      `);
    });

    it('does not load logs if clicked node status is skipped', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: {
          nodes: {
            node1: {
              id: 'node1',
              phase: 'Skipped',
            },
          },
        },
      });
      tree = shallow(<RunDetails {...generateProps()} />);
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      await getPodLogsSpy;
      await TestUtils.flushPromises();
      expect(getPodLogsSpy).not.toHaveBeenCalled();
      expect(tree.state()).toMatchObject({
        logsBannerAdditionalInfo: '',
        logsBannerMessage: '',
      });
      expect(tree).toMatchSnapshot();
    });

    it('keeps side pane open and on same tab when logs change after refresh', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: { nodes: { node1: { id: 'node1' } } },
      });
      tree = shallow(<RunDetails {...generateProps()} />);
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
      expect(tree.state('sidepanelSelectedTab')).toEqual(STEP_TABS.LOGS);

      getPodLogsSpy.mockImplementationOnce(() => 'new test logs');
      await (tree.instance() as RunDetails).refresh();
      expect(tree).toMatchSnapshot();
    });

    it('dismisses log failure warning banner when logs can be fetched after refresh', async () => {
      testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
        status: { nodes: { node1: { id: 'node1' } } },
      });
      TestUtils.makeErrorResponseOnce(getPodLogsSpy, 'getting logs failed');
      tree = shallow(<RunDetails {...generateProps()} />);
      await getRunSpy;
      await TestUtils.flushPromises();
      clickGraphNode(tree, 'node1');
      tree
        .find('MD2Tabs')
        .at(1)
        .simulate('switch', STEP_TABS.LOGS);
      await getPodLogsSpy;
      await TestUtils.flushPromises();
      expect(tree.state()).toMatchObject({
        logsBannerAdditionalInfo: 'getting logs failed',
        logsBannerMessage:
          'Warning: failed to retrieve pod logs. Possible reasons include cluster autoscaling or pod preemption',
        logsBannerMode: 'warning',
      });

      testRun.run!.status = 'Failed';
      await (tree.instance() as RunDetails).refresh();
      expect(tree.state()).toMatchObject({
        logsBannerAdditionalInfo: '',
        logsBannerMessage: '',
      });
    });
  });

  describe('auto refresh', () => {
    beforeEach(() => {
      testRun.run!.status = NodePhase.PENDING;
    });

    it('starts an interval of 5 seconds to auto refresh the page', async () => {
      tree = shallow(<RunDetails {...generateProps()} />);
      await TestUtils.flushPromises();

      expect(setInterval).toHaveBeenCalledTimes(1);
      expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 5000);
    });

    it('refreshes after each interval', async () => {
      tree = shallow(<RunDetails {...generateProps()} />);
      await TestUtils.flushPromises();

      const refreshSpy = jest.spyOn(tree.instance() as RunDetails, 'refresh');

      expect(refreshSpy).toHaveBeenCalledTimes(0);

      jest.runOnlyPendingTimers();
      expect(refreshSpy).toHaveBeenCalledTimes(1);
      await TestUtils.flushPromises();
    }, 10000);

    [NodePhase.ERROR, NodePhase.FAILED, NodePhase.SUCCEEDED, NodePhase.SKIPPED].forEach(status => {
      it(`sets 'runFinished' to true if run has status: ${status}`, async () => {
        testRun.run!.status = status;
        tree = shallow(<RunDetails {...generateProps()} />);
        await TestUtils.flushPromises();

        expect(tree.state('runFinished')).toBe(true);
      });
    });

    [NodePhase.PENDING, NodePhase.RUNNING, NodePhase.UNKNOWN].forEach(status => {
      it(`leaves 'runFinished' false if run has status: ${status}`, async () => {
        testRun.run!.status = status;
        tree = shallow(<RunDetails {...generateProps()} />);
        await TestUtils.flushPromises();

        expect(tree.state('runFinished')).toBe(false);
      });
    });

    it('pauses auto refreshing if window loses focus', async () => {
      tree = shallow(<RunDetails {...generateProps()} />);
      await TestUtils.flushPromises();

      expect(setInterval).toHaveBeenCalledTimes(1);
      expect(clearInterval).toHaveBeenCalledTimes(0);

      window.dispatchEvent(new Event('blur'));
      await TestUtils.flushPromises();

      expect(clearInterval).toHaveBeenCalledTimes(1);
    });

    it('resumes auto refreshing if window loses focus and then regains it', async () => {
      // Declare that the run has not finished
      testRun.run!.status = NodePhase.PENDING;
      tree = shallow(<RunDetails {...generateProps()} />);
      await TestUtils.flushPromises();

      expect(tree.state('runFinished')).toBe(false);
      expect(setInterval).toHaveBeenCalledTimes(1);
      expect(clearInterval).toHaveBeenCalledTimes(0);

      window.dispatchEvent(new Event('blur'));
      await TestUtils.flushPromises();

      expect(clearInterval).toHaveBeenCalledTimes(1);

      window.dispatchEvent(new Event('focus'));
      await TestUtils.flushPromises();

      expect(setInterval).toHaveBeenCalledTimes(2);
    });

    it('does not resume auto refreshing if window loses focus and then regains it but run is finished', async () => {
      // Declare that the run has finished
      testRun.run!.status = NodePhase.SUCCEEDED;
      tree = shallow(<RunDetails {...generateProps()} />);
      await TestUtils.flushPromises();

      expect(tree.state('runFinished')).toBe(true);
      expect(setInterval).toHaveBeenCalledTimes(0);
      expect(clearInterval).toHaveBeenCalledTimes(0);

      window.dispatchEvent(new Event('blur'));
      await TestUtils.flushPromises();

      // We expect 0 calls because the interval was never set, so it doesn't need to be cleared
      expect(clearInterval).toHaveBeenCalledTimes(0);

      window.dispatchEvent(new Event('focus'));
      await TestUtils.flushPromises();

      expect(setInterval).toHaveBeenCalledTimes(0);
    });
  });

  describe('EnhancedRunDetails', () => {
    it('redirects to experiments page when namespace changes', () => {
      const history = createMemoryHistory({
        initialEntries: ['/does-not-matter'],
      });
      const { rerender } = render(
        <Router history={history}>
          <NamespaceContext.Provider value='ns1'>
            <EnhancedRunDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      expect(history.location.pathname).not.toEqual('/experiments');
      rerender(
        <Router history={history}>
          <NamespaceContext.Provider value='ns2'>
            <EnhancedRunDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      expect(history.location.pathname).toEqual('/experiments');
    });

    it('does not redirect when namespace stays the same', () => {
      const history = createMemoryHistory({
        initialEntries: ['/initial-path'],
      });
      const { rerender } = render(
        <Router history={history}>
          <NamespaceContext.Provider value='ns1'>
            <EnhancedRunDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      expect(history.location.pathname).toEqual('/initial-path');
      rerender(
        <Router history={history}>
          <NamespaceContext.Provider value='ns1'>
            <EnhancedRunDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      expect(history.location.pathname).toEqual('/initial-path');
    });

    it('does not redirect when namespace initializes', () => {
      const history = createMemoryHistory({
        initialEntries: ['/initial-path'],
      });
      const { rerender } = render(
        <Router history={history}>
          <NamespaceContext.Provider value={undefined}>
            <EnhancedRunDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      expect(history.location.pathname).toEqual('/initial-path');
      rerender(
        <Router history={history}>
          <NamespaceContext.Provider value='ns1'>
            <EnhancedRunDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      expect(history.location.pathname).toEqual('/initial-path');
    });
  });
});
Example #18
Source File: ResourceSelector.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('ResourceSelector', () => {
  let tree: ReactWrapper | ShallowWrapper;

  const updateDialogSpy = jest.fn();
  const selectionChangedCbSpy = jest.fn();
  const listResourceSpy = jest.fn();
  const RESOURCES: BaseResource[] = [
    {
      created_at: new Date(2018, 1, 2, 3, 4, 5),
      description: 'test-1 description',
      id: 'some-id-1',
      name: 'test-1 name',
    },
    {
      created_at: new Date(2018, 10, 9, 8, 7, 6),
      description: 'test-2 description',
      id: 'some-2-id',
      name: 'test-2 name',
    },
  ];

  const selectorColumns = [
    { label: 'Resource name', flex: 1, sortKey: 'name' },
    { label: 'Description', flex: 1.5 },
    { label: 'Uploaded on', flex: 1, sortKey: 'created_at' },
  ];

  const testEmptyMessage = 'Test - Sorry, no resources.';
  const testTitle = 'A test selector';

  function generateProps(): ResourceSelectorProps {
    return {
      columns: selectorColumns,
      emptyMessage: testEmptyMessage,
      filterLabel: 'test filter label',
      history: {} as any,
      initialSortColumn: 'created_at',
      listApi: listResourceSpy as any,
      location: '' as any,
      match: {} as any,
      selectionChanged: selectionChangedCbSpy,
      title: testTitle,
      updateDialog: updateDialogSpy,
    };
  }

  beforeEach(() => {
    listResourceSpy.mockReset();
    listResourceSpy.mockImplementation(() => ({
      nextPageToken: 'test-next-page-token',
      resources: RESOURCES,
    }));
    updateDialogSpy.mockReset();
    selectionChangedCbSpy.mockReset();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    await tree.unmount();
  });

  it('displays resource selector', async () => {
    tree = shallow(<TestResourceSelector {...generateProps()} />);
    await (tree.instance() as TestResourceSelector)._load({});

    expect(listResourceSpy).toHaveBeenCalledTimes(1);
    expect(listResourceSpy).toHaveBeenLastCalledWith(undefined, undefined, undefined, undefined);
    expect(tree.state('resources')).toEqual(RESOURCES);
    expect(tree).toMatchSnapshot();
  });

  it('converts resources into a table rows', async () => {
    const props = generateProps();
    const resources: BaseResource[] = [
      {
        created_at: new Date(2018, 1, 2, 3, 4, 5),
        description: 'a description',
        id: 'an-id',
        name: 'a name',
      },
    ];
    listResourceSpy.mockImplementationOnce(() => ({ resources, nextPageToken: '' }));
    props.listApi = listResourceSpy as any;

    tree = shallow(<TestResourceSelector {...props} />);
    await (tree.instance() as TestResourceSelector)._load({});

    expect(tree.state('rows')).toEqual([
      {
        id: 'an-id',
        otherFields: ['a name', 'a description', '2/2/2018, 3:04:05 AM'],
      },
    ]);
  });

  it('shows error dialog if listing fails', async () => {
    TestUtils.makeErrorResponseOnce(listResourceSpy, 'woops!');
    jest.spyOn(console, 'error').mockImplementation();

    tree = shallow(<TestResourceSelector {...generateProps()} />);
    await (tree.instance() as TestResourceSelector)._load({});

    expect(listResourceSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content: 'List request failed with:\nwoops!',
        title: 'Error retrieving resources',
      }),
    );
    expect(tree.state('resources')).toEqual([]);
  });

  it('calls selection callback when a resource is selected', async () => {
    tree = shallow(<TestResourceSelector {...generateProps()} />);
    await (tree.instance() as TestResourceSelector)._load({});

    expect(tree.state('selectedIds')).toEqual([]);
    (tree.instance() as TestResourceSelector)._selectionChanged([RESOURCES[1].id!]);
    expect(selectionChangedCbSpy).toHaveBeenLastCalledWith(RESOURCES[1]);
    expect(tree.state('selectedIds')).toEqual([RESOURCES[1].id]);
  });

  it('logs error if more than one resource is selected', async () => {
    tree = shallow(<TestResourceSelector {...generateProps()} />);
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
    await (tree.instance() as TestResourceSelector)._load({});

    expect(tree.state('selectedIds')).toEqual([]);

    (tree.instance() as TestResourceSelector)._selectionChanged([
      RESOURCES[0].id!,
      RESOURCES[1].id!,
    ]);

    expect(selectionChangedCbSpy).not.toHaveBeenCalled();
    expect(tree.state('selectedIds')).toEqual([]);
    expect(consoleSpy).toHaveBeenLastCalledWith('2 resources were selected somehow', [
      RESOURCES[0].id,
      RESOURCES[1].id,
    ]);
  });

  it('logs error if selected resource ID is not found in list', async () => {
    tree = shallow(<TestResourceSelector {...generateProps()} />);
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
    await (tree.instance() as TestResourceSelector)._load({});

    expect(tree.state('selectedIds')).toEqual([]);

    (tree.instance() as TestResourceSelector)._selectionChanged(['id-not-in-list']);

    expect(selectionChangedCbSpy).not.toHaveBeenCalled();
    expect(tree.state('selectedIds')).toEqual([]);
    expect(consoleSpy).toHaveBeenLastCalledWith(
      'Somehow no resource was found with ID: id-not-in-list',
    );
  });
});
Example #19
Source File: RecurringRunsManager.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('RecurringRunsManager', () => {
  class TestRecurringRunsManager extends RecurringRunsManager {
    public async _loadRuns(request: ListRequest): Promise<string> {
      return super._loadRuns(request);
    }
    public _setEnabledState(id: string, enabled: boolean): Promise<void> {
      return super._setEnabledState(id, enabled);
    }
  }

  let tree: ReactWrapper | ShallowWrapper;

  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  const listJobsSpy = jest.spyOn(Apis.jobServiceApi, 'listJobs');
  const enableJobSpy = jest.spyOn(Apis.jobServiceApi, 'enableJob');
  const disableJobSpy = jest.spyOn(Apis.jobServiceApi, 'disableJob');
  jest.spyOn(console, 'error').mockImplementation();

  const JOBS: ApiJob[] = [
    {
      created_at: new Date(2018, 10, 9, 8, 7, 6),
      enabled: true,
      id: 'job1',
      name: 'test recurring run name',
    },
    {
      created_at: new Date(2018, 10, 9, 8, 7, 6),
      enabled: false,
      id: 'job2',
      name: 'test recurring run name2',
    },
    {
      created_at: new Date(2018, 10, 9, 8, 7, 6),
      id: 'job3',
      name: 'test recurring run name3',
    },
  ];

  function generateProps(): RecurringRunListProps {
    return {
      experimentId: 'test-experiment',
      history: {} as any,
      location: '' as any,
      match: {} as any,
      updateDialog: updateDialogSpy,
      updateSnackbar: updateSnackbarSpy,
    };
  }

  beforeEach(() => {
    listJobsSpy.mockReset();
    listJobsSpy.mockImplementation(() => ({ jobs: JOBS }));
    enableJobSpy.mockReset();
    disableJobSpy.mockReset();
    updateDialogSpy.mockReset();
    updateSnackbarSpy.mockReset();
  });

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

  it('calls API to load recurring runs', async () => {
    tree = shallow(<TestRecurringRunsManager {...generateProps()} />);
    await (tree.instance() as TestRecurringRunsManager)._loadRuns({});
    expect(listJobsSpy).toHaveBeenCalledTimes(1);
    expect(listJobsSpy).toHaveBeenLastCalledWith(
      undefined,
      undefined,
      undefined,
      ApiResourceType.EXPERIMENT,
      'test-experiment',
      undefined,
    );
    expect(tree.state('runs')).toEqual(JOBS);
    expect(tree).toMatchSnapshot();
  });

  it('shows error dialog if listing fails', async () => {
    TestUtils.makeErrorResponseOnce(listJobsSpy, 'woops!');
    jest.spyOn(console, 'error').mockImplementation();
    tree = shallow(<TestRecurringRunsManager {...generateProps()} />);
    await (tree.instance() as TestRecurringRunsManager)._loadRuns({});
    expect(listJobsSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content: 'List recurring run configs request failed with:\nwoops!',
        title: 'Error retrieving recurring run configs',
      }),
    );
    expect(tree.state('runs')).toEqual([]);
  });

  it('calls API to enable run', async () => {
    tree = shallow(<TestRecurringRunsManager {...generateProps()} />);
    await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', true);
    expect(enableJobSpy).toHaveBeenCalledTimes(1);
    expect(enableJobSpy).toHaveBeenLastCalledWith('test-run');
  });

  it('calls API to disable run', async () => {
    tree = shallow(<TestRecurringRunsManager {...generateProps()} />);
    await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', false);
    expect(disableJobSpy).toHaveBeenCalledTimes(1);
    expect(disableJobSpy).toHaveBeenLastCalledWith('test-run');
  });

  it('shows error if enable API call fails', async () => {
    tree = shallow(<TestRecurringRunsManager {...generateProps()} />);
    TestUtils.makeErrorResponseOnce(enableJobSpy, 'cannot enable');
    await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', true);
    expect(updateDialogSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content: 'Error changing enabled state of recurring run:\ncannot enable',
        title: 'Error',
      }),
    );
  });

  it('shows error if disable API call fails', async () => {
    tree = shallow(<TestRecurringRunsManager {...generateProps()} />);
    TestUtils.makeErrorResponseOnce(disableJobSpy, 'cannot disable');
    await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', false);
    expect(updateDialogSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content: 'Error changing enabled state of recurring run:\ncannot disable',
        title: 'Error',
      }),
    );
  });

  it('renders run name as link to its details page', () => {
    tree = TestUtils.mountWithRouter(<RecurringRunsManager {...generateProps()} />);
    expect(
      (tree.instance() as RecurringRunsManager)._nameCustomRenderer({
        id: 'run-id',
        value: 'test-run',
      }),
    ).toMatchSnapshot();
  });

  it('renders a disable button if the run is enabled, clicking the button calls disable API', async () => {
    tree = TestUtils.mountWithRouter(<RecurringRunsManager {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const enableBtn = tree.find('.tableRow Button').at(0);
    expect(enableBtn).toMatchSnapshot();

    enableBtn.simulate('click');
    await TestUtils.flushPromises();
    expect(disableJobSpy).toHaveBeenCalledTimes(1);
    expect(disableJobSpy).toHaveBeenLastCalledWith(JOBS[0].id);
  });

  it('renders an enable button if the run is disabled, clicking the button calls enable API', async () => {
    tree = TestUtils.mountWithRouter(<RecurringRunsManager {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const enableBtn = tree.find('.tableRow Button').at(1);
    expect(enableBtn).toMatchSnapshot();

    enableBtn.simulate('click');
    await TestUtils.flushPromises();
    expect(enableJobSpy).toHaveBeenCalledTimes(1);
    expect(enableJobSpy).toHaveBeenLastCalledWith(JOBS[1].id);
  });

  it("renders an enable button if the run's enabled field is undefined, clicking the button calls enable API", async () => {
    tree = TestUtils.mountWithRouter(<RecurringRunsManager {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const enableBtn = tree.find('.tableRow Button').at(2);
    expect(enableBtn).toMatchSnapshot();

    enableBtn.simulate('click');
    await TestUtils.flushPromises();
    expect(enableJobSpy).toHaveBeenCalledTimes(1);
    expect(enableJobSpy).toHaveBeenLastCalledWith(JOBS[2].id);
  });

  it('reloads the list of runs after enable/disabling', async () => {
    tree = TestUtils.mountWithRouter(<RecurringRunsManager {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const enableBtn = tree.find('.tableRow Button').at(0);
    expect(enableBtn).toMatchSnapshot();

    expect(listJobsSpy).toHaveBeenCalledTimes(1);
    enableBtn.simulate('click');
    await TestUtils.flushPromises();
    expect(listJobsSpy).toHaveBeenCalledTimes(2);
  });
});
Example #20
Source File: RecurringRunDetails.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('RecurringRunDetails', () => {
  let tree: ReactWrapper<any> | ShallowWrapper<any>;

  const updateBannerSpy = jest.fn();
  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  const updateToolbarSpy = jest.fn();
  const historyPushSpy = jest.fn();
  const getJobSpy = jest.spyOn(Apis.jobServiceApi, 'getJob');
  const deleteJobSpy = jest.spyOn(Apis.jobServiceApi, 'deleteJob');
  const enableJobSpy = jest.spyOn(Apis.jobServiceApi, 'enableJob');
  const disableJobSpy = jest.spyOn(Apis.jobServiceApi, 'disableJob');
  const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment');

  let fullTestJob: ApiJob = {};

  function generateProps(): PageProps {
    const match = {
      isExact: true,
      params: { [RouteParams.runId]: fullTestJob.id },
      path: '',
      url: '',
    };
    return TestUtils.generatePageProps(
      RecurringRunDetails,
      '' as any,
      match,
      historyPushSpy,
      updateBannerSpy,
      updateDialogSpy,
      updateToolbarSpy,
      updateSnackbarSpy,
    );
  }

  beforeEach(() => {
    fullTestJob = {
      created_at: new Date(2018, 8, 5, 4, 3, 2),
      description: 'test job description',
      enabled: true,
      id: 'test-job-id',
      max_concurrency: '50',
      no_catchup: true,
      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 ApiJob;

    jest.clearAllMocks();
    getJobSpy.mockImplementation(() => fullTestJob);
    deleteJobSpy.mockImplementation();
    enableJobSpy.mockImplementation();
    disableJobSpy.mockImplementation();
    getExperimentSpy.mockImplementation();
  });

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

  it('renders a recurring run with periodic schedule', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('renders a recurring run with cron schedule', async () => {
    const cronTestJob = {
      ...fullTestJob,
      no_catchup: undefined, // in api requests, it's undefined when false
      trigger: {
        cron_schedule: {
          cron: '* * * 0 0 !',
          end_time: new Date(2018, 10, 9, 8, 7, 6),
          start_time: new Date(2018, 9, 8, 7, 6),
        },
      },
    };
    getJobSpy.mockImplementation(() => cronTestJob);
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('loads the recurring run given its id in query params', async () => {
    // The run id is in the router match object, defined inside generateProps
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(getJobSpy).toHaveBeenLastCalledWith(fullTestJob.id);
    expect(getExperimentSpy).not.toHaveBeenCalled();
  });

  it('shows All runs -> run name when there is no experiment', async () => {
    // The run id is in the router match object, defined inside generateProps
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        breadcrumbs: [{ displayName: 'All runs', href: RoutePage.RUNS }],
        pageTitle: fullTestJob.name,
      }),
    );
  });

  it('loads the recurring run and its experiment if it has one', async () => {
    fullTestJob.resource_references = [
      { key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } },
    ];
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(getJobSpy).toHaveBeenLastCalledWith(fullTestJob.id);
    expect(getExperimentSpy).toHaveBeenLastCalledWith('test-experiment-id');
  });

  it('shows Experiments -> Experiment name -> run name when there is an experiment', async () => {
    fullTestJob.resource_references = [
      { key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } },
    ];
    getExperimentSpy.mockImplementation(id => ({ id, name: 'test experiment name' }));
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        breadcrumbs: [
          { displayName: 'Experiments', href: RoutePage.EXPERIMENTS },
          {
            displayName: 'test experiment name',
            href: RoutePage.EXPERIMENT_DETAILS.replace(
              ':' + RouteParams.experimentId,
              'test-experiment-id',
            ),
          },
        ],
        pageTitle: fullTestJob.name,
      }),
    );
  });

  it('shows error banner if run cannot be fetched', async () => {
    TestUtils.makeErrorResponseOnce(getJobSpy, 'woops!');
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear, once to show error
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'woops!',
        message: `Error: failed to retrieve recurring run: ${fullTestJob.id}. Click Details for more information.`,
        mode: 'error',
      }),
    );
  });

  it('shows warning banner if has experiment but experiment cannot be fetched. still loads run', async () => {
    fullTestJob.resource_references = [
      { key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } },
    ];
    TestUtils.makeErrorResponseOnce(getExperimentSpy, 'woops!');
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear, once to show error
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'woops!',
        message: `Error: failed to retrieve this recurring run's experiment. Click Details for more information.`,
        mode: 'warning',
      }),
    );
    expect(tree.state('run')).toEqual(fullTestJob);
  });

  it('has a Refresh button, clicking it refreshes the run details', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    const instance = tree.instance() as RecurringRunDetails;
    const refreshBtn = instance.getInitialToolbarState().actions[ButtonKeys.REFRESH];
    expect(refreshBtn).toBeDefined();
    expect(getJobSpy).toHaveBeenCalledTimes(1);
    await refreshBtn!.action();
    expect(getJobSpy).toHaveBeenCalledTimes(2);
  });

  it('has a clone button, clicking it navigates to new run page', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const cloneBtn = instance.getInitialToolbarState().actions[ButtonKeys.CLONE_RECURRING_RUN];
    expect(cloneBtn).toBeDefined();
    await cloneBtn!.action();
    expect(historyPushSpy).toHaveBeenCalledTimes(1);
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      RoutePage.NEW_RUN +
        `?${QUERY_PARAMS.cloneFromRecurringRun}=${fullTestJob!.id}` +
        `&${QUERY_PARAMS.isRecurring}=1`,
    );
  });

  it('shows enabled Disable, and disabled Enable buttons if the run is enabled', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenCalledTimes(2);
    const enableBtn = TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ENABLE_RECURRING_RUN);
    expect(enableBtn).toBeDefined();
    expect(enableBtn!.disabled).toBe(true);
    const disableBtn = TestUtils.getToolbarButton(
      updateToolbarSpy,
      ButtonKeys.DISABLE_RECURRING_RUN,
    );
    expect(disableBtn).toBeDefined();
    expect(disableBtn!.disabled).toBe(false);
  });

  it('shows enabled Disable, and disabled Enable buttons if the run is disabled', async () => {
    fullTestJob.enabled = false;
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenCalledTimes(2);
    const enableBtn = TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ENABLE_RECURRING_RUN);
    expect(enableBtn).toBeDefined();
    expect(enableBtn!.disabled).toBe(false);
    const disableBtn = TestUtils.getToolbarButton(
      updateToolbarSpy,
      ButtonKeys.DISABLE_RECURRING_RUN,
    );
    expect(disableBtn).toBeDefined();
    expect(disableBtn!.disabled).toBe(true);
  });

  it('shows enabled Disable, and disabled Enable buttons if the run is undefined', async () => {
    fullTestJob.enabled = undefined;
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenCalledTimes(2);
    const enableBtn = TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ENABLE_RECURRING_RUN);
    expect(enableBtn).toBeDefined();
    expect(enableBtn!.disabled).toBe(false);
    const disableBtn = TestUtils.getToolbarButton(
      updateToolbarSpy,
      ButtonKeys.DISABLE_RECURRING_RUN,
    );
    expect(disableBtn).toBeDefined();
    expect(disableBtn!.disabled).toBe(true);
  });

  it('calls disable API when disable button is clicked, refreshes the page', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const disableBtn = instance.getInitialToolbarState().actions[ButtonKeys.DISABLE_RECURRING_RUN];
    await disableBtn!.action();
    expect(disableJobSpy).toHaveBeenCalledTimes(1);
    expect(disableJobSpy).toHaveBeenLastCalledWith('test-job-id');
    expect(getJobSpy).toHaveBeenCalledTimes(2);
    expect(getJobSpy).toHaveBeenLastCalledWith('test-job-id');
  });

  it('shows error dialog if disable fails', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    TestUtils.makeErrorResponseOnce(disableJobSpy, 'could not disable');
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const disableBtn = instance.getInitialToolbarState().actions[ButtonKeys.DISABLE_RECURRING_RUN];
    await disableBtn!.action();
    expect(updateDialogSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content: 'could not disable',
        title: 'Failed to disable recurring run',
      }),
    );
  });

  it('shows error dialog if enable fails', async () => {
    fullTestJob.enabled = false;
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    TestUtils.makeErrorResponseOnce(enableJobSpy, 'could not enable');
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const enableBtn = instance.getInitialToolbarState().actions[ButtonKeys.ENABLE_RECURRING_RUN];
    await enableBtn!.action();
    expect(updateDialogSpy).toHaveBeenCalledTimes(1);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content: 'could not enable',
        title: 'Failed to enable recurring run',
      }),
    );
  });

  it('calls enable API when enable button is clicked, refreshes the page', async () => {
    fullTestJob.enabled = false;
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const enableBtn = instance.getInitialToolbarState().actions[ButtonKeys.ENABLE_RECURRING_RUN];
    await enableBtn!.action();
    expect(enableJobSpy).toHaveBeenCalledTimes(1);
    expect(enableJobSpy).toHaveBeenLastCalledWith('test-job-id');
    expect(getJobSpy).toHaveBeenCalledTimes(2);
    expect(getJobSpy).toHaveBeenLastCalledWith('test-job-id');
  });

  it('shows a delete button', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const deleteBtn = instance.getInitialToolbarState().actions[ButtonKeys.DELETE_RUN];
    expect(deleteBtn).toBeDefined();
  });

  it('shows delete dialog when delete button is clicked', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const deleteBtn = instance.getInitialToolbarState().actions[ButtonKeys.DELETE_RUN];
    await deleteBtn!.action();
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        title: 'Delete this recurring run config?',
      }),
    );
  });

  it('calls delete API when delete confirmation dialog button is clicked', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const deleteBtn = instance.getInitialToolbarState().actions[ButtonKeys.DELETE_RUN];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    expect(deleteJobSpy).toHaveBeenCalledTimes(1);
    expect(deleteJobSpy).toHaveBeenLastCalledWith('test-job-id');
  });

  it('does not call delete API when delete cancel dialog button is clicked', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as RecurringRunDetails;
    const deleteBtn = instance.getInitialToolbarState().actions[ButtonKeys.DELETE_RUN];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Cancel');
    await confirmBtn.onClick();
    expect(deleteJobSpy).not.toHaveBeenCalled();
    // Should not reroute
    expect(historyPushSpy).not.toHaveBeenCalled();
  });

  // TODO: need to test the dismiss path too--when the dialog is dismissed using ESC
  // or clicking outside it, it should be treated the same way as clicking Cancel.

  it('redirects back to parent experiment after delete', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const deleteBtn = (tree.instance() as RecurringRunDetails).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    expect(deleteJobSpy).toHaveBeenLastCalledWith('test-job-id');
    expect(historyPushSpy).toHaveBeenCalledTimes(1);
    expect(historyPushSpy).toHaveBeenLastCalledWith(RoutePage.EXPERIMENTS);
  });

  it('shows snackbar after successful deletion', async () => {
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const deleteBtn = (tree.instance() as RecurringRunDetails).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    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({
      message: 'Delete succeeded for this recurring run config',
      open: true,
    });
  });

  it('shows error dialog after failing deletion', async () => {
    TestUtils.makeErrorResponseOnce(deleteJobSpy, 'could not delete');
    tree = shallow(<RecurringRunDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    const deleteBtn = (tree.instance() as RecurringRunDetails).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    await TestUtils.flushPromises();
    expect(updateDialogSpy).toHaveBeenCalledTimes(2);
    expect(updateDialogSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        content:
          'Failed to delete recurring run config: test-job-id with error: "could not delete"',
        title: 'Failed to delete recurring run config',
      }),
    );
    // Should not reroute
    expect(historyPushSpy).not.toHaveBeenCalled();
  });
});
Example #21
Source File: PipelineVersionList.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('PipelineVersionList', () => {
  let tree: ReactWrapper | ShallowWrapper;

  const listPipelineVersionsSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelineVersions');
  const onErrorSpy = jest.fn();

  function generateProps(): PipelineVersionListProps {
    return {
      history: {} as any,
      location: { search: '' } as any,
      match: '' as any,
      onError: onErrorSpy,
      pipelineId: 'pipeline',
    };
  }

  async function mountWithNPipelineVersions(n: number): Promise<ReactWrapper> {
    listPipelineVersionsSpy.mockImplementation((pipelineId: string) => ({
      versions: range(n).map(i => ({
        id: 'test-pipeline-version-id' + i,
        name: 'test pipeline version name' + i,
      })),
    }));
    tree = TestUtils.mountWithRouter(<PipelineVersionList {...generateProps()} />);
    await listPipelineVersionsSpy;
    await TestUtils.flushPromises();
    tree.update(); // Make sure the tree is updated before returning it
    return tree;
  }

  beforeEach(() => {
    jest.clearAllMocks();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    await tree.unmount();
    jest.resetAllMocks();
  });

  it('renders an empty list with empty state message', () => {
    tree = shallow(<PipelineVersionList {...generateProps()} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one pipeline version', async () => {
    tree = shallow(<PipelineVersionList {...generateProps()} />);
    tree.setState({
      pipelineVersions: [
        {
          created_at: new Date(2018, 8, 22, 11, 5, 48),
          name: 'pipelineversion1',
        } as ApiPipelineVersion,
      ],
    });
    await listPipelineVersionsSpy;
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one pipeline version without created date', async () => {
    tree = shallow(<PipelineVersionList {...generateProps()} />);
    tree.setState({
      pipelines: [
        {
          name: 'pipelineversion1',
        } as ApiPipelineVersion,
      ],
    });
    await listPipelineVersionsSpy;
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one pipeline version with error', async () => {
    tree = shallow(<PipelineVersionList {...generateProps()} />);
    tree.setState({
      pipelineVersions: [
        {
          created_at: new Date(2018, 8, 22, 11, 5, 48),
          error: 'oops! could not load pipeline',
          name: 'pipeline1',
          parameters: [],
        } as ApiPipelineVersion,
      ],
    });
    await listPipelineVersionsSpy;
    expect(tree).toMatchSnapshot();
  });

  it('calls Apis to list pipeline versions, sorted by creation time in descending order', async () => {
    tree = await mountWithNPipelineVersions(2);
    await (tree.instance() as PipelineVersionListTest)._loadPipelineVersions({
      pageSize: 10,
      pageToken: '',
      sortBy: 'created_at',
    } as ListRequest);
    expect(listPipelineVersionsSpy).toHaveBeenLastCalledWith(
      'PIPELINE',
      'pipeline',
      10,
      '',
      'created_at',
    );
    expect(tree).toMatchSnapshot();
  });

  it('calls Apis to list pipeline versions, sorted by pipeline version name in descending order', async () => {
    tree = await mountWithNPipelineVersions(3);
    await (tree.instance() as PipelineVersionListTest)._loadPipelineVersions({
      pageSize: 10,
      pageToken: '',
      sortBy: 'name',
    } as ListRequest);
    expect(listPipelineVersionsSpy).toHaveBeenLastCalledWith(
      'PIPELINE',
      'pipeline',
      10,
      '',
      'name',
    );
    expect(tree).toMatchSnapshot();
  });
});
Example #22
Source File: Tensorboard.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('Tensorboard', () => {
  let tree: ReactWrapper | ShallowWrapper;
  const flushPromisesAndTimers = async () => {
    jest.runOnlyPendingTimers();
    await TestUtils.flushPromises();
  };

  beforeEach(() => {
    jest.clearAllMocks();
    jest.useFakeTimers();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    if (tree) {
      await tree.unmount();
    }
    jest.resetAllMocks();
    jest.restoreAllMocks();
  });

  it('base component snapshot', async () => {
    const getAppMock = () => Promise.resolve({ podAddress: '', tfVersion: '' });
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    tree = shallow(<TensorboardViewer configs={[]} />);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('does not break on no config', async () => {
    const getAppMock = () => Promise.resolve({ podAddress: '', tfVersion: '' });
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    tree = shallow(<TensorboardViewer configs={[]} />);
    const base = tree.debug();

    await TestUtils.flushPromises();
    expect(diff({ base, update: tree.debug() })).toMatchInlineSnapshot(`
      Snapshot Diff:
      - Expected
      + Received

      @@ --- --- @@
                  </WithStyles(MenuItem)>
                </WithStyles(WithFormControlContext(Select))>
              </WithStyles(FormControl)>
            </div>
            <div>
      -       <BusyButton className="buttonAction" disabled={false} onClick={[Function]} busy={true} title="Start Tensorboard" />
      +       <BusyButton className="buttonAction" disabled={false} onClick={[Function]} busy={false} title="Start Tensorboard" />
            </div>
          </div>
        </div>
    `);
  });

  it('does not break on empty data', async () => {
    const getAppMock = () => Promise.resolve({ podAddress: '', tfVersion: '' });
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    const config = { ...DEFAULT_CONFIG, url: '' };
    tree = shallow(<TensorboardViewer configs={[config]} />);
    const base = tree.debug();

    await TestUtils.flushPromises();
    expect(diff({ base, update: tree.debug() })).toMatchInlineSnapshot(`
      Snapshot Diff:
      - Expected
      + Received

      @@ --- --- @@
                  </WithStyles(MenuItem)>
                </WithStyles(WithFormControlContext(Select))>
              </WithStyles(FormControl)>
            </div>
            <div>
      -       <BusyButton className="buttonAction" disabled={false} onClick={[Function]} busy={true} title="Start Tensorboard" />
      +       <BusyButton className="buttonAction" disabled={false} onClick={[Function]} busy={false} title="Start Tensorboard" />
            </div>
          </div>
        </div>
    `);
  });

  it('shows a link to the tensorboard instance if exists', async () => {
    const config = { ...DEFAULT_CONFIG, url: 'http://test/url' };
    const getAppMock = () => Promise.resolve({ podAddress: 'test/address', tfVersion: '1.14.0' });
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    jest.spyOn(Apis, 'isTensorboardPodReady').mockImplementation(() => Promise.resolve(true));
    tree = shallow(<TensorboardViewer configs={[config]} />);

    await TestUtils.flushPromises();
    await flushPromisesAndTimers();
    expect(Apis.isTensorboardPodReady).toHaveBeenCalledTimes(1);
    expect(Apis.isTensorboardPodReady).toHaveBeenCalledWith('apis/v1beta1/_proxy/test/address');
    expect(tree).toMatchSnapshot();
  });

  it('shows start button if no instance exists', async () => {
    const config = DEFAULT_CONFIG;
    const getAppMock = () => Promise.resolve({ podAddress: '', tfVersion: '' });
    const getTensorboardSpy = jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    tree = shallow(<TensorboardViewer configs={[DEFAULT_CONFIG]} />);
    const base = tree.debug();

    await TestUtils.flushPromises();
    expect(
      diff({
        base,
        update: tree.debug(),
        baseAnnotation: 'initial',
        updateAnnotation: 'no instance exists',
      }),
    ).toMatchInlineSnapshot(`
      Snapshot Diff:
      - initial
      + no instance exists

      @@ --- --- @@
                  </WithStyles(MenuItem)>
                </WithStyles(WithFormControlContext(Select))>
              </WithStyles(FormControl)>
            </div>
            <div>
      -       <BusyButton className="buttonAction" disabled={false} onClick={[Function]} busy={true} title="Start Tensorboard" />
      +       <BusyButton className="buttonAction" disabled={false} onClick={[Function]} busy={false} title="Start Tensorboard" />
            </div>
          </div>
        </div>
    `);
    expect(getTensorboardSpy).toHaveBeenCalledWith(config.url, config.namespace);
  });

  it('starts tensorboard instance when button is clicked', async () => {
    const config = { ...DEFAULT_CONFIG };
    const getAppMock = () => Promise.resolve({ podAddress: '', tfVersion: '' });
    const startAppMock = jest.fn(() => Promise.resolve(''));
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    jest.spyOn(Apis, 'startTensorboardApp').mockImplementationOnce(startAppMock);
    tree = shallow(<TensorboardViewer configs={[config]} />);
    await TestUtils.flushPromises();
    tree.find('BusyButton').simulate('click');
    expect(startAppMock).toHaveBeenCalledWith(config.url, '2.0.0', config.namespace);
  });

  it('starts tensorboard instance for two configs', async () => {
    const config = { ...DEFAULT_CONFIG, url: 'http://test/url' };
    const config2 = { ...DEFAULT_CONFIG, url: 'http://test/url2' };
    const getAppMock = jest.fn(() => Promise.resolve({ podAddress: '', tfVersion: '' }));
    const startAppMock = jest.fn(() => Promise.resolve(''));
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    jest.spyOn(Apis, 'startTensorboardApp').mockImplementationOnce(startAppMock);
    tree = shallow(<TensorboardViewer configs={[config, config2]} />);
    await TestUtils.flushPromises();
    expect(getAppMock).toHaveBeenCalledWith(
      `Series1:${config.url},Series2:${config2.url}`,
      config.namespace,
    );
    tree.find('BusyButton').simulate('click');
    const expectedUrl = `Series1:${config.url},Series2:${config2.url}`;
    expect(startAppMock).toHaveBeenCalledWith(expectedUrl, '2.0.0', config.namespace);
  });

  it('returns friendly display name', () => {
    expect(TensorboardViewer.prototype.getDisplayName()).toBe('Tensorboard');
  });

  it('is aggregatable', () => {
    expect(TensorboardViewer.prototype.isAggregatable()).toBeTruthy();
  });

  it('select a version, then start a tensorboard of the corresponding version', async () => {
    const config = { ...DEFAULT_CONFIG };

    const getAppMock = jest.fn(() => Promise.resolve({ podAddress: '', tfVersion: '' }));
    const startAppMock = jest.fn(() => Promise.resolve(''));
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    const startAppSpy = jest
      .spyOn(Apis, 'startTensorboardApp')
      .mockImplementationOnce(startAppMock);

    tree = mount(<TensorboardViewer configs={[config]} />);
    await TestUtils.flushPromises();

    tree
      .find('Select')
      .find('[role="button"]')
      .simulate('click');
    tree
      .findWhere(el => el.text() === 'TensorFlow 1.15.0')
      .hostNodes()
      .simulate('click');
    tree.find('BusyButton').simulate('click');
    expect(startAppSpy).toHaveBeenCalledWith(config.url, '1.15.0', config.namespace);
  });

  it('delete the tensorboard instance, confirm in the dialog,\
    then return back to previous page', async () => {
    const getAppMock = jest.fn(() =>
      Promise.resolve({ podAddress: 'podaddress', tfVersion: '1.14.0' }),
    );
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    const deleteAppMock = jest.fn(() => Promise.resolve(''));
    const deleteAppSpy = jest.spyOn(Apis, 'deleteTensorboardApp').mockImplementation(deleteAppMock);
    const config = { ...DEFAULT_CONFIG };

    tree = mount(<TensorboardViewer configs={[config]} />);
    await TestUtils.flushPromises();
    expect(!!tree.state('podAddress')).toBeTruthy();

    // delete a tensorboard
    tree.update();
    tree
      .find('#delete')
      .find('Button')
      .simulate('click');
    tree.find('BusyButton').simulate('click');
    expect(deleteAppSpy).toHaveBeenCalledWith(config.url, config.namespace);
    await TestUtils.flushPromises();
    tree.update();
    // the tree has returned to 'start tensorboard' page
    expect(tree.findWhere(el => el.text() === 'Start Tensorboard').exists()).toBeTruthy();
  });

  it('show version info in delete confirming dialog, \
    if a tensorboard instance already exists', async () => {
    const getAppMock = jest.fn(() =>
      Promise.resolve({ podAddress: 'podaddress', tfVersion: '1.14.0' }),
    );
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    const config = DEFAULT_CONFIG;
    tree = mount(<TensorboardViewer configs={[config]} />);
    await TestUtils.flushPromises();
    tree.update();
    tree
      .find('#delete')
      .find('Button')
      .simulate('click');
    expect(tree.findWhere(el => el.text() === 'Stop Tensorboard 1.14.0?').exists()).toBeTruthy();
  });

  it('click on cancel on delete tensorboard dialog, then return back to previous page', async () => {
    const getAppMock = jest.fn(() =>
      Promise.resolve({ podAddress: 'podaddress', tfVersion: '1.14.0' }),
    );
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    const config = DEFAULT_CONFIG;
    tree = mount(<TensorboardViewer configs={[config]} />);
    await TestUtils.flushPromises();
    tree.update();
    tree
      .find('#delete')
      .find('Button')
      .simulate('click');

    tree
      .find('#cancel')
      .find('Button')
      .simulate('click');

    expect(tree.findWhere(el => el.text() === 'Open Tensorboard').exists()).toBeTruthy();
    expect(tree.findWhere(el => el.text() === 'Delete Tensorboard').exists()).toBeTruthy();
  });

  it('asks user to wait when Tensorboard status is not ready', async () => {
    const getAppMock = jest.fn(() =>
      Promise.resolve({ podAddress: 'podaddress', tfVersion: '1.14.0' }),
    );
    jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock);
    jest.spyOn(Apis, 'isTensorboardPodReady').mockImplementation(() => Promise.resolve(false));
    jest.spyOn(Apis, 'deleteTensorboardApp').mockImplementation(jest.fn(() => Promise.resolve('')));
    const config = DEFAULT_CONFIG;
    tree = mount(<TensorboardViewer configs={[config]} />);

    await TestUtils.flushPromises();
    await flushPromisesAndTimers();
    tree.update();
    expect(Apis.isTensorboardPodReady).toHaveBeenCalledTimes(1);
    expect(Apis.isTensorboardPodReady).toHaveBeenCalledWith('apis/v1beta1/_proxy/podaddress');
    expect(tree.findWhere(el => el.text() === 'Open Tensorboard').exists()).toBeTruthy();
    expect(
      tree
        .findWhere(
          el =>
            el.text() === 'Tensorboard is starting, and you may need to wait for a few minutes.',
        )
        .exists(),
    ).toBeTruthy();
    expect(tree.findWhere(el => el.text() === 'Delete Tensorboard').exists()).toBeTruthy();

    // After a while, it is ready and wait message is not shwon any more
    jest.spyOn(Apis, 'isTensorboardPodReady').mockImplementation(() => Promise.resolve(true));
    await flushPromisesAndTimers();
    tree.update();
    expect(
      tree
        .findWhere(
          el =>
            el.text() === `Tensorboard is starting, and you may need to wait for a few minutes.`,
        )
        .exists(),
    ).toEqual(false);
  });
});
Example #23
Source File: PipelineDetails.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
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 getPipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipelineVersion');
  const listPipelineVersionsSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelineVersions');
  const getRunSpy = jest.spyOn(Apis.runServiceApi, 'getRun');
  const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment');
  const deletePipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipelineVersion');
  const getPipelineVersionTemplateSpy = jest.spyOn(
    Apis.pipelineServiceApi,
    'getPipelineVersionTemplate',
  );
  const createGraphSpy = jest.spyOn(StaticGraphParser, 'createGraph');

  let tree: ShallowWrapper | ReactWrapper;
  let testPipeline: ApiPipeline = {};
  let testPipelineVersion: ApiPipelineVersion = {};
  let testRun: ApiRunDetail = {};

  function generateProps(fromRunSpec = false): PageProps {
    const match = {
      isExact: true,
      params: fromRunSpec
        ? {}
        : {
            [RouteParams.pipelineId]: testPipeline.id,
            [RouteParams.pipelineVersionId]:
              (testPipeline.default_version && testPipeline.default_version!.id) || '',
          },
      path: '',
      url: '',
    };
    const location = { search: fromRunSpec ? `?${QUERY_PARAMS.fromRunId}=test-run-id` : '' } as any;
    const pageProps = TestUtils.generatePageProps(
      PipelineDetails,
      location,
      match,
      historyPushSpy,
      updateBannerSpy,
      updateDialogSpy,
      updateToolbarSpy,
      updateSnackbarSpy,
    );
    return pageProps;
  }

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

  beforeEach(() => {
    jest.clearAllMocks();

    testPipeline = {
      created_at: new Date(2018, 8, 5, 4, 3, 2),
      description: 'test pipeline description',
      id: 'test-pipeline-id',
      name: 'test pipeline',
      parameters: [{ name: 'param1', value: 'value1' }],
      default_version: {
        id: 'test-pipeline-version-id',
        name: 'test-pipeline-version',
      },
    };

    testPipelineVersion = {
      id: 'test-pipeline-version-id',
      name: 'test-pipeline-version',
    };

    testRun = {
      run: {
        id: 'test-run-id',
        name: 'test run',
        pipeline_spec: {
          pipeline_id: 'run-pipeline-id',
        },
      },
    };

    getPipelineSpy.mockImplementation(() => Promise.resolve(testPipeline));
    getPipelineVersionSpy.mockImplementation(() => Promise.resolve(testPipelineVersion));
    listPipelineVersionsSpy.mockImplementation(() =>
      Promise.resolve({ versions: [testPipelineVersion] }),
    );
    getRunSpy.mockImplementation(() => Promise.resolve(testRun));
    getExperimentSpy.mockImplementation(() =>
      Promise.resolve({ id: 'test-experiment-id', name: 'test experiment' } as ApiExperiment),
    );
    // getTemplateSpy.mockImplementation(() => Promise.resolve({ template: 'test template' }));
    getPipelineVersionTemplateSpy.mockImplementation(() =>
      Promise.resolve({ template: 'test template' }),
    );
    createGraphSpy.mockImplementation(() => new graphlib.Graph());
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    await tree.unmount();
    jest.resetAllMocks();
  });

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

  it('shows pipeline name in page name, and breadcrumb to go back to pipelines', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        breadcrumbs: [{ displayName: 'Pipelines', href: RoutePage.PIPELINES }],
        pageTitle: testPipeline.name + ' (' + testPipelineVersion.name + ')',
      }),
    );
  });

  it(
    'shows all runs breadcrumbs, and "Pipeline details" as page title when the pipeline ' +
      'comes from a run spec that does not have an experiment',
    async () => {
      tree = shallow(<PipelineDetails {...generateProps(true)} />);
      await getRunSpy;
      await getPipelineVersionTemplateSpy;
      await TestUtils.flushPromises();
      expect(updateToolbarSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          breadcrumbs: [
            { displayName: 'All runs', href: RoutePage.RUNS },
            {
              displayName: testRun.run!.name,
              href: RoutePage.RUN_DETAILS.replace(':' + RouteParams.runId, testRun.run!.id!),
            },
          ],
          pageTitle: 'Pipeline details',
        }),
      );
    },
  );

  it(
    'shows all runs breadcrumbs, and "Pipeline details" as page title when the pipeline ' +
      'comes from a run spec that has an experiment',
    async () => {
      testRun.run!.resource_references = [
        { key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } },
      ];
      tree = shallow(<PipelineDetails {...generateProps(true)} />);
      await getRunSpy;
      await getExperimentSpy;
      await getPipelineVersionTemplateSpy;
      await TestUtils.flushPromises();
      expect(updateToolbarSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          breadcrumbs: [
            { displayName: 'Experiments', href: RoutePage.EXPERIMENTS },
            {
              displayName: 'test experiment',
              href: RoutePage.EXPERIMENT_DETAILS.replace(
                ':' + RouteParams.experimentId,
                'test-experiment-id',
              ),
            },
            {
              displayName: testRun.run!.name,
              href: RoutePage.RUN_DETAILS.replace(':' + RouteParams.runId, testRun.run!.id!),
            },
          ],
          pageTitle: 'Pipeline details',
        }),
      );
    },
  );

  it('parses the workflow source in embedded pipeline spec as JSON and then converts it to YAML', async () => {
    testRun.run!.pipeline_spec = {
      pipeline_id: 'run-pipeline-id',
      workflow_manifest: '{"spec": {"arguments": {"parameters": [{"name": "output"}]}}}',
    };

    tree = shallow(<PipelineDetails {...generateProps(true)} />);
    await TestUtils.flushPromises();

    expect(tree.state('templateString')).toBe(
      'spec:\n  arguments:\n    parameters:\n      - name: output\n',
    );
  });

  it('shows load error banner when failing to parse the workflow source in embedded pipeline spec', async () => {
    testRun.run!.pipeline_spec = {
      pipeline_id: 'run-pipeline-id',
      workflow_manifest: 'not valid JSON',
    };

    tree = shallow(<PipelineDetails {...generateProps(true)} />);
    await TestUtils.flushPromises();

    expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear banner, once to show error
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'Unexpected token o in JSON at position 1',
        message: `Failed to parse pipeline spec from run with ID: ${
          testRun.run!.id
        }. Click Details for more information.`,
        mode: 'error',
      }),
    );
  });

  it('shows load error banner when failing to get run details, when loading from run spec', async () => {
    TestUtils.makeErrorResponseOnce(getRunSpy, 'woops');
    tree = shallow(<PipelineDetails {...generateProps(true)} />);
    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 run details. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('shows load error banner when failing to get experiment details, when loading from run spec', async () => {
    testRun.run!.resource_references = [
      { key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } },
    ];
    TestUtils.makeErrorResponseOnce(getExperimentSpy, 'woops');
    tree = shallow(<PipelineDetails {...generateProps(true)} />);
    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 run details. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('uses an empty string and does not show error when getTemplate response is empty', async () => {
    getPipelineVersionTemplateSpy.mockImplementationOnce(() => Promise.resolve({}));

    tree = shallow(<PipelineDetails {...generateProps()} />);
    await getPipelineSpy;
    await TestUtils.flushPromises();

    // No errors
    expect(updateBannerSpy).toHaveBeenCalledTimes(1); // Once to clear banner
    expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({}));

    expect(tree.state('templateString')).toBe('');
  });

  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(getPipelineVersionTemplateSpy, '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 getPipelineVersionTemplateSpy;
    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('clears the error banner when refreshing the page', async () => {
    TestUtils.makeErrorResponseOnce(getPipelineVersionTemplateSpy, 'woops');
    tree = shallow(<PipelineDetails {...generateProps()} />);
    await TestUtils.flushPromises();

    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'woops',
        message: 'Cannot retrieve pipeline template. Click Details for more information.',
        mode: 'error',
      }),
    );

    (tree.instance() as PipelineDetails).refresh();

    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

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

  it('sets summary shown state to false when clicking the Hide button', async () => {
    tree = mount(<PipelineDetails {...generateProps()} />);
    await getPipelineVersionTemplateSpy;
    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 getPipelineVersionTemplateSpy;
    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 getPipelineVersionTemplateSpy;
    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 if it has a pipeline reference', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as PipelineDetails;
    const newExperimentBtn = instance.getInitialToolbarState().actions[ButtonKeys.NEW_EXPERIMENT];
    expect(newExperimentBtn).toBeDefined();
  });

  it("has 'create run' toolbar button if viewing an embedded pipeline", async () => {
    tree = shallow(<PipelineDetails {...generateProps(true)} />);
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as PipelineDetails;
    /* create run and create pipeline version, so 2 */
    expect(Object.keys(instance.getInitialToolbarState().actions)).toHaveLength(2);
    const newRunBtn = instance.getInitialToolbarState().actions[
      (ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION, ButtonKeys.NEW_PIPELINE_VERSION)
    ];
    expect(newRunBtn).toBeDefined();
  });

  it('clicking new run button when viewing embedded pipeline navigates to the new run page with run ID', async () => {
    tree = shallow(<PipelineDetails {...generateProps(true)} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as PipelineDetails;
    const newRunBtn = instance.getInitialToolbarState().actions[
      ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION
    ];
    newRunBtn!.action();
    expect(historyPushSpy).toHaveBeenCalledTimes(1);
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      RoutePage.NEW_RUN + `?${QUERY_PARAMS.fromRunId}=${testRun.run!.id}`,
    );
  });

  it("has 'create run' toolbar button if not viewing an embedded pipeline", async () => {
    tree = shallow(<PipelineDetails {...generateProps(false)} />);
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as PipelineDetails;
    /* create run, create pipeline version, create experiment and delete run, so 4 */
    expect(Object.keys(instance.getInitialToolbarState().actions)).toHaveLength(4);
    const newRunBtn = instance.getInitialToolbarState().actions[
      ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION
    ];
    expect(newRunBtn).toBeDefined();
  });

  it('clicking new run button navigates to the new run page', async () => {
    tree = shallow(<PipelineDetails {...generateProps(false)} />);
    await TestUtils.flushPromises();
    const instance = tree.instance() as PipelineDetails;
    const newRunFromPipelineVersionBtn = instance.getInitialToolbarState().actions[
      ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION
    ];
    newRunFromPipelineVersionBtn.action();
    expect(historyPushSpy).toHaveBeenCalledTimes(1);
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      RoutePage.NEW_RUN +
        `?${QUERY_PARAMS.pipelineId}=${testPipeline.id}&${
          QUERY_PARAMS.pipelineVersionId
        }=${testPipeline.default_version!.id!}`,
    );
  });

  it('clicking new run button when viewing half-loaded page navigates to the new run page with pipeline ID and version ID', async () => {
    tree = shallow(<PipelineDetails {...generateProps(false)} />);
    // Intentionally don't wait until all network requests finish.
    const instance = tree.instance() as PipelineDetails;
    const newRunFromPipelineVersionBtn = instance.getInitialToolbarState().actions[
      ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION
    ];
    newRunFromPipelineVersionBtn.action();
    expect(historyPushSpy).toHaveBeenCalledTimes(1);
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      RoutePage.NEW_RUN +
        `?${QUERY_PARAMS.pipelineId}=${testPipeline.id}&${
          QUERY_PARAMS.pipelineVersionId
        }=${testPipeline.default_version!.id!}`,
    );
  });

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

  it('clicking new experiment button when viewing half-loaded page navigates to the new experiment page with the pipeline ID', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    // Intentionally don't wait until all network requests finish.
    const instance = tree.instance() as PipelineDetails;
    const newExperimentBtn = instance.getInitialToolbarState().actions[ButtonKeys.NEW_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 getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    const instance = tree.instance() as PipelineDetails;
    const deleteBtn = instance.getInitialToolbarState().actions[ButtonKeys.DELETE_RUN];
    expect(deleteBtn).toBeDefined();
  });

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

  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[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const cancelBtn = call.buttons.find((b: any) => b.text === 'Cancel');
    await cancelBtn.onClick();
    expect(deletePipelineVersionSpy).not.toHaveBeenCalled();
  });

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

  it('calls delete API when delete dialog is confirmed and page is half-loaded', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    // Intentionally don't wait until all network requests finish.
    const deleteBtn = (tree.instance() as PipelineDetails).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    await deleteBtn!.action();
    const call = updateDialogSpy.mock.calls[0][0];
    const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete');
    await confirmBtn.onClick();
    expect(deletePipelineVersionSpy).toHaveBeenCalledTimes(1);
    expect(deletePipelineVersionSpy).toHaveBeenLastCalledWith(testPipeline.default_version!.id);
  });

  it('shows error dialog if deletion fails', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    TestUtils.makeErrorResponseOnce(deletePipelineVersionSpy, 'woops');
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    const deleteBtn = (tree.instance() as PipelineDetails).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    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: 'Failed to delete pipeline version: test-pipeline-version-id with error: "woops"',
        title: 'Failed to delete pipeline version',
      }),
    );
  });

  it('shows success snackbar if deletion succeeds', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    const deleteBtn = (tree.instance() as PipelineDetails).getInitialToolbarState().actions[
      ButtonKeys.DELETE_RUN
    ];
    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: 'Delete succeeded for this pipeline version',
        open: true,
      }),
    );
  });

  it('opens side panel on clicked node, shows message when node is not found in graph', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, '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 getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    clickGraphNode(tree, 'node1');
    expect(tree).toMatchSnapshot();
  });

  it('shows pipeline source code when config tab is clicked', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    await getPipelineVersionTemplateSpy;
    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 getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    tree.setState({ selectedNodeId: 'some-node-id' });
    tree.find('SidePanel').simulate('close');
    expect(tree.state('selectedNodeId')).toBe('');
    expect(tree).toMatchSnapshot();
  });

  it('shows correct versions in version selector', async () => {
    tree = shallow(<PipelineDetails {...generateProps()} />);
    await getPipelineVersionTemplateSpy;
    await TestUtils.flushPromises();
    expect(tree.state('versions')).toHaveLength(1);
    expect(tree).toMatchSnapshot();
  });
});
Example #24
Source File: SideNav.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('SideNav', () => {
  let tree: ReactWrapper | ShallowWrapper;

  const consoleErrorSpy = jest.spyOn(console, 'error');
  const buildInfoSpy = jest.spyOn(Apis, 'getBuildInfo');
  const checkHubSpy = jest.spyOn(Apis, 'isJupyterHubAvailable');
  const clusterNameSpy = jest.spyOn(Apis, 'getClusterName');
  const projectIdSpy = jest.spyOn(Apis, 'getProjectId');
  const localStorageHasKeySpy = jest.spyOn(LocalStorage, 'hasKey');
  const localStorageIsCollapsedSpy = jest.spyOn(LocalStorage, 'isNavbarCollapsed');

  beforeEach(() => {
    jest.clearAllMocks();

    consoleErrorSpy.mockImplementation(() => null);

    buildInfoSpy.mockImplementation(() => ({
      apiServerCommitHash: 'd3c4add0a95e930c70a330466d0923827784eb9a',
      apiServerReady: true,
      buildDate: 'Wed Jan 9 19:40:24 UTC 2019',
      frontendCommitHash: '8efb2fcff9f666ba5b101647e909dc9c6889cecb',
    }));
    checkHubSpy.mockImplementation(() => ({ ok: true }));
    clusterNameSpy.mockImplementation(() => Promise.reject('Error when fetching cluster name'));
    projectIdSpy.mockImplementation(() => Promise.reject('Error when fetching project ID'));

    localStorageHasKeySpy.mockImplementation(() => false);
    localStorageIsCollapsedSpy.mockImplementation(() => false);
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    await tree.unmount();
    jest.resetAllMocks();
    (window as any).innerWidth = wideWidth;
  });

  it('renders expanded state', () => {
    localStorageHasKeySpy.mockImplementationOnce(() => false);
    (window as any).innerWidth = wideWidth;
    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders collapsed state', () => {
    localStorageHasKeySpy.mockImplementationOnce(() => false);
    (window as any).innerWidth = narrowWidth;
    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders Pipelines as active page', () => {
    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders Pipelines as active when on PipelineDetails page', () => {
    tree = shallow(<SideNav page={RoutePage.PIPELINE_DETAILS} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active page', () => {
    tree = shallow(<SideNav page={RoutePage.EXPERIMENTS} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active when on ExperimentDetails page', () => {
    tree = shallow(<SideNav page={RoutePage.EXPERIMENT_DETAILS} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active page when on NewExperiment page', () => {
    tree = shallow(<SideNav page={RoutePage.NEW_EXPERIMENT} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active page when on Compare page', () => {
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active page when on AllRuns page', () => {
    tree = shallow(<SideNav page={RoutePage.RUNS} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active page when on RunDetails page', () => {
    tree = shallow(<SideNav page={RoutePage.RUN_DETAILS} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active page when on RecurringRunDetails page', () => {
    tree = shallow(<SideNav page={RoutePage.RECURRING_RUN} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders experiments as active page when on NewRun page', () => {
    tree = shallow(<SideNav page={RoutePage.NEW_RUN} {...defaultProps} />);
    expect(tree).toMatchSnapshot();
  });

  it('show jupyterhub link if accessible', () => {
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    tree.setState({ jupyterHubAvailable: true });
    expect(tree).toMatchSnapshot();
  });

  it('collapses if collapse state is true localStorage', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => true);
    localStorageHasKeySpy.mockImplementationOnce(() => true);

    (window as any).innerWidth = wideWidth;
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(true);
  });

  it('expands if collapse state is false in localStorage', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => false);
    localStorageHasKeySpy.mockImplementationOnce(() => true);

    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(false);
  });

  it('collapses if no collapse state in localStorage, and window is too narrow', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => false);
    localStorageHasKeySpy.mockImplementationOnce(() => false);

    (window as any).innerWidth = narrowWidth;
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(true);
  });

  it('expands if no collapse state in localStorage, and window is wide', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => false);
    localStorageHasKeySpy.mockImplementationOnce(() => false);

    (window as any).innerWidth = wideWidth;
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(false);
  });

  it('collapses if no collapse state in localStorage, and window goes from wide to narrow', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => false);
    localStorageHasKeySpy.mockImplementationOnce(() => false);

    (window as any).innerWidth = wideWidth;
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(false);

    (window as any).innerWidth = narrowWidth;
    const resizeEvent = new Event('resize');
    window.dispatchEvent(resizeEvent);
    expect(isCollapsed(tree)).toBe(true);
  });

  it('expands if no collapse state in localStorage, and window goes from narrow to wide', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => false);
    localStorageHasKeySpy.mockImplementationOnce(() => false);

    (window as any).innerWidth = narrowWidth;
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(true);

    (window as any).innerWidth = wideWidth;
    const resizeEvent = new Event('resize');
    window.dispatchEvent(resizeEvent);
    expect(isCollapsed(tree)).toBe(false);
  });

  it('saves state in localStorage if chevron is clicked', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => false);
    localStorageHasKeySpy.mockImplementationOnce(() => false);
    const spy = jest.spyOn(LocalStorage, 'saveNavbarCollapsed');

    (window as any).innerWidth = narrowWidth;
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(true);

    tree.find('WithStyles(IconButton)').simulate('click');
    expect(spy).toHaveBeenCalledWith(false);
  });

  it('does not collapse if collapse state is saved in localStorage, and window resizes', () => {
    localStorageIsCollapsedSpy.mockImplementationOnce(() => false);
    localStorageHasKeySpy.mockImplementationOnce(() => true);

    (window as any).innerWidth = wideWidth;
    tree = shallow(<SideNav page={RoutePage.COMPARE} {...defaultProps} />);
    expect(isCollapsed(tree)).toBe(false);

    (window as any).innerWidth = narrowWidth;
    const resizeEvent = new Event('resize');
    window.dispatchEvent(resizeEvent);
    expect(isCollapsed(tree)).toBe(false);
  });

  it('populates the display build information using the response from the healthz endpoint', async () => {
    const buildInfo = {
      apiServerCommitHash: '0a7b9e38f2b9bcdef4bbf3234d971e1635b50cd5',
      apiServerTagName: '1.0.0',
      apiServerReady: true,
      buildDate: 'Tue Oct 23 14:23:53 UTC 2018',
      frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98',
      frontendTagName: '1.0.0-rc1',
    };
    buildInfoSpy.mockImplementationOnce(() => buildInfo);

    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();

    expect(tree.state('displayBuildInfo')).toEqual({
      tagName: buildInfo.apiServerTagName,
      commitHash: buildInfo.apiServerCommitHash.substring(0, 7),
      commitUrl:
        'https://www.github.com/kubeflow/pipelines/commit/' + buildInfo.apiServerCommitHash,
      date: new Date(buildInfo.buildDate).toLocaleDateString(),
    });
  });

  it('populates the cluster information from context', async () => {
    const clusterName = 'some-cluster-name';
    const projectId = 'some-project-id';

    clusterNameSpy.mockImplementationOnce(() => Promise.resolve(clusterName));
    projectIdSpy.mockImplementationOnce(() => Promise.resolve(projectId));
    buildInfoSpy.mockImplementationOnce(() => Promise.reject('Error when fetching build info'));

    tree = mount(
      <GkeMetadataProvider>
        <MemoryRouter>
          <EnhancedSideNav page={RoutePage.PIPELINES} {...routerProps} />
        </MemoryRouter>
      </GkeMetadataProvider>,
    );
    const base = tree.html();
    await TestUtils.flushPromises();
    expect(
      diffHTML({
        base,
        baseAnnotation: 'base',
        update: tree.html(),
        updateAnnotation: 'after GKE metadata loaded',
      }),
    ).toMatchInlineSnapshot(`
      Snapshot Diff:
      - base
      + after GKE metadata loaded

      @@ --- --- @@
                  <path fill="none" d="M0 0h24v24H0z"></path></svg></span
              ><span class="MuiTouchRipple-root-53"></span>
            </button>
          </div>
          <div class="infoVisible">
      +     <div
      +       class="envMetadata"
      +       title="Cluster name: some-cluster-name, Project ID: some-project-id"
      +     >
      +       <span>Cluster name: </span
      +       ><a
      +         href="https://console.cloud.google.com/kubernetes/list?project=some-project-id&amp;filter=name:some-cluster-name"
      +         class="link unstyled"
      +         rel="noopener"
      +         target="_blank"
      +         >some-cluster-name</a
      +       >
      +     </div>
            <div class="envMetadata" title="Report an Issue">
              <a
                href="https://github.com/kubeflow/pipelines/issues/new?template=BUG_REPORT.md"
                class="link unstyled"
                rel="noopener"
    `);
  });

  it('displays the frontend tag name if the api server hash is not returned', async () => {
    const buildInfo = {
      apiServerReady: true,
      // No apiServerCommitHash or apiServerTagName
      buildDate: 'Tue Oct 23 14:23:53 UTC 2018',
      frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98',
      frontendTagName: '1.0.0',
    };
    buildInfoSpy.mockImplementationOnce(() => buildInfo);

    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    await TestUtils.flushPromises();

    expect(tree.state('displayBuildInfo')).toEqual(
      expect.objectContaining({
        commitHash: buildInfo.frontendCommitHash.substring(0, 7),
        tagName: buildInfo.frontendTagName,
      }),
    );
  });

  it('uses the frontend commit hash for the link URL if the api server hash is not returned', async () => {
    const buildInfo = {
      apiServerReady: true,
      // No apiServerCommitHash
      buildDate: 'Tue Oct 23 14:23:53 UTC 2018',
      frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98',
    };
    buildInfoSpy.mockImplementationOnce(() => buildInfo);

    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    await TestUtils.flushPromises();

    expect(tree.state('displayBuildInfo')).toEqual(
      expect.objectContaining({
        commitUrl:
          'https://www.github.com/kubeflow/pipelines/commit/' + buildInfo.frontendCommitHash,
      }),
    );
  });

  it("displays 'unknown' if the frontend and api server tag names/commit hashes are not returned", async () => {
    const buildInfo = {
      apiServerReady: true,
      // No apiServerCommitHash
      buildDate: 'Tue Oct 23 14:23:53 UTC 2018',
      // No frontendCommitHash
    };
    buildInfoSpy.mockImplementationOnce(() => buildInfo);

    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    await TestUtils.flushPromises();

    expect(tree.state('displayBuildInfo')).toEqual(
      expect.objectContaining({
        commitHash: 'unknown',
        tagName: 'unknown',
      }),
    );
  });

  it('links to the github repo root if the frontend and api server commit hashes are not returned', async () => {
    const buildInfo = {
      apiServerReady: true,
      // No apiServerCommitHash
      buildDate: 'Tue Oct 23 14:23:53 UTC 2018',
      // No frontendCommitHash
    };
    buildInfoSpy.mockImplementationOnce(() => buildInfo);

    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    await TestUtils.flushPromises();

    expect(tree.state('displayBuildInfo')).toEqual(
      expect.objectContaining({
        commitUrl: 'https://www.github.com/kubeflow/pipelines',
      }),
    );
  });

  it("displays 'unknown' if the date is not returned", async () => {
    const buildInfo = {
      apiServerCommitHash: '0a7b9e38f2b9bcdef4bbf3234d971e1635b50cd5',
      apiServerReady: true,
      // No buildDate
      frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98',
    };
    buildInfoSpy.mockImplementationOnce(() => buildInfo);

    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    await TestUtils.flushPromises();

    expect(tree.state('displayBuildInfo')).toEqual(
      expect.objectContaining({
        date: 'unknown',
      }),
    );
  });

  it('logs an error if the call getBuildInfo fails', async () => {
    TestUtils.makeErrorResponseOnce(buildInfoSpy, 'Uh oh!');

    tree = shallow(<SideNav page={RoutePage.PIPELINES} {...defaultProps} />);
    await TestUtils.flushPromises();

    expect(tree.state('displayBuildInfo')).toBeUndefined();
    expect(consoleErrorSpy.mock.calls[0][0]).toBe('Failed to retrieve build info');
  });
});
Example #25
Source File: NewRun.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('NewRun', () => {
  let tree: ReactWrapper | ShallowWrapper;

  const consoleErrorSpy = jest.spyOn(console, 'error');
  const startJobSpy = jest.spyOn(Apis.jobServiceApi, 'createJob');
  const startRunSpy = jest.spyOn(Apis.runServiceApi, 'createRun');
  const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment');
  const listExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'listExperiment');
  const getPipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipeline');
  const getPipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipelineVersion');
  const getRunSpy = jest.spyOn(Apis.runServiceApi, 'getRun');
  const loggerErrorSpy = jest.spyOn(logger, 'error');
  const historyPushSpy = jest.fn();
  const historyReplaceSpy = jest.fn();
  const updateBannerSpy = jest.fn();
  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  const updateToolbarSpy = jest.fn();

  let MOCK_EXPERIMENT = newMockExperiment();
  let MOCK_PIPELINE = newMockPipeline();
  let MOCK_PIPELINE_VERSION = newMockPipelineVersion();
  let MOCK_RUN_DETAIL = newMockRunDetail();
  let MOCK_RUN_WITH_EMBEDDED_PIPELINE = newMockRunWithEmbeddedPipeline();

  function muteErrors() {
    updateBannerSpy.mockImplementation(() => null);
    loggerErrorSpy.mockImplementation(() => null);
  }

  function newMockExperiment(): ApiExperiment {
    return {
      description: 'mock experiment description',
      id: 'some-mock-experiment-id',
      name: 'some mock experiment name',
    };
  }

  function newMockPipeline(): ApiPipeline {
    return {
      id: 'original-run-pipeline-id',
      name: 'original mock pipeline name',
      parameters: [],
      default_version: {
        id: 'original-run-pipeline-version-id',
        name: 'original mock pipeline version name',
      },
    };
  }

  function newMockPipelineWithParameters(): ApiPipeline {
    return {
      id: 'unoriginal-run-pipeline-id',
      name: 'unoriginal mock pipeline name',
      parameters: [
        {
          name: 'set value',
          value: 'abc',
        },
        {
          name: 'empty value',
          value: '',
        },
      ],
      default_version: {
        id: 'original-run-pipeline-version-id',
        name: 'original mock pipeline version name',
      },
    };
  }

  function newMockPipelineVersion(): ApiPipelineVersion {
    return {
      id: 'original-run-pipeline-version-id',
      name: 'original mock pipeline version name',
    };
  }

  function newMockRunDetail(): ApiRunDetail {
    return {
      pipeline_runtime: {
        workflow_manifest: '{}',
      },
      run: {
        id: 'some-mock-run-id',
        name: 'some mock run name',
        service_account: 'pipeline-runner',
        pipeline_spec: {
          pipeline_id: 'original-run-pipeline-id',
          workflow_manifest: '{}',
        },
      },
    };
  }

  function newMockRunWithEmbeddedPipeline(): ApiRunDetail {
    const runDetail = newMockRunDetail();
    delete runDetail.run!.pipeline_spec!.pipeline_id;
    runDetail.run!.pipeline_spec!.workflow_manifest =
      '{"metadata": {"name": "embedded"}, "parameters": []}';
    return runDetail;
  }

  function generateProps(): PageProps {
    return {
      history: { push: historyPushSpy, replace: historyReplaceSpy } as any,
      location: {
        pathname: RoutePage.NEW_RUN,
        // TODO: this should be removed once experiments are no longer required to reach this page.
        search: `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`,
      } as any,
      match: '' as any,
      toolbarProps: TestNewRun.prototype.getInitialToolbarState(),
      updateBanner: updateBannerSpy,
      updateDialog: updateDialogSpy,
      updateSnackbar: updateSnackbarSpy,
      updateToolbar: updateToolbarSpy,
    };
  }

  beforeEach(() => {
    jest.resetAllMocks();

    // TODO: decide this
    // consoleErrorSpy.mockImplementation(() => null);
    startRunSpy.mockImplementation(() => ({ id: 'new-run-id' }));
    getExperimentSpy.mockImplementation(() => MOCK_EXPERIMENT);
    listExperimentSpy.mockImplementation(() => {
      const response: ApiListExperimentsResponse = {
        experiments: [MOCK_EXPERIMENT],
        total_size: 1,
      };
      return response;
    });
    getPipelineSpy.mockImplementation(() => MOCK_PIPELINE);
    getPipelineVersionSpy.mockImplementation(() => MOCK_PIPELINE_VERSION);
    getRunSpy.mockImplementation(() => MOCK_RUN_DETAIL);
    updateBannerSpy.mockImplementation((opts: any) => {
      if (opts.mode) {
        // it's error or warning
        throw new Error('There was an error loading the page: ' + JSON.stringify(opts));
      }
    });

    MOCK_EXPERIMENT = newMockExperiment();
    MOCK_PIPELINE = newMockPipeline();
    MOCK_RUN_DETAIL = newMockRunDetail();
    MOCK_RUN_WITH_EMBEDDED_PIPELINE = newMockRunWithEmbeddedPipeline();
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    await tree.unmount();
  });

  it('renders the new run page', async () => {
    tree = shallow(<TestNewRun {...generateProps()} />);
    await TestUtils.flushPromises();

    expect(tree).toMatchSnapshot();
  });

  it('does not include any action buttons in the toolbar', async () => {
    const props = generateProps();
    // Clear the experiment ID from the query params, as it used at some point to update the
    // breadcrumb, and we cover that in a later test.
    props.location.search = '';

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(updateToolbarSpy).toHaveBeenLastCalledWith({
      actions: {},
      breadcrumbs: [{ displayName: 'Experiments', href: RoutePage.EXPERIMENTS }],
      pageTitle: 'Start a run',
    });
  });

  it('clears the banner when refresh is called', async () => {
    tree = shallow(<TestNewRun {...(generateProps() as any)} />);
    expect(updateBannerSpy).toHaveBeenCalledTimes(1);
    (tree.instance() as TestNewRun).refresh();
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2);
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

  it('clears the banner when load is called', async () => {
    tree = shallow(<TestNewRun {...(generateProps() as any)} />);
    expect(updateBannerSpy).toHaveBeenCalledTimes(1);
    (tree.instance() as TestNewRun).load();
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(2);
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

  it('allows updating the run name', async () => {
    tree = shallow(<TestNewRun {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: 'run name' } });

    expect(tree.state()).toHaveProperty('runName', 'run name');
  });

  it('reports validation error when missing the run name', async () => {
    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${
      QUERY_PARAMS.pipelineVersionId
    }=${MOCK_PIPELINE.default_version!.id}`;

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: null } });

    expect(tree.state()).toHaveProperty('errorMessage', 'Run name is required');
  });

  it('allows updating the run description', async () => {
    tree = shallow(<TestNewRun {...(generateProps() as any)} />);
    await TestUtils.flushPromises();
    (tree.instance() as TestNewRun).handleChange('description')({
      target: { value: 'run description' },
    });

    expect(tree.state()).toHaveProperty('description', 'run description');
  });

  it('changes title and form if the new run will recur, based on the radio buttons', async () => {
    // Default props do not include isRecurring in query params
    tree = shallow(<TestNewRun {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    (tree.instance() as TestNewRun)._updateRecurringRunState(true);
    await TestUtils.flushPromises();

    expect(tree).toMatchSnapshot();
  });

  it('changes title and form to default state if the new run is a one-off, based on the radio buttons', async () => {
    // Modify props to set page to recurring run form
    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.isRecurring}=1`;
    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    (tree.instance() as TestNewRun)._updateRecurringRunState(false);
    await TestUtils.flushPromises();

    expect(tree).toMatchSnapshot();
  });

  it('exits to the AllRuns page if there is no associated experiment', async () => {
    const props = generateProps();
    // Clear query params which might otherwise include an experiment ID.
    props.location.search = '';

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();
    tree.find('#exitNewRunPageBtn').simulate('click');

    expect(historyPushSpy).toHaveBeenCalledWith(RoutePage.RUNS);
  });

  it('fetches the associated experiment if one is present in the query params', async () => {
    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`;

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(getExperimentSpy).toHaveBeenLastCalledWith(MOCK_EXPERIMENT.id);
  });

  it("updates the run's state with the associated experiment if one is present in the query params", async () => {
    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`;

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(tree.state()).toHaveProperty('experiment', MOCK_EXPERIMENT);
    expect(tree.state()).toHaveProperty('experimentName', MOCK_EXPERIMENT.name);
    expect(tree).toMatchSnapshot();
  });

  it('updates the breadcrumb with the associated experiment if one is present in the query params', async () => {
    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`;

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(updateToolbarSpy).toHaveBeenLastCalledWith({
      actions: {},
      breadcrumbs: [
        { displayName: 'Experiments', href: RoutePage.EXPERIMENTS },
        {
          displayName: MOCK_EXPERIMENT.name,
          href: RoutePage.EXPERIMENT_DETAILS.replace(
            ':' + RouteParams.experimentId,
            MOCK_EXPERIMENT.id!,
          ),
        },
      ],
      pageTitle: 'Start a run',
    });
  });

  it("exits to the associated experiment's details page if one is present in the query params", async () => {
    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`;

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();
    tree.find('#exitNewRunPageBtn').simulate('click');

    expect(historyPushSpy).toHaveBeenCalledWith(
      RoutePage.EXPERIMENT_DETAILS.replace(':' + RouteParams.experimentId, MOCK_EXPERIMENT.id!),
    );
  });

  it("changes the exit button's text if query params indicate this is the first run of an experiment", async () => {
    const props = generateProps();
    props.location.search =
      `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
      `&${QUERY_PARAMS.firstRunInExperiment}=1`;

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(tree).toMatchSnapshot();
  });

  it('shows a page error if getExperiment fails', async () => {
    muteErrors();

    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`;

    TestUtils.makeErrorResponseOnce(getExperimentSpy, 'test error message');

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'test error message',
        message: `Error: failed to retrieve associated experiment: ${MOCK_EXPERIMENT.id}. Click Details for more information.`,
        mode: 'error',
      }),
    );
  });

  it('fetches the associated pipeline if one is present in the query params', async () => {
    const randomSpy = jest.spyOn(Math, 'random');
    randomSpy.mockImplementation(() => 0.5);

    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${
      QUERY_PARAMS.pipelineVersionId
    }=${MOCK_PIPELINE.default_version!.id}`;

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(tree.state()).toHaveProperty('pipeline', MOCK_PIPELINE);
    expect(tree.state()).toHaveProperty('pipelineName', MOCK_PIPELINE.name);
    expect(tree.state()).toHaveProperty('pipelineVersion', MOCK_PIPELINE_VERSION);
    expect((tree.state() as any).runName).toMatch(/Run of original mock pipeline version name/);
    expect(tree).toMatchSnapshot();

    randomSpy.mockRestore();
  });

  it('shows a page error if getPipeline fails', async () => {
    muteErrors();

    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`;

    TestUtils.makeErrorResponseOnce(getPipelineSpy, 'test error message');

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'test error message',
        message: `Error: failed to retrieve pipeline: ${MOCK_PIPELINE.id}. Click Details for more information.`,
        mode: 'error',
      }),
    );
  });

  it('shows a page error if getPipelineVersion fails', async () => {
    muteErrors();

    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

    TestUtils.makeErrorResponseOnce(getPipelineVersionSpy, 'test error message');

    tree = shallow(<TestNewRun {...props} />);
    await TestUtils.flushPromises();

    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'test error message',
        message: `Error: failed to retrieve pipeline version: ${MOCK_PIPELINE_VERSION.id}. Click Details for more information.`,
        mode: 'error',
      }),
    );
  });

  it('renders a warning message if there are pipeline parameters with empty values', async () => {
    tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    const pipeline = newMockPipelineWithParameters();
    tree.setState({ parameters: pipeline.parameters });

    // Ensure that at least one of the provided parameters has a missing value.
    expect((pipeline.parameters || []).some(parameter => !parameter.value)).toBe(true);
    expect(tree.find('#missing-parameters-message').exists()).toBe(true);
  });

  it('does not render a warning message if there are no pipeline parameters with empty values', async () => {
    tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    const pipeline = newMockPipelineWithParameters();
    (pipeline.parameters || []).forEach(parameter => {
      parameter.value = 'I am not set';
    });
    tree.setState({ parameters: pipeline.parameters });

    // Ensure all provided parameters have valid values.
    expect((pipeline.parameters || []).every(parameter => !!parameter.value)).toBe(true);
    expect(tree.find('#missing-parameters-message').exists()).toBe(false);
  });

  describe('choosing a pipeline', () => {
    it("opens up the pipeline selector modal when users clicks 'Choose'", async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      tree
        .find('#choosePipelineBtn')
        .at(0)
        .simulate('click');
      await TestUtils.flushPromises();
      expect(tree.state('pipelineSelectorOpen')).toBe(true);
    });

    it('closes the pipeline selector modal', async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      tree
        .find('#choosePipelineBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('pipelineSelectorOpen')).toBe(true);

      tree
        .find('#cancelPipelineSelectionBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('pipelineSelectorOpen')).toBe(false);
    });

    it('sets the pipeline from the selector modal when confirmed', async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      const oldPipeline = newMockPipeline();
      oldPipeline.id = 'old-pipeline-id';
      oldPipeline.name = 'old-pipeline-name';
      const newPipeline = newMockPipeline();
      newPipeline.id = 'new-pipeline-id';
      newPipeline.name = 'new-pipeline-name';
      getPipelineSpy.mockImplementation(() => newPipeline);
      tree.setState({ pipeline: oldPipeline, pipelineName: oldPipeline.name });

      tree
        .find('#choosePipelineBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('pipelineSelectorOpen')).toBe(true);

      // Simulate selecting pipeline
      tree.setState({ unconfirmedSelectedPipeline: newPipeline });

      // Confirm pipeline selector
      tree
        .find('#usePipelineBtn')
        .at(0)
        .simulate('click');
      await TestUtils.flushPromises();
      expect(tree.state('pipelineSelectorOpen')).toBe(false);

      expect(tree.state('pipeline')).toEqual(newPipeline);
      expect(tree.state('pipelineName')).toEqual(newPipeline.name);
      expect(tree.state('pipelineSelectorOpen')).toBe(false);
      await TestUtils.flushPromises();
    });

    it('does not set the pipeline from the selector modal when cancelled', async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      const oldPipeline = newMockPipeline();
      oldPipeline.id = 'old-pipeline-id';
      oldPipeline.name = 'old-pipeline-name';
      const newPipeline = newMockPipeline();
      newPipeline.id = 'new-pipeline-id';
      newPipeline.name = 'new-pipeline-name';
      getPipelineSpy.mockImplementation(() => newPipeline);
      tree.setState({ pipeline: oldPipeline, pipelineName: oldPipeline.name });

      tree
        .find('#choosePipelineBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('pipelineSelectorOpen')).toBe(true);

      // Simulate selecting pipeline
      tree.setState({ unconfirmedSelectedPipeline: newPipeline });

      // Cancel pipeline selector
      tree
        .find('#cancelPipelineSelectionBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('pipelineSelectorOpen')).toBe(false);

      expect(tree.state('pipeline')).toEqual(oldPipeline);
      expect(tree.state('pipelineName')).toEqual(oldPipeline.name);
      expect(tree.state('pipelineSelectorOpen')).toBe(false);
      await TestUtils.flushPromises();
    });
  });

  describe('choosing an experiment', () => {
    it("opens up the experiment selector modal when users clicks 'Choose'", async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      tree
        .find('#chooseExperimentBtn')
        .at(0)
        .simulate('click');
      await TestUtils.flushPromises();
      expect(tree.state('experimentSelectorOpen')).toBe(true);
      expect(listExperimentSpy).toHaveBeenCalledWith(
        '',
        10,
        'created_at desc',
        encodeURIComponent(
          JSON.stringify({
            predicates: [
              {
                key: 'storage_state',
                op: PredicateOp.NOTEQUALS,
                string_value: ExperimentStorageState.ARCHIVED.toString(),
              },
            ],
          } as ApiFilter),
        ),
        undefined,
        undefined,
      );
    });

    it('lists available experiments by namespace if available', async () => {
      tree = TestUtils.mountWithRouter(
        <TestNewRun {...(generateProps() as any)} namespace='test-ns' />,
      );
      await TestUtils.flushPromises();

      tree
        .find('#chooseExperimentBtn')
        .at(0)
        .simulate('click');
      await TestUtils.flushPromises();
      expect(listExperimentSpy).toHaveBeenCalledWith(
        '',
        10,
        'created_at desc',
        encodeURIComponent(
          JSON.stringify({
            predicates: [
              {
                key: 'storage_state',
                op: PredicateOp.NOTEQUALS,
                string_value: ExperimentStorageState.ARCHIVED.toString(),
              },
            ],
          } as ApiFilter),
        ),
        'NAMESPACE',
        'test-ns',
      );
    });

    it('closes the experiment selector modal', async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      tree
        .find('#chooseExperimentBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('experimentSelectorOpen')).toBe(true);

      tree
        .find('#cancelExperimentSelectionBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('experimentSelectorOpen')).toBe(false);
    });

    it('sets the experiment from the selector modal when confirmed', async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      const oldExperiment = newMockExperiment();
      oldExperiment.id = 'old-experiment-id';
      oldExperiment.name = 'old-experiment-name';
      const newExperiment = newMockExperiment();
      newExperiment.id = 'new-experiment-id';
      newExperiment.name = 'new-experiment-name';
      getExperimentSpy.mockImplementation(() => newExperiment);
      tree.setState({ experiment: oldExperiment, experimentName: oldExperiment.name });

      tree
        .find('#chooseExperimentBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('experimentSelectorOpen')).toBe(true);

      // Simulate selecting experiment
      tree.setState({ unconfirmedSelectedExperiment: newExperiment });

      // Confirm experiment selector
      tree
        .find('#useExperimentBtn')
        .at(0)
        .simulate('click');
      await TestUtils.flushPromises();
      expect(tree.state('experimentSelectorOpen')).toBe(false);

      expect(tree.state('experiment')).toEqual(newExperiment);
      expect(tree.state('experimentName')).toEqual(newExperiment.name);
      expect(tree.state('experimentSelectorOpen')).toBe(false);
      await TestUtils.flushPromises();
    });

    it('does not set the experiment from the selector modal when cancelled', async () => {
      tree = TestUtils.mountWithRouter(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      const oldExperiment = newMockExperiment();
      oldExperiment.id = 'old-experiment-id';
      oldExperiment.name = 'old-experiment-name';
      const newExperiment = newMockExperiment();
      newExperiment.id = 'new-experiment-id';
      newExperiment.name = 'new-experiment-name';
      getExperimentSpy.mockImplementation(() => newExperiment);
      tree.setState({ experiment: oldExperiment, experimentName: oldExperiment.name });

      tree
        .find('#chooseExperimentBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('experimentSelectorOpen')).toBe(true);

      // Simulate selecting experiment
      tree.setState({ unconfirmedSelectedExperiment: newExperiment });

      // Cancel experiment selector
      tree
        .find('#cancelExperimentSelectionBtn')
        .at(0)
        .simulate('click');
      expect(tree.state('experimentSelectorOpen')).toBe(false);

      expect(tree.state('experiment')).toEqual(oldExperiment);
      expect(tree.state('experimentName')).toEqual(oldExperiment.name);
      expect(tree.state('experimentSelectorOpen')).toBe(false);
      await TestUtils.flushPromises();
    });
  });

  // TODO: Add test for when dialog is dismissed. Due to the particulars of how the Dialog element
  // works, this will not be possible until it's wrapped in some manner, like UploadPipelineDialog
  // in PipelineList

  describe('cloning from a run', () => {
    it('fetches the original run if an ID is present in the query params', async () => {
      const run = newMockRunDetail().run!;
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${run.id}`;

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(getRunSpy).toHaveBeenCalledTimes(1);
      expect(getRunSpy).toHaveBeenLastCalledWith(run.id);
    });

    it("automatically generates the new run name based on the original run's name", async () => {
      const runDetail = newMockRunDetail();
      runDetail.run!.name = '-original run-';
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(tree.state('runName')).toBe('Clone of -original run-');
    });

    it('automatically generates the new clone name if the original run was a clone', async () => {
      const runDetail = newMockRunDetail();
      runDetail.run!.name = 'Clone of some run';
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(tree.state('runName')).toBe('Clone (2) of some run');
    });

    it('uses service account in the original run', async () => {
      const defaultRunDetail = newMockRunDetail();
      const runDetail = {
        ...defaultRunDetail,
        run: {
          ...defaultRunDetail.run,
          service_account: 'sa1',
        },
      };
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;
      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(tree.state('serviceAccount')).toBe('sa1');
    });

    it('uses the query param experiment ID over the one in the original run if an ID is present in both', async () => {
      const experiment = newMockExperiment();
      const runDetail = newMockRunDetail();
      runDetail.run!.resource_references = [
        {
          key: { id: `${experiment.id}-different`, type: ApiResourceType.EXPERIMENT },
        },
      ];
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}` +
        `&${QUERY_PARAMS.experimentId}=${experiment.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(getRunSpy).toHaveBeenCalledTimes(1);
      expect(getRunSpy).toHaveBeenLastCalledWith(runDetail.run!.id);
      expect(getExperimentSpy).toHaveBeenCalledTimes(1);
      expect(getExperimentSpy).toHaveBeenLastCalledWith(experiment.id);
    });

    it('uses the experiment ID in the original run if no experiment ID is present in query params', async () => {
      const originalRunExperimentId = 'original-run-experiment-id';
      const runDetail = newMockRunDetail();
      runDetail.run!.resource_references = [
        {
          key: { id: originalRunExperimentId, type: ApiResourceType.EXPERIMENT },
        },
      ];
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(getRunSpy).toHaveBeenCalledTimes(1);
      expect(getRunSpy).toHaveBeenLastCalledWith(runDetail.run!.id);
      expect(getExperimentSpy).toHaveBeenCalledTimes(1);
      expect(getExperimentSpy).toHaveBeenLastCalledWith(originalRunExperimentId);
    });

    it('retrieves the pipeline from the original run, even if there is a pipeline ID in the query params', async () => {
      // The error is caused by incomplete mock data.
      muteErrors();

      const runDetail = newMockRunDetail();
      runDetail.run!.pipeline_spec = { pipeline_id: 'original-run-pipeline-id' };
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}` +
        `&${QUERY_PARAMS.pipelineId}=some-other-pipeline-id`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(getPipelineSpy).toHaveBeenCalledTimes(1);
      expect(getPipelineSpy).toHaveBeenLastCalledWith(runDetail.run!.pipeline_spec!.pipeline_id);
    });

    it('shows a page error if getPipeline fails to find the pipeline from the original run', async () => {
      muteErrors();

      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${MOCK_RUN_DETAIL.run!.id}`;

      TestUtils.makeErrorResponseOnce(getPipelineSpy, 'test error message');

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          additionalInfo: 'test error message',
          message:
            'Error: failed to find a pipeline corresponding to that of the original run:' +
            ` ${MOCK_RUN_DETAIL.run!.id}. Click Details for more information.`,
          mode: 'error',
        }),
      );
    });

    it('shows an error if getPipeline fails to find the pipeline from the original run', async () => {
      muteErrors();

      const runDetail = newMockRunDetail();
      runDetail.run!.pipeline_spec!.pipeline_id = undefined;
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          message:
            "Error: failed to read the clone run's pipeline definition. Click Details for more information.",
          mode: 'error',
        }),
      );
    });

    it('does not call getPipeline if original run has pipeline spec instead of id', async () => {
      // Error expected because of incompelte mock data.
      muteErrors();

      const runDetail = newMockRunDetail();
      delete runDetail.run!.pipeline_spec!.pipeline_id;
      runDetail.run!.pipeline_spec!.workflow_manifest = 'test workflow yaml';
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(getPipelineSpy).not.toHaveBeenCalled();
    });

    it('shows a page error if parsing embedded pipeline yaml fails', async () => {
      muteErrors();

      const runDetail = newMockRunDetail();
      delete runDetail.run!.pipeline_spec!.pipeline_id;
      runDetail.run!.pipeline_spec!.workflow_manifest = '!definitely not yaml';
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          message:
            "Error: failed to read the clone run's pipeline definition. Click Details for more information.",
          mode: 'error',
        }),
      );
    });

    it('loads and selects embedded pipeline from run', async () => {
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${
        MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id
      }`;

      getRunSpy.mockImplementation(() => MOCK_RUN_WITH_EMBEDDED_PIPELINE);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenCalledTimes(1);
      expect(tree.state('workflowFromRun')).toEqual({
        metadata: { name: 'embedded' },
        parameters: [],
      });
      expect(tree.state('useWorkflowFromRun')).toBe(true);
    });

    it("shows a page error if the original run's workflow_manifest is undefined", async () => {
      muteErrors();

      const runDetail = newMockRunDetail();
      runDetail.run!.pipeline_spec!.workflow_manifest = undefined;
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          message: `Error: run ${runDetail.run!.id} had no workflow manifest`,
          mode: 'error',
        }),
      );
    });

    it("shows a page error if the original run's workflow_manifest is invalid JSON", async () => {
      muteErrors();

      const runDetail = newMockRunWithEmbeddedPipeline();
      runDetail.run!.pipeline_spec!.workflow_manifest = 'not json';
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          message:
            "Error: failed to read the clone run's pipeline definition. Click Details for more information.",
          mode: 'error',
        }),
      );
    });

    it("gets the pipeline parameter values of the original run's pipeline", async () => {
      const runDetail = newMockRunDetail();
      const originalRunPipelineParams: ApiParameter[] = [
        { name: 'thisTestParam', value: 'thisTestVal' },
      ];
      runDetail.pipeline_runtime!.workflow_manifest = JSON.stringify({
        spec: {
          arguments: {
            parameters: originalRunPipelineParams,
          },
        },
      });
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`;

      getRunSpy.mockImplementation(() => runDetail);

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(tree.state('parameters')).toEqual(originalRunPipelineParams);
    });

    it('shows a page error if getRun fails', async () => {
      muteErrors();

      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${MOCK_RUN_DETAIL.run!.id}`;

      TestUtils.makeErrorResponseOnce(getRunSpy, 'test error message');

      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          additionalInfo: 'test error message',
          message: `Error: failed to retrieve original run: ${
            MOCK_RUN_DETAIL.run!.id
          }. Click Details for more information.`,
          mode: 'error',
        }),
      );
    });
  });

  describe('arriving from pipeline details page', () => {
    let mockEmbeddedPipelineProps: PageProps;
    beforeEach(() => {
      mockEmbeddedPipelineProps = generateProps();
      mockEmbeddedPipelineProps.location.search = `?${QUERY_PARAMS.fromRunId}=${
        MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id
      }`;
      getRunSpy.mockImplementationOnce(() => MOCK_RUN_WITH_EMBEDDED_PIPELINE);
    });

    it('indicates that a pipeline is preselected and provides a means of selecting a different pipeline', async () => {
      tree = shallow(<TestNewRun {...(mockEmbeddedPipelineProps as any)} />);
      await TestUtils.flushPromises();

      expect(tree.state('useWorkflowFromRun')).toBe(true);
      expect(tree.state('usePipelineFromRunLabel')).toBe('Using pipeline from previous page');
      expect(tree).toMatchSnapshot();
    });

    it('retrieves the run with the embedded pipeline', async () => {
      tree = shallow(<TestNewRun {...(mockEmbeddedPipelineProps as any)} />);
      await TestUtils.flushPromises();

      expect(getRunSpy).toHaveBeenLastCalledWith(MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id);
    });

    it('parses the embedded workflow and stores it in state', async () => {
      MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.pipeline_spec!.workflow_manifest = JSON.stringify(
        MOCK_PIPELINE,
      );

      tree = shallow(<TestNewRun {...(mockEmbeddedPipelineProps as any)} />);
      await TestUtils.flushPromises();

      expect(tree.state('workflowFromRun')).toEqual(MOCK_PIPELINE);
      expect(tree.state('parameters')).toEqual(MOCK_PIPELINE.parameters);
      expect(tree.state('useWorkflowFromRun')).toBe(true);
    });

    it('displays a page error if it fails to parse the embedded pipeline', async () => {
      muteErrors();

      MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.pipeline_spec!.workflow_manifest = 'not JSON';

      tree = shallow(<TestNewRun {...(mockEmbeddedPipelineProps as any)} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          additionalInfo: 'Unexpected token o in JSON at position 1',
          message:
            "Error: failed to parse the embedded pipeline's spec: not JSON. Click Details for more information.",
          mode: 'error',
        }),
      );
    });

    it('displays a page error if referenced run has no embedded pipeline', async () => {
      muteErrors();

      // Remove workflow_manifest entirely
      delete MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.pipeline_spec!.workflow_manifest;

      tree = mount(<TestNewRun {...(mockEmbeddedPipelineProps as any)} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          message: `Error: somehow the run provided in the query params: ${
            MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id
          } had no embedded pipeline.`,
          mode: 'error',
        }),
      );
    });

    it('displays a page error if it fails to retrieve the run containing the embedded pipeline', async () => {
      muteErrors();

      getRunSpy.mockReset();
      TestUtils.makeErrorResponseOnce(getRunSpy, 'test - error!');

      tree = shallow(<TestNewRun {...(mockEmbeddedPipelineProps as any)} />);
      await TestUtils.flushPromises();

      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          additionalInfo: 'test - error!',
          message: `Error: failed to retrieve the specified run: ${
            MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id
          }. Click Details for more information.`,
          mode: 'error',
        }),
      );
    });
  });

  describe('starting a new run', () => {
    it("disables 'Start' new run button by default", async () => {
      tree = shallow(<TestNewRun {...(generateProps() as any)} />);
      await TestUtils.flushPromises();

      expect(tree.find('#startNewRunBtn').props()).toHaveProperty('disabled', true);
    });

    it("enables the 'Start' new run button if pipeline ID and pipeline version ID in query params and run name entered", async () => {
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = shallow(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: 'run name' } });
      await TestUtils.flushPromises();

      expect(tree.find('#startNewRunBtn').props()).toHaveProperty('disabled', false);
    });

    it("re-disables the 'Start' new run button if pipeline ID and pipeline version ID in query params and run name entered then cleared", async () => {
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = shallow(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: 'run name' } });
      await TestUtils.flushPromises();
      expect(tree.find('#startNewRunBtn').props()).toHaveProperty('disabled', false);

      (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: '' } });
      expect(tree.find('#startNewRunBtn').props()).toHaveProperty('disabled', true);
    });

    it("sends a request to Start a run when 'Start' is clicked", async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = mount(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      (tree.instance() as TestNewRun).handleChange('description')({
        target: { value: 'test run description' },
      });
      (tree.instance() as TestNewRun).handleChange('serviceAccount')({
        target: { value: 'service-account-name' },
      });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(startRunSpy).toHaveBeenCalledTimes(1);
      expect(startRunSpy).toHaveBeenLastCalledWith({
        description: 'test run description',
        name: 'test run name',
        pipeline_spec: {
          parameters: MOCK_PIPELINE.parameters,
        },
        service_account: 'service-account-name',
        resource_references: [
          {
            key: {
              id: MOCK_EXPERIMENT.id,
              type: ApiResourceType.EXPERIMENT,
            },
            relationship: ApiRelationship.OWNER,
          },
          {
            key: {
              id: MOCK_PIPELINE_VERSION.id,
              type: ApiResourceType.PIPELINEVERSION,
            },
            relationship: ApiRelationship.CREATOR,
          },
        ],
      });
    });

    it('sends a request to Start a run with the json editor open', async () => {
      const props = generateProps();
      const pipeline = newMockPipelineWithParameters();
      pipeline.parameters = [{ name: 'testName', value: 'testValue' }];
      props.location.search =
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${pipeline.id}`;
      tree = TestUtils.mountWithRouter(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      tree.setState({ parameters: pipeline.parameters });
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      (tree.instance() as TestNewRun).handleChange('description')({
        target: { value: 'test run description' },
      });

      tree
        .find('input#newRunPipelineParam0')
        .simulate('change', { target: { value: '{"test2": "value2"}' } });

      tree.find('TextField#newRunPipelineParam0 Button').simulate('click');

      tree.find('BusyButton#startNewRunBtn').simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(startRunSpy).toHaveBeenCalledTimes(1);
      expect(startRunSpy).toHaveBeenLastCalledWith({
        description: 'test run description',
        name: 'test run name',
        pipeline_spec: {
          parameters: [{ name: 'testName', value: '{\n  "test2": "value2"\n}' }],
        },
        service_account: '',
        resource_references: [
          {
            key: {
              id: MOCK_EXPERIMENT.id,
              type: ApiResourceType.EXPERIMENT,
            },
            relationship: ApiRelationship.OWNER,
          },
          {
            key: {
              id: 'original-run-pipeline-version-id',
              type: ApiResourceType.PIPELINEVERSION,
            },
            relationship: ApiRelationship.CREATOR,
          },
        ],
      });
    });

    it('updates the parameters in state on handleParamChange', async () => {
      const props = generateProps();
      const pipeline = newMockPipeline();
      const pipelineVersion = newMockPipelineVersion();
      pipelineVersion.parameters = [
        { name: 'param-1', value: '' },
        { name: 'param-2', value: 'prefilled value' },
      ];
      props.location.search = `?${QUERY_PARAMS.pipelineId}=${pipeline.id}&${QUERY_PARAMS.pipelineVersionId}=${pipelineVersion.id}`;

      getPipelineSpy.mockImplementation(() => pipeline);
      getPipelineVersionSpy.mockImplementation(() => pipelineVersion);

      tree = mount(<TestNewRun {...props} />);
      await TestUtils.flushPromises();
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      // Fill in the first pipeline parameter
      (tree.instance() as TestNewRun)._handleParamChange(0, 'test param value');

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(startRunSpy).toHaveBeenCalledTimes(1);
      expect(startRunSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          pipeline_spec: {
            parameters: [
              { name: 'param-1', value: 'test param value' },
              { name: 'param-2', value: 'prefilled value' },
            ],
          },
        }),
      );
    });

    it('copies pipeline from run in the start API call when cloning a run with embedded pipeline', async () => {
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${
        MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id
      }`;

      getRunSpy.mockImplementation(() => MOCK_RUN_WITH_EMBEDDED_PIPELINE);

      tree = mount(
        // Router is needed as context for Links to work.
        <MemoryRouter>
          <TestNewRun {...props} />
        </MemoryRouter>,
      );
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(startRunSpy).toHaveBeenCalledTimes(1);
      expect(startRunSpy).toHaveBeenLastCalledWith({
        description: '',
        name: 'Clone of ' + MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.name,
        pipeline_spec: {
          parameters: [],
          pipeline_id: undefined,
          workflow_manifest: '{"metadata":{"name":"embedded"},"parameters":[]}',
        },
        service_account: 'pipeline-runner',
        resource_references: [],
      });
      // TODO: verify route change happens
    });

    it('updates the pipeline params as user selects different pipelines', async () => {
      tree = shallow(<TestNewRun {...generateProps()} />);
      await TestUtils.flushPromises();

      // No parameters should be showing
      expect(tree).toMatchSnapshot();

      // Select a pipeline version with parameters
      const pipeline = newMockPipeline();
      const pipelineVersionWithParams = newMockPipelineVersion();
      pipelineVersionWithParams.id = 'pipeline-version-with-params';
      pipelineVersionWithParams.parameters = [
        { name: 'param-1', value: 'prefilled value 1' },
        { name: 'param-2', value: 'prefilled value 2' },
      ];
      getPipelineSpy.mockImplementationOnce(() => pipeline);
      getPipelineVersionSpy.mockImplementationOnce(() => pipelineVersionWithParams);
      tree.setState({ unconfirmedSelectedPipeline: pipeline });
      tree.setState({ unconfirmedSelectedPipelineVersion: pipelineVersionWithParams });
      const instance = tree.instance() as TestNewRun;
      instance._pipelineSelectorClosed(true);
      instance._pipelineVersionSelectorClosed(true);
      await TestUtils.flushPromises();
      expect(tree).toMatchSnapshot();

      // Select a new pipeline with no parameters
      const noParamsPipeline = newMockPipeline();
      noParamsPipeline.id = 'no-params-pipeline';
      noParamsPipeline.parameters = [];
      const noParamsPipelineVersion = newMockPipelineVersion();
      noParamsPipelineVersion.id = 'no-params-pipeline-version';
      noParamsPipelineVersion.parameters = [];
      getPipelineSpy.mockImplementationOnce(() => noParamsPipeline);
      getPipelineVersionSpy.mockImplementationOnce(() => noParamsPipelineVersion);
      tree.setState({ unconfirmedSelectedPipeline: noParamsPipeline });
      tree.setState({ unconfirmedSelectedPipelineVersion: noParamsPipelineVersion });
      instance._pipelineSelectorClosed(true);
      instance._pipelineVersionSelectorClosed(true);
      await TestUtils.flushPromises();
      expect(tree).toMatchSnapshot();
    });

    it('trims whitespace from the pipeline params', async () => {
      tree = mount(
        <MemoryRouter>
          <TestNewRun {...generateProps()} />
        </MemoryRouter>,
      );
      await TestUtils.flushPromises();
      const instance = tree.find(TestNewRun).instance() as TestNewRun;
      fillRequiredFields(instance);

      // Select a pipeline with parameters
      const pipeline = newMockPipeline();
      const pipelineVersionWithParams = newMockPipeline();
      pipelineVersionWithParams.id = 'pipeline-version-with-params';
      pipelineVersionWithParams.parameters = [
        { name: 'param-1', value: '  whitespace on either side  ' },
        { name: 'param-2', value: 'value 2' },
      ];
      getPipelineSpy.mockImplementationOnce(() => pipeline);
      getPipelineVersionSpy.mockImplementationOnce(() => pipelineVersionWithParams);
      instance.setState({ unconfirmedSelectedPipeline: pipeline });
      instance.setState({ unconfirmedSelectedPipelineVersion: pipelineVersionWithParams });
      instance._pipelineSelectorClosed(true);
      instance._pipelineVersionSelectorClosed(true);
      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      await TestUtils.flushPromises();

      expect(startRunSpy).toHaveBeenCalledTimes(1);
      expect(startRunSpy).toHaveBeenLastCalledWith(
        expect.objectContaining({
          pipeline_spec: {
            parameters: [
              { name: 'param-1', value: 'whitespace on either side' },
              { name: 'param-2', value: 'value 2' },
            ],
          },
        }),
      );
    });

    it("sets the page to a busy state upon clicking 'Start'", async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = mount(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');

      expect(tree.state('isBeingStarted')).toBe(true);
    });

    it('navigates to the ExperimentDetails page upon successful start if there was an experiment', async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = mount(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(historyPushSpy).toHaveBeenCalledWith(
        RoutePage.EXPERIMENT_DETAILS.replace(':' + RouteParams.experimentId, MOCK_EXPERIMENT.id!),
      );
    });

    it('navigates to the AllRuns page upon successful start if there was not an experiment', async () => {
      const props = generateProps();
      // No experiment in query params
      props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = mount(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(historyPushSpy).toHaveBeenCalledWith(RoutePage.RUNS);
    });

    it('shows an error dialog if Starting the new run fails', async () => {
      muteErrors();

      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      TestUtils.makeErrorResponseOnce(startRunSpy, 'test error message');

      tree = mount(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(updateDialogSpy).toHaveBeenCalledTimes(1);
      expect(updateDialogSpy.mock.calls[0][0]).toMatchObject({
        content: 'test error message',
        title: 'Run creation failed',
      });
    });

    it("shows an error dialog if 'Start' is clicked and the new run somehow has no pipeline", async () => {
      muteErrors();

      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`;

      tree = shallow(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      await TestUtils.flushPromises();

      tree.find('#startNewRunBtn').simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(updateDialogSpy).toHaveBeenCalledTimes(1);
      expect(updateDialogSpy.mock.calls[0][0]).toMatchObject({
        content: 'Cannot start run without pipeline version',
        title: 'Run creation failed',
      });
    });

    it('unsets the page to a busy state if starting run fails', async () => {
      muteErrors();

      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      TestUtils.makeErrorResponseOnce(startRunSpy, 'test error message');

      tree = mount(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(tree.state('isBeingStarted')).toBe(false);
    });

    it('shows snackbar confirmation after experiment is started', async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = mount(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .hostNodes()
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(updateSnackbarSpy).toHaveBeenLastCalledWith({
        message: 'Successfully started new Run: test run name',
        open: true,
      });
    });
  });

  describe('starting a new recurring run', () => {
    it('changes the title if the new run will recur, based on query param', async () => {
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.isRecurring}=1`;
      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(updateToolbarSpy).toHaveBeenLastCalledWith({
        actions: {},
        breadcrumbs: [{ displayName: 'Experiments', href: RoutePage.EXPERIMENTS }],
        pageTitle: 'Start a recurring run',
      });
    });

    it('includes additional trigger input fields if run will be recurring', async () => {
      const props = generateProps();
      props.location.search = `?${QUERY_PARAMS.isRecurring}=1`;
      tree = shallow(<TestNewRun {...props} />);
      await TestUtils.flushPromises();

      expect(tree).toMatchSnapshot();
    });

    it("sends a request to start a new recurring run with default periodic schedule when 'Start' is clicked", async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.isRecurring}=1` +
        `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = TestUtils.mountWithRouter(<TestNewRun {...props} />);
      const instance = tree.instance() as TestNewRun;
      await TestUtils.flushPromises();

      instance.handleChange('runName')({ target: { value: 'test run name' } });
      instance.handleChange('description')({ target: { value: 'test run description' } });
      instance.handleChange('serviceAccount')({ target: { value: 'service-account-name' } });
      await TestUtils.flushPromises();

      tree
        .find('#startNewRunBtn')
        .at(0)
        .simulate('click');
      // The start APIs are called in a callback triggered by clicking 'Start', so we wait again
      await TestUtils.flushPromises();

      expect(startRunSpy).toHaveBeenCalledTimes(0);
      expect(startJobSpy).toHaveBeenCalledTimes(1);
      expect(startJobSpy).toHaveBeenLastCalledWith({
        description: 'test run description',
        enabled: true,
        max_concurrency: '10',
        name: 'test run name',
        no_catchup: false,
        pipeline_spec: {
          parameters: MOCK_PIPELINE.parameters,
        },
        service_account: 'service-account-name',
        resource_references: [
          {
            key: {
              id: MOCK_EXPERIMENT.id,
              type: ApiResourceType.EXPERIMENT,
            },
            relationship: ApiRelationship.OWNER,
          },
          {
            key: {
              id: MOCK_PIPELINE_VERSION.id,
              type: ApiResourceType.PIPELINEVERSION,
            },
            relationship: ApiRelationship.CREATOR,
          },
        ],
        // Default trigger
        trigger: {
          periodic_schedule: {
            end_time: undefined,
            interval_second: '60',
            start_time: undefined,
          },
        },
      });
    });

    it('displays an error message if periodic schedule end date/time is earlier than start date/time', async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.isRecurring}=1` +
        `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = shallow(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      tree.setState({
        trigger: {
          periodic_schedule: {
            end_time: new Date(2018, 4, 1),
            start_time: new Date(2018, 5, 1),
          },
        },
      });
      await TestUtils.flushPromises();

      expect(tree.state('errorMessage')).toBe(
        'End date/time cannot be earlier than start date/time',
      );
    });

    it('displays an error message if cron schedule end date/time is earlier than start date/time', async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.isRecurring}=1` +
        `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = shallow(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      tree.setState({
        trigger: {
          cron_schedule: {
            end_time: new Date(2018, 4, 1),
            start_time: new Date(2018, 5, 1),
          },
        },
      });
      await TestUtils.flushPromises();

      expect(tree.state('errorMessage')).toBe(
        'End date/time cannot be earlier than start date/time',
      );
    });

    it('displays an error message if max concurrent runs is negative', async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.isRecurring}=1` +
        `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = shallow(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      tree.setState({
        maxConcurrentRuns: '-1',
        trigger: {
          periodic_schedule: {
            interval_second: '60',
          },
        },
      });
      await TestUtils.flushPromises();

      expect(tree.state('errorMessage')).toBe(
        'For triggered runs, maximum concurrent runs must be a positive number',
      );
    });

    it('displays an error message if max concurrent runs is not a number', async () => {
      const props = generateProps();
      props.location.search =
        `?${QUERY_PARAMS.isRecurring}=1` +
        `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` +
        `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`;

      tree = shallow(<TestNewRun {...props} />);
      (tree.instance() as TestNewRun).handleChange('runName')({
        target: { value: 'test run name' },
      });
      tree.setState({
        maxConcurrentRuns: 'not a number',
        trigger: {
          periodic_schedule: {
            interval_second: '60',
          },
        },
      });
      await TestUtils.flushPromises();

      expect(tree.state('errorMessage')).toBe(
        'For triggered runs, maximum concurrent runs must be a positive number',
      );
    });
  });
});
Example #26
Source File: NewPipelineVersion.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('NewPipelineVersion', () => {
  let tree: ReactWrapper | ShallowWrapper;

  const historyPushSpy = jest.fn();
  const historyReplaceSpy = jest.fn();
  const updateBannerSpy = jest.fn();
  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  const updateToolbarSpy = jest.fn();

  let getPipelineSpy: jest.SpyInstance<{}>;
  let createPipelineSpy: jest.SpyInstance<{}>;
  let createPipelineVersionSpy: jest.SpyInstance<{}>;
  let uploadPipelineSpy: jest.SpyInstance<{}>;

  let MOCK_PIPELINE = {
    id: 'original-run-pipeline-id',
    name: 'original mock pipeline name',
    default_version: {
      id: 'original-run-pipeline-version-id',
      name: 'original mock pipeline version name',
      resource_references: [
        {
          key: {
            id: 'original-run-pipeline-id',
            type: ApiResourceType.PIPELINE,
          },
          relationship: 1,
        },
      ],
    },
  };

  let MOCK_PIPELINE_VERSION = {
    id: 'original-run-pipeline-version-id',
    name: 'original mock pipeline version name',
    resource_references: [
      {
        key: {
          id: 'original-run-pipeline-id',
          type: ApiResourceType.PIPELINE,
        },
        relationship: 1,
      },
    ],
  };

  function generateProps(search?: string): PageProps {
    return {
      history: { push: historyPushSpy, replace: historyReplaceSpy } as any,
      location: {
        pathname: RoutePage.NEW_PIPELINE_VERSION,
        search: search,
      } as any,
      match: '' as any,
      toolbarProps: TestNewPipelineVersion.prototype.getInitialToolbarState(),
      updateBanner: updateBannerSpy,
      updateDialog: updateDialogSpy,
      updateSnackbar: updateSnackbarSpy,
      updateToolbar: updateToolbarSpy,
    };
  }

  beforeEach(() => {
    jest.clearAllMocks();
    getPipelineSpy = jest
      .spyOn(Apis.pipelineServiceApi, 'getPipeline')
      .mockImplementation(() => MOCK_PIPELINE);
    createPipelineVersionSpy = jest
      .spyOn(Apis.pipelineServiceApi, 'createPipelineVersion')
      .mockImplementation(() => MOCK_PIPELINE_VERSION);
    createPipelineSpy = jest
      .spyOn(Apis.pipelineServiceApi, 'createPipeline')
      .mockImplementation(() => MOCK_PIPELINE);
    uploadPipelineSpy = jest.spyOn(Apis, 'uploadPipeline').mockImplementation(() => MOCK_PIPELINE);
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    if (tree) {
      await tree.unmount();
    }
    jest.resetAllMocks();
    jest.restoreAllMocks();
  });

  // New pipeline version page has two functionalities: creating a pipeline and creating a version under an existing pipeline.
  // Our tests will be divided into 3 parts: switching between creating pipeline or creating version; test pipeline creation; test pipeline version creation.

  describe('switching between creating pipeline and creating pipeline version', () => {
    it('creates pipeline is default when landing from pipeline list page', () => {
      tree = shallow(<TestNewPipelineVersion {...generateProps()} />);

      // When landing from pipeline list page, the default is to create pipeline
      expect(tree.state('newPipeline')).toBe(true);

      // Switch to create pipeline version
      tree.find('#createPipelineVersionUnderExistingPipelineBtn').simulate('change');
      expect(tree.state('newPipeline')).toBe(false);

      // Switch back
      tree.find('#createNewPipelineBtn').simulate('change');
      expect(tree.state('newPipeline')).toBe(true);
    });

    it('creates pipeline version is default when landing from pipeline details page', () => {
      tree = shallow(
        <TestNewPipelineVersion
          {...generateProps(`?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`)}
        />,
      );

      // When landing from pipeline list page, the default is to create pipeline
      expect(tree.state('newPipeline')).toBe(false);

      // Switch to create pipeline version
      tree.find('#createNewPipelineBtn').simulate('change');
      expect(tree.state('newPipeline')).toBe(true);

      // Switch back
      tree.find('#createPipelineVersionUnderExistingPipelineBtn').simulate('change');
      expect(tree.state('newPipeline')).toBe(false);
    });
  });

  describe('creating version under an existing pipeline', () => {
    it('does not include any action buttons in the toolbar', async () => {
      tree = shallow(
        <TestNewPipelineVersion
          {...generateProps(`?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`)}
        />,
      );
      await TestUtils.flushPromises();

      expect(updateToolbarSpy).toHaveBeenLastCalledWith({
        actions: {},
        breadcrumbs: [{ displayName: 'Pipeline Versions', href: '/pipeline_versions/new' }],
        pageTitle: 'Upload Pipeline or Pipeline Version',
      });
      expect(getPipelineSpy).toHaveBeenCalledTimes(1);
    });

    it('allows updating pipeline version name', async () => {
      tree = shallow(
        <TestNewPipelineVersion
          {...generateProps(`?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`)}
        />,
      );
      await TestUtils.flushPromises();

      (tree.instance() as TestNewPipelineVersion).handleChange('pipelineVersionName')({
        target: { value: 'version name' },
      });

      expect(tree.state()).toHaveProperty('pipelineVersionName', 'version name');
      expect(getPipelineSpy).toHaveBeenCalledTimes(1);
    });

    it('allows updating package url', async () => {
      tree = shallow(
        <TestNewPipelineVersion
          {...generateProps(`?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`)}
        />,
      );
      await TestUtils.flushPromises();

      (tree.instance() as TestNewPipelineVersion).handleChange('packageUrl')({
        target: { value: 'https://dummy' },
      });

      expect(tree.state()).toHaveProperty('packageUrl', 'https://dummy');
      expect(getPipelineSpy).toHaveBeenCalledTimes(1);
    });

    it('allows updating code source', async () => {
      tree = shallow(
        <TestNewPipelineVersion
          {...generateProps(`?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`)}
        />,
      );
      await TestUtils.flushPromises();

      (tree.instance() as TestNewPipelineVersion).handleChange('codeSourceUrl')({
        target: { value: 'https://dummy' },
      });

      expect(tree.state()).toHaveProperty('codeSourceUrl', 'https://dummy');
      expect(getPipelineSpy).toHaveBeenCalledTimes(1);
    });

    it("sends a request to create a version when 'Create' is clicked", async () => {
      tree = shallow(
        <TestNewPipelineVersion
          {...generateProps(`?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`)}
        />,
      );
      await TestUtils.flushPromises();

      (tree.instance() as TestNewPipelineVersion).handleChange('pipelineVersionName')({
        target: { value: 'test version name' },
      });
      (tree.instance() as TestNewPipelineVersion).handleChange('packageUrl')({
        target: { value: 'https://dummy_package_url' },
      });
      await TestUtils.flushPromises();

      tree.find('#createNewPipelineOrVersionBtn').simulate('click');
      // The APIs are called in a callback triggered by clicking 'Create', so we wait again
      await TestUtils.flushPromises();

      expect(createPipelineVersionSpy).toHaveBeenCalledTimes(1);
      expect(createPipelineVersionSpy).toHaveBeenLastCalledWith({
        code_source_url: '',
        name: 'test version name',
        package_url: {
          pipeline_url: 'https://dummy_package_url',
        },
        resource_references: [
          {
            key: {
              id: MOCK_PIPELINE.id,
              type: ApiResourceType.PIPELINE,
            },
            relationship: 1,
          },
        ],
      });
    });

    // TODO(jingzhang36): test error dialog if creating pipeline version fails
  });

  describe('creating new pipeline', () => {
    it('renders the new pipeline page', async () => {
      tree = shallow(<TestNewPipelineVersion {...generateProps()} />);
      await TestUtils.flushPromises();
      expect(tree).toMatchSnapshot();
    });

    it('switches between import methods', () => {
      tree = shallow(<TestNewPipelineVersion {...generateProps()} />);

      // Import method is URL by default
      expect(tree.state('importMethod')).toBe(ImportMethod.URL);

      // Click to import by local
      tree.find('#localPackageBtn').simulate('change');
      expect(tree.state('importMethod')).toBe(ImportMethod.LOCAL);

      // Click back to URL
      tree.find('#remotePackageBtn').simulate('change');
      expect(tree.state('importMethod')).toBe(ImportMethod.URL);
    });

    it('creates pipeline from url', async () => {
      tree = shallow(<TestNewPipelineVersion {...generateProps()} />);

      (tree.instance() as TestNewPipelineVersion).handleChange('pipelineName')({
        target: { value: 'test pipeline name' },
      });
      (tree.instance() as TestNewPipelineVersion).handleChange('pipelineDescription')({
        target: { value: 'test pipeline description' },
      });
      (tree.instance() as TestNewPipelineVersion).handleChange('packageUrl')({
        target: { value: 'https://dummy_package_url' },
      });
      await TestUtils.flushPromises();

      tree.find('#createNewPipelineOrVersionBtn').simulate('click');
      // The APIs are called in a callback triggered by clicking 'Create', so we wait again
      await TestUtils.flushPromises();

      expect(tree.state()).toHaveProperty('newPipeline', true);
      expect(tree.state()).toHaveProperty('importMethod', ImportMethod.URL);
      expect(createPipelineSpy).toHaveBeenCalledTimes(1);
      expect(createPipelineSpy).toHaveBeenLastCalledWith({
        description: 'test pipeline description',
        name: 'test pipeline name',
        url: {
          pipeline_url: 'https://dummy_package_url',
        },
      });
    });

    it('creates pipeline from local file', async () => {
      tree = shallow(<NewPipelineVersion {...generateProps()} />);

      // Set local file, pipeline name, pipeline description and click create
      tree.find('#localPackageBtn').simulate('change');
      (tree.instance() as TestNewPipelineVersion).handleChange('pipelineName')({
        target: { value: 'test pipeline name' },
      });
      (tree.instance() as TestNewPipelineVersion).handleChange('pipelineDescription')({
        target: { value: 'test pipeline description' },
      });
      const file = new File(['file contents'], 'file_name', { type: 'text/plain' });
      (tree.instance() as TestNewPipelineVersion)._onDropForTest([file]);
      tree.find('#createNewPipelineOrVersionBtn').simulate('click');

      tree.update();
      await TestUtils.flushPromises();

      expect(tree.state('importMethod')).toBe(ImportMethod.LOCAL);
      expect(uploadPipelineSpy).toHaveBeenLastCalledWith(
        'test pipeline name',
        'test pipeline description',
        file,
      );
      expect(createPipelineSpy).not.toHaveBeenCalled();
    });
  });
});
Example #27
Source File: NewExperiment.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('NewExperiment', () => {
  let tree: ReactWrapper | ShallowWrapper;
  const createExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'createExperiment');
  const historyPushSpy = jest.fn();
  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  const updateToolbarSpy = jest.fn();

  function generateProps(): PageProps {
    return {
      history: { push: historyPushSpy } as any,
      location: { pathname: RoutePage.NEW_EXPERIMENT } as any,
      match: '' as any,
      toolbarProps: NewExperiment.prototype.getInitialToolbarState(),
      updateBanner: () => null,
      updateDialog: updateDialogSpy,
      updateSnackbar: updateSnackbarSpy,
      updateToolbar: updateToolbarSpy,
    };
  }

  // Used by tests that don't care about exact experiment name
  function fillAnyExperimentName() {
    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'a-random-experiment-name-DO-NOT-VERIFY-THIS' },
    });
  }

  beforeEach(() => {
    // Reset mocks
    createExperimentSpy.mockReset();
    historyPushSpy.mockReset();
    updateDialogSpy.mockReset();
    updateSnackbarSpy.mockReset();
    updateToolbarSpy.mockReset();

    createExperimentSpy.mockImplementation(() => ({ id: 'new-experiment-id' }));
  });

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

  it('renders the new experiment page', () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);
    expect(tree).toMatchSnapshot();
  });

  it('does not include any action buttons in the toolbar', () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);

    expect(updateToolbarSpy).toHaveBeenCalledWith({
      actions: {},
      breadcrumbs: [{ displayName: 'Experiments', href: RoutePage.EXPERIMENTS }],
      pageTitle: 'New experiment',
    });
  });

  it("enables the 'Next' button when an experiment name is entered", () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);
    expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', true);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment name' },
    });

    expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', false);
    expect(tree).toMatchSnapshot();
  });

  it("re-disables the 'Next' button when an experiment name is cleared after having been entered", () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);
    expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', true);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment name' },
    });
    expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', false);

    (tree.instance() as any).handleChange('experimentName')({ target: { value: '' } });
    expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', true);
    expect(tree).toMatchSnapshot();
  });

  it('updates the experiment name', () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);
    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment name' },
    });

    expect(tree.state()).toEqual({
      description: '',
      experimentName: 'experiment name',
      isbeingCreated: false,
      validationError: '',
    });
  });

  it('updates the experiment description', () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);
    (tree.instance() as any).handleChange('description')({ target: { value: 'a description!' } });

    expect(tree.state()).toEqual({
      description: 'a description!',
      experimentName: '',
      isbeingCreated: false,
      validationError: 'Experiment name is required',
    });
  });

  it("sets the page to a busy state upon clicking 'Next'", async () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment-name' },
    });

    tree.find('#createExperimentBtn').simulate('click');
    await TestUtils.flushPromises();

    expect(tree.state()).toHaveProperty('isbeingCreated', true);
    expect(tree.find('#createExperimentBtn').props()).toHaveProperty('busy', true);
  });

  it("calls the createExperiment API with the new experiment upon clicking 'Next'", async () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment name' },
    });
    (tree.instance() as any).handleChange('description')({
      target: { value: 'experiment description' },
    });

    tree.find('#createExperimentBtn').simulate('click');
    await TestUtils.flushPromises();

    expect(createExperimentSpy).toHaveBeenCalledWith({
      description: 'experiment description',
      name: 'experiment name',
    });
  });

  it('calls the createExperimentAPI with namespace when it is provided', async () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} namespace='test-ns' />);

    fillAnyExperimentName();
    tree.find('#createExperimentBtn').simulate('click');
    await TestUtils.flushPromises();

    expect(createExperimentSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        resource_references: [
          {
            key: {
              id: 'test-ns',
              type: ApiResourceType.NAMESPACE,
            },
            relationship: ApiRelationship.OWNER,
          },
        ],
      }),
    );
  });

  it('navigates to NewRun page upon successful creation', async () => {
    const experimentId = 'test-exp-id-1';
    createExperimentSpy.mockImplementation(() => ({ id: experimentId }));
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment-name' },
    });

    tree.find('#createExperimentBtn').simulate('click');
    await createExperimentSpy;
    await TestUtils.flushPromises();

    expect(historyPushSpy).toHaveBeenCalledWith(
      RoutePage.NEW_RUN + `?experimentId=${experimentId}` + `&firstRunInExperiment=1`,
    );
  });

  it('includes pipeline ID in NewRun page query params if present', async () => {
    const experimentId = 'test-exp-id-1';
    createExperimentSpy.mockImplementation(() => ({ id: experimentId }));

    const pipelineId = 'some-pipeline-id';
    const props = generateProps();
    props.location.search = `?${QUERY_PARAMS.pipelineId}=${pipelineId}`;
    tree = shallow(<NewExperiment {...(props as any)} />);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment-name' },
    });

    tree.find('#createExperimentBtn').simulate('click');
    await createExperimentSpy;
    await TestUtils.flushPromises();

    expect(historyPushSpy).toHaveBeenCalledWith(
      RoutePage.NEW_RUN +
        `?experimentId=${experimentId}` +
        `&pipelineId=${pipelineId}` +
        `&firstRunInExperiment=1`,
    );
  });

  it('shows snackbar confirmation after experiment is created', async () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment-name' },
    });

    tree.find('#createExperimentBtn').simulate('click');
    await TestUtils.flushPromises();

    expect(updateSnackbarSpy).toHaveBeenLastCalledWith({
      autoHideDuration: 10000,
      message: 'Successfully created new Experiment: experiment-name',
      open: true,
    });
  });

  it('unsets busy state when creation fails', async () => {
    // Don't actually log to console.
    // tslint:disable-next-line:no-console
    console.error = jest.spyOn(console, 'error').mockImplementation();

    tree = shallow(<NewExperiment {...(generateProps() as any)} />);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment-name' },
    });

    TestUtils.makeErrorResponseOnce(createExperimentSpy, 'test error!');
    tree.find('#createExperimentBtn').simulate('click');
    await createExperimentSpy;
    await TestUtils.flushPromises();

    expect(tree.state()).toHaveProperty('isbeingCreated', false);
  });

  it('shows error dialog when creation fails', async () => {
    // Don't actually log to console.
    // tslint:disable-next-line:no-console
    console.error = jest.spyOn(console, 'error').mockImplementation();

    tree = shallow(<NewExperiment {...(generateProps() as any)} />);

    (tree.instance() as any).handleChange('experimentName')({
      target: { value: 'experiment-name' },
    });

    TestUtils.makeErrorResponseOnce(createExperimentSpy, 'test error!');
    tree.find('#createExperimentBtn').simulate('click');
    await createExperimentSpy;
    await TestUtils.flushPromises();

    const call = updateDialogSpy.mock.calls[0][0];
    expect(call).toHaveProperty('title', 'Experiment creation failed');
    expect(call).toHaveProperty('content', 'test error!');
  });

  it('navigates to experiment list page upon cancellation', async () => {
    tree = shallow(<NewExperiment {...(generateProps() as any)} />);
    tree.find('#cancelNewExperimentBtn').simulate('click');
    await TestUtils.flushPromises();

    expect(historyPushSpy).toHaveBeenCalledWith(RoutePage.EXPERIMENTS);
  });
});
Example #28
Source File: ExperimentList.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('ExperimentList', () => {
  let tree: ShallowWrapper | ReactWrapper;

  jest.spyOn(console, 'log').mockImplementation(() => null);

  const updateBannerSpy = jest.fn();
  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  const updateToolbarSpy = jest.fn();
  const historyPushSpy = jest.fn();
  const listExperimentsSpy = jest.spyOn(Apis.experimentServiceApi, 'listExperiment');
  const listRunsSpy = jest.spyOn(Apis.runServiceApi, 'listRuns');
  // We mock this because it uses toLocaleDateString, which causes mismatches between local and CI
  // test enviroments
  jest.spyOn(Utils, 'formatDateString').mockImplementation(() => '1/2/2019, 12:34:56 PM');

  function generateProps(): PageProps {
    return TestUtils.generatePageProps(
      ExperimentList,
      { pathname: RoutePage.EXPERIMENTS } as any,
      '' as any,
      historyPushSpy,
      updateBannerSpy,
      updateDialogSpy,
      updateToolbarSpy,
      updateSnackbarSpy,
    );
  }

  function mockListNExpperiments(n: number = 1) {
    return () =>
      Promise.resolve({
        experiments: range(n).map(i => ({
          id: 'test-experiment-id' + i,
          name: 'test experiment name' + i,
        })),
      });
  }

  async function mountWithNExperiments(
    n: number,
    nRuns: number,
    { namespace }: { namespace?: string } = {},
  ): Promise<void> {
    listExperimentsSpy.mockImplementation(mockListNExpperiments(n));
    listRunsSpy.mockImplementation(() => ({
      runs: range(nRuns).map(i => ({ id: 'test-run-id' + i, name: 'test run name' + i })),
    }));
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} namespace={namespace} />);
    await listExperimentsSpy;
    await listRunsSpy;
    await TestUtils.flushPromises();
    tree.update(); // Make sure the tree is updated before returning it
  }

  afterEach(() => {
    jest.resetAllMocks();
    jest.clearAllMocks();
    if (tree.exists()) {
      tree.unmount();
    }
  });

  it('renders an empty list with empty state message', () => {
    tree = shallow(<ExperimentList {...generateProps()} />);
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one experiment', async () => {
    tree = shallow(<ExperimentList {...generateProps()} />);
    tree.setState({
      displayExperiments: [
        {
          description: 'test experiment description',
          expandState: ExpandState.COLLAPSED,
          name: 'test experiment name',
        },
      ],
    });
    await listExperimentsSpy;
    await listRunsSpy;
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one experiment with no description', async () => {
    tree = shallow(<ExperimentList {...generateProps()} />);
    tree.setState({
      experiments: [
        {
          expandState: ExpandState.COLLAPSED,
          name: 'test experiment name',
        },
      ],
    });
    await listExperimentsSpy;
    await listRunsSpy;
    expect(tree).toMatchSnapshot();
  });

  it('renders a list of one experiment with error', async () => {
    tree = shallow(<ExperimentList {...generateProps()} />);
    tree.setState({
      experiments: [
        {
          description: 'test experiment description',
          error: 'oops! could not load experiment',
          expandState: ExpandState.COLLAPSED,
          name: 'test experiment name',
        },
      ],
    });
    await listExperimentsSpy;
    await listRunsSpy;
    expect(tree).toMatchSnapshot();
  });

  it('calls Apis to list experiments, sorted by creation time in descending order', async () => {
    await mountWithNExperiments(1, 1);
    expect(listExperimentsSpy).toHaveBeenLastCalledWith(...LIST_EXPERIMENT_DEFAULTS);
    expect(listRunsSpy).toHaveBeenLastCalledWith(
      undefined,
      5,
      'created_at desc',
      'EXPERIMENT',
      'test-experiment-id0',
      encodeURIComponent(
        JSON.stringify({
          predicates: [
            {
              key: 'storage_state',
              op: PredicateOp.NOTEQUALS,
              string_value: RunStorageState.ARCHIVED.toString(),
            },
          ],
        } as ApiFilter),
      ),
    );
    expect(tree.state()).toHaveProperty('displayExperiments', [
      {
        expandState: ExpandState.COLLAPSED,
        id: 'test-experiment-id0',
        last5Runs: [{ id: 'test-run-id0', name: 'test run name0' }],
        name: 'test experiment name0',
      },
    ]);
  });

  it('calls Apis to list experiments with namespace when available', async () => {
    await mountWithNExperiments(1, 1, { namespace: 'test-ns' });
    expect(listExperimentsSpy).toHaveBeenLastCalledWith(
      ...LIST_EXPERIMENT_DEFAULTS_WITHOUT_RESOURCE_REFERENCE,
      'NAMESPACE',
      'test-ns',
    );
  });

  it('has a Refresh button, clicking it refreshes the experiment list', async () => {
    await mountWithNExperiments(1, 1);
    const instance = tree.instance() as ExperimentList;
    expect(listExperimentsSpy.mock.calls.length).toBe(1);
    const refreshBtn = instance.getInitialToolbarState().actions[ButtonKeys.REFRESH];
    expect(refreshBtn).toBeDefined();
    await refreshBtn!.action();
    expect(listExperimentsSpy.mock.calls.length).toBe(2);
    expect(listExperimentsSpy).toHaveBeenLastCalledWith(...LIST_EXPERIMENT_DEFAULTS);
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

  it('shows error banner when listing experiments fails', async () => {
    TestUtils.makeErrorResponseOnce(listExperimentsSpy, 'bad stuff happened');
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} />);
    await listExperimentsSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'bad stuff happened',
        message:
          'Error: failed to retrieve list of experiments. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('shows error next to experiment when listing its last 5 runs fails', async () => {
    // tslint:disable-next-line:no-console
    console.error = jest.spyOn(console, 'error').mockImplementation();

    listExperimentsSpy.mockImplementationOnce(() => ({ experiments: [{ name: 'exp1' }] }));
    TestUtils.makeErrorResponseOnce(listRunsSpy, 'bad stuff happened');
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} />);
    await listExperimentsSpy;
    await TestUtils.flushPromises();
    expect(tree.state()).toHaveProperty('displayExperiments', [
      {
        error: 'Failed to load the last 5 runs of this experiment',
        expandState: 0,
        name: 'exp1',
      },
    ]);
  });

  it('shows error banner when listing experiments fails after refresh', async () => {
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} />);
    const instance = tree.instance() as ExperimentList;
    const refreshBtn = instance.getInitialToolbarState().actions[ButtonKeys.REFRESH];
    expect(refreshBtn).toBeDefined();
    TestUtils.makeErrorResponseOnce(listExperimentsSpy, 'bad stuff happened');
    await refreshBtn!.action();
    expect(listExperimentsSpy.mock.calls.length).toBe(2);
    expect(listExperimentsSpy).toHaveBeenLastCalledWith(...LIST_EXPERIMENT_DEFAULTS);
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'bad stuff happened',
        message:
          'Error: failed to retrieve list of experiments. Click Details for more information.',
        mode: 'error',
      }),
    );
  });

  it('hides error banner when listing experiments fails then succeeds', async () => {
    TestUtils.makeErrorResponseOnce(listExperimentsSpy, 'bad stuff happened');
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} />);
    const instance = tree.instance() as ExperimentList;
    await listExperimentsSpy;
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'bad stuff happened',
        message:
          'Error: failed to retrieve list of experiments. Click Details for more information.',
        mode: 'error',
      }),
    );
    updateBannerSpy.mockReset();

    const refreshBtn = instance.getInitialToolbarState().actions[ButtonKeys.REFRESH];
    listExperimentsSpy.mockImplementationOnce(() => ({ experiments: [{ name: 'experiment1' }] }));
    listRunsSpy.mockImplementationOnce(() => ({ runs: [{ name: 'run1' }] }));
    await refreshBtn!.action();
    expect(listExperimentsSpy.mock.calls.length).toBe(2);
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

  it('can expand an experiment to see its runs', async () => {
    await mountWithNExperiments(1, 1);
    tree
      .find('.tableRow button')
      .at(0)
      .simulate('click');
    expect(tree.state()).toHaveProperty('displayExperiments', [
      {
        expandState: ExpandState.EXPANDED,
        id: 'test-experiment-id0',
        last5Runs: [{ id: 'test-run-id0', name: 'test run name0' }],
        name: 'test experiment name0',
      },
    ]);
  });

  it('renders a list of runs for given experiment', async () => {
    tree = shallow(<ExperimentList {...generateProps()} />);
    tree.setState({
      displayExperiments: [{ id: 'experiment1', last5Runs: [{ id: 'run1id' }, { id: 'run2id' }] }],
    });
    const runListTree = (tree.instance() as any)._getExpandedExperimentComponent(0);
    expect(runListTree.props.experimentIdMask).toEqual('experiment1');
  });

  it('navigates to new experiment page when Create experiment button is clicked', async () => {
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} />);
    const createBtn = (tree.instance() as ExperimentList).getInitialToolbarState().actions[
      ButtonKeys.NEW_EXPERIMENT
    ];
    await createBtn!.action();
    expect(historyPushSpy).toHaveBeenLastCalledWith(RoutePage.NEW_EXPERIMENT);
  });

  it('always has new experiment button enabled', async () => {
    await mountWithNExperiments(1, 1);
    const calls = updateToolbarSpy.mock.calls[0];
    expect(calls[0].actions[ButtonKeys.NEW_EXPERIMENT]).not.toHaveProperty('disabled');
  });

  it('enables clone button when one run is selected', async () => {
    await mountWithNExperiments(1, 1);
    (tree.instance() as any)._selectionChanged(['run1']);
    expect(updateToolbarSpy).toHaveBeenCalledTimes(2);
    expect(updateToolbarSpy.mock.calls[0][0].actions[ButtonKeys.CLONE_RUN]).toHaveProperty(
      'disabled',
      true,
    );
    expect(updateToolbarSpy.mock.calls[1][0].actions[ButtonKeys.CLONE_RUN]).toHaveProperty(
      'disabled',
      false,
    );
  });

  it('disables clone button when more than one run is selected', async () => {
    await mountWithNExperiments(1, 1);
    (tree.instance() as any)._selectionChanged(['run1', 'run2']);
    expect(updateToolbarSpy).toHaveBeenCalledTimes(2);
    expect(updateToolbarSpy.mock.calls[0][0].actions[ButtonKeys.CLONE_RUN]).toHaveProperty(
      'disabled',
      true,
    );
    expect(updateToolbarSpy.mock.calls[1][0].actions[ButtonKeys.CLONE_RUN]).toHaveProperty(
      'disabled',
      true,
    );
  });

  it('enables compare runs button only when more than one is selected', async () => {
    await mountWithNExperiments(1, 1);
    (tree.instance() as any)._selectionChanged(['run1']);
    (tree.instance() as any)._selectionChanged(['run1', 'run2']);
    (tree.instance() as any)._selectionChanged(['run1', 'run2', 'run3']);
    expect(updateToolbarSpy).toHaveBeenCalledTimes(4);
    expect(updateToolbarSpy.mock.calls[0][0].actions[ButtonKeys.COMPARE]).toHaveProperty(
      'disabled',
      true,
    );
    expect(updateToolbarSpy.mock.calls[1][0].actions[ButtonKeys.COMPARE]).toHaveProperty(
      'disabled',
      false,
    );
    expect(updateToolbarSpy.mock.calls[2][0].actions[ButtonKeys.COMPARE]).toHaveProperty(
      'disabled',
      false,
    );
  });

  it('navigates to compare page with the selected run ids', async () => {
    await mountWithNExperiments(1, 1);
    (tree.instance() as any)._selectionChanged(['run1', 'run2', 'run3']);
    const compareBtn = (tree.instance() as ExperimentList).getInitialToolbarState().actions[
      ButtonKeys.COMPARE
    ];
    await compareBtn!.action();
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      `${RoutePage.COMPARE}?${QUERY_PARAMS.runlist}=run1,run2,run3`,
    );
  });

  it('navigates to new run page with the selected run id for cloning', async () => {
    await mountWithNExperiments(1, 1);
    (tree.instance() as any)._selectionChanged(['run1']);
    const cloneBtn = (tree.instance() as ExperimentList).getInitialToolbarState().actions[
      ButtonKeys.CLONE_RUN
    ];
    await cloneBtn!.action();
    expect(historyPushSpy).toHaveBeenLastCalledWith(
      `${RoutePage.NEW_RUN}?${QUERY_PARAMS.cloneFromRun}=run1`,
    );
  });

  it('enables archive button when at least one run is selected', async () => {
    await mountWithNExperiments(1, 1);
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ARCHIVE).disabled).toBeTruthy();
    (tree.instance() as any)._selectionChanged(['run1']);
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ARCHIVE).disabled).toBeFalsy();
    (tree.instance() as any)._selectionChanged(['run1', 'run2']);
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ARCHIVE).disabled).toBeFalsy();
    (tree.instance() as any)._selectionChanged([]);
    expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.ARCHIVE).disabled).toBeTruthy();
  });

  it('renders experiment names as links to their details pages', async () => {
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} />);
    expect(
      (tree.instance() as ExperimentList)._nameCustomRenderer({
        id: 'experiment-id',
        value: 'experiment name',
      }),
    ).toMatchSnapshot();
  });

  it('renders last 5 runs statuses', async () => {
    tree = TestUtils.mountWithRouter(<ExperimentList {...generateProps()} />);
    expect(
      (tree.instance() as ExperimentList)._last5RunsCustomRenderer({
        id: 'experiment-id',
        value: [
          { status: NodePhase.SUCCEEDED },
          { status: NodePhase.PENDING },
          { status: NodePhase.FAILED },
          { status: NodePhase.UNKNOWN },
          { status: NodePhase.SUCCEEDED },
        ],
      }),
    ).toMatchSnapshot();
  });

  describe('EnhancedExperimentList', () => {
    it('defaults to no namespace', () => {
      render(<EnhancedExperimentList {...generateProps()} />);
      expect(listExperimentsSpy).toHaveBeenLastCalledWith(...LIST_EXPERIMENT_DEFAULTS);
    });

    it('gets namespace from context', () => {
      render(
        <NamespaceContext.Provider value='test-ns'>
          <EnhancedExperimentList {...generateProps()} />
        </NamespaceContext.Provider>,
      );
      expect(listExperimentsSpy).toHaveBeenLastCalledWith(
        ...LIST_EXPERIMENT_DEFAULTS_WITHOUT_RESOURCE_REFERENCE,
        'NAMESPACE',
        'test-ns',
      );
    });

    it('auto refreshes list when namespace changes', () => {
      const { rerender } = render(
        <NamespaceContext.Provider value='test-ns-1'>
          <EnhancedExperimentList {...generateProps()} />
        </NamespaceContext.Provider>,
      );
      expect(listExperimentsSpy).toHaveBeenCalledTimes(1);
      expect(listExperimentsSpy).toHaveBeenLastCalledWith(
        ...LIST_EXPERIMENT_DEFAULTS_WITHOUT_RESOURCE_REFERENCE,
        'NAMESPACE',
        'test-ns-1',
      );
      rerender(
        <NamespaceContext.Provider value='test-ns-2'>
          <EnhancedExperimentList {...generateProps()} />
        </NamespaceContext.Provider>,
      );
      expect(listExperimentsSpy).toHaveBeenCalledTimes(2);
      expect(listExperimentsSpy).toHaveBeenLastCalledWith(
        ...LIST_EXPERIMENT_DEFAULTS_WITHOUT_RESOURCE_REFERENCE,
        'NAMESPACE',
        'test-ns-2',
      );
    });

    it("doesn't keep error message for request from previous namespace", async () => {
      listExperimentsSpy.mockImplementation(() => Promise.reject('namespace cannot be empty'));
      const { rerender } = render(
        <MemoryRouter>
          <NamespaceContext.Provider value={undefined}>
            <EnhancedExperimentList {...generateProps()} />
          </NamespaceContext.Provider>
        </MemoryRouter>,
      );

      listExperimentsSpy.mockImplementation(mockListNExpperiments());
      rerender(
        <MemoryRouter>
          <NamespaceContext.Provider value={'test-ns'}>
            <EnhancedExperimentList {...generateProps()} />
          </NamespaceContext.Provider>
        </MemoryRouter>,
      );
      await act(TestUtils.flushPromises);
      expect(updateBannerSpy).toHaveBeenLastCalledWith(
        {}, // Empty object means banner has no error message
      );
    });
  });
});
Example #29
Source File: ExperimentDetails.test.tsx    From kfp-tekton-backend with Apache License 2.0 4 votes vote down vote up
describe('ExperimentDetails', () => {
  let tree: ReactWrapper | ShallowWrapper;

  const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => null);
  const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => null);

  const updateToolbarSpy = jest.fn();
  const updateBannerSpy = jest.fn();
  const updateDialogSpy = jest.fn();
  const updateSnackbarSpy = jest.fn();
  const historyPushSpy = jest.fn();
  const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment');
  const listJobsSpy = jest.spyOn(Apis.jobServiceApi, 'listJobs');
  const listRunsSpy = jest.spyOn(Apis.runServiceApi, 'listRuns');

  const MOCK_EXPERIMENT = newMockExperiment();

  function newMockExperiment(): ApiExperiment {
    return {
      description: 'mock experiment description',
      id: 'some-mock-experiment-id',
      name: 'some mock experiment name',
    };
  }

  function generateProps(): PageProps {
    const match = { params: { [RouteParams.experimentId]: MOCK_EXPERIMENT.id } } as any;
    return TestUtils.generatePageProps(
      ExperimentDetails,
      {} as any,
      match,
      historyPushSpy,
      updateBannerSpy,
      updateDialogSpy,
      updateToolbarSpy,
      updateSnackbarSpy,
    );
  }

  async function mockNJobs(n: number): Promise<void> {
    listJobsSpy.mockImplementation(() => ({
      jobs: range(n).map(i => ({
        enabled: true,
        id: 'test-job-id' + i,
        name: 'test job name' + i,
      })),
    }));
    await listJobsSpy;
    await TestUtils.flushPromises();
  }

  async function mockNRuns(n: number): Promise<void> {
    listRunsSpy.mockImplementation(() => ({
      runs: range(n).map(i => ({ id: 'test-run-id' + i, name: 'test run name' + i })),
    }));
    await listRunsSpy;
    await TestUtils.flushPromises();
  }

  beforeEach(async () => {
    // Reset mocks
    consoleLogSpy.mockReset();
    consoleErrorSpy.mockReset();
    updateBannerSpy.mockReset();
    updateDialogSpy.mockReset();
    updateSnackbarSpy.mockReset();
    updateToolbarSpy.mockReset();
    getExperimentSpy.mockReset();
    historyPushSpy.mockReset();
    listJobsSpy.mockReset();
    listRunsSpy.mockReset();

    getExperimentSpy.mockImplementation(() => newMockExperiment());

    await mockNJobs(0);
    await mockNRuns(0);
  });

  afterEach(async () => {
    // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle
    // depends on mocks/spies
    if (tree.exists()) {
      await tree.unmount();
    }
  });

  it('renders a page with no runs or recurring runs', async () => {
    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateBannerSpy).toHaveBeenCalledTimes(1);
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
    expect(tree).toMatchSnapshot();
  });

  it('uses the experiment ID in props as the page title if the experiment has no name', async () => {
    const experiment = newMockExperiment();
    experiment.name = '';

    const props = generateProps();
    props.match = { params: { [RouteParams.experimentId]: 'test exp ID' } } as any;

    getExperimentSpy.mockImplementationOnce(() => experiment);

    tree = shallow(<ExperimentDetails {...props} />);
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        pageTitle: 'test exp ID',
        pageTitleTooltip: 'test exp ID',
      }),
    );
  });

  it('uses the experiment name as the page title', async () => {
    const experiment = newMockExperiment();
    experiment.name = 'A Test Experiment';

    getExperimentSpy.mockImplementationOnce(() => experiment);

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(updateToolbarSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        pageTitle: 'A Test Experiment',
        pageTitleTooltip: 'A Test Experiment',
      }),
    );
  });

  it('uses an empty string if the experiment has no description', async () => {
    const experiment = newMockExperiment();
    delete experiment.description;

    getExperimentSpy.mockImplementationOnce(() => experiment);

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('removes all description text after second newline and replaces with an ellipsis', async () => {
    const experiment = newMockExperiment();
    experiment.description = 'Line 1\nLine 2\nLine 3\nLine 4';

    getExperimentSpy.mockImplementationOnce(() => experiment);

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    expect(tree).toMatchSnapshot();
  });

  it('opens the expanded description modal when the expand button is clicked', async () => {
    tree = TestUtils.mountWithRouter(<ExperimentDetails {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    tree.update();

    tree
      .find('#expandExperimentDescriptionBtn')
      .at(0)
      .simulate('click');
    await TestUtils.flushPromises();
    expect(updateDialogSpy).toHaveBeenCalledWith({
      content: MOCK_EXPERIMENT.description,
      title: 'Experiment description',
    });
  });

  it('calls getExperiment with the experiment ID in props', async () => {
    const props = generateProps();
    props.match = { params: { [RouteParams.experimentId]: 'test exp ID' } } as any;
    tree = shallow(<ExperimentDetails {...props} />);
    await TestUtils.flushPromises();
    expect(getExperimentSpy).toHaveBeenCalledTimes(1);
    expect(getExperimentSpy).toHaveBeenCalledWith('test exp ID');
  });

  it('shows an error banner if fetching the experiment fails', async () => {
    TestUtils.makeErrorResponseOnce(getExperimentSpy, 'test error');

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();

    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'test error',
        message:
          'Error: failed to retrieve experiment: ' +
          MOCK_EXPERIMENT.id +
          '. Click Details for more information.',
        mode: 'error',
      }),
    );
    expect(consoleErrorSpy.mock.calls[0][0]).toBe(
      'Error loading experiment: ' + MOCK_EXPERIMENT.id,
    );
  });

  it('shows a list of available runs', async () => {
    await mockNJobs(1);
    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();

    expect(tree.find('RunList').prop('storageState')).toBe(RunStorageState.AVAILABLE.toString());
  });

  it("fetches this experiment's recurring runs", async () => {
    await mockNJobs(1);

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();

    expect(listJobsSpy).toHaveBeenCalledTimes(1);
    expect(listJobsSpy).toHaveBeenLastCalledWith(
      undefined,
      100,
      '',
      'EXPERIMENT',
      MOCK_EXPERIMENT.id,
    );
    expect(tree.state('activeRecurringRunsCount')).toBe(1);
    expect(tree).toMatchSnapshot();
  });

  it("shows an error banner if fetching the experiment's recurring runs fails", async () => {
    TestUtils.makeErrorResponseOnce(listJobsSpy, 'test error');

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();

    expect(updateBannerSpy).toHaveBeenLastCalledWith(
      expect.objectContaining({
        additionalInfo: 'test error',
        message:
          'Error: failed to retrieve recurring runs for experiment: ' +
          MOCK_EXPERIMENT.id +
          '. Click Details for more information.',
        mode: 'error',
      }),
    );
    expect(consoleErrorSpy.mock.calls[0][0]).toBe(
      'Error fetching recurring runs for experiment: ' + MOCK_EXPERIMENT.id,
    );
  });

  it('only counts enabled recurring runs as active', async () => {
    const jobs = [
      { id: 'enabled-job-1-id', enabled: true, name: 'enabled-job-1' },
      { id: 'enabled-job-2-id', enabled: true, name: 'enabled-job-2' },
      { id: 'disabled-job-1-id', enabled: false, name: 'disabled-job-1' },
    ];
    listJobsSpy.mockImplementationOnce(() => ({ jobs }));
    await listJobsSpy;

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();

    expect(tree.state('activeRecurringRunsCount')).toBe(2);
  });

  it("opens the recurring run manager modal when 'manage' is clicked", async () => {
    await mockNJobs(1);
    tree = TestUtils.mountWithRouter(<ExperimentDetails {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    tree.update();

    tree
      .find('#manageExperimentRecurringRunsBtn')
      .at(0)
      .simulate('click');
    await TestUtils.flushPromises();
    expect(tree.state('recurringRunsManagerOpen')).toBe(true);
  });

  it('closes the recurring run manager modal', async () => {
    await mockNJobs(1);
    tree = TestUtils.mountWithRouter(<ExperimentDetails {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    tree.update();

    tree
      .find('#manageExperimentRecurringRunsBtn')
      .at(0)
      .simulate('click');
    await TestUtils.flushPromises();
    expect(tree.state('recurringRunsManagerOpen')).toBe(true);

    tree
      .find('#closeExperimentRecurringRunManagerBtn')
      .at(0)
      .simulate('click');
    await TestUtils.flushPromises();
    expect(tree.state('recurringRunsManagerOpen')).toBe(false);
  });

  it('refreshes the number of active recurring runs when the recurring run manager is closed', async () => {
    await mockNJobs(1);
    tree = TestUtils.mountWithRouter(<ExperimentDetails {...(generateProps() as any)} />);
    await TestUtils.flushPromises();

    tree.update();

    // Called when the page initially loads to display the number of active recurring runs
    expect(listJobsSpy).toHaveBeenCalledTimes(1);

    tree
      .find('#manageExperimentRecurringRunsBtn')
      .at(0)
      .simulate('click');
    await TestUtils.flushPromises();
    expect(tree.state('recurringRunsManagerOpen')).toBe(true);

    // Called in the recurring run manager to list the recurring runs
    expect(listJobsSpy).toHaveBeenCalledTimes(2);

    tree
      .find('#closeExperimentRecurringRunManagerBtn')
      .at(0)
      .simulate('click');
    await TestUtils.flushPromises();
    expect(tree.state('recurringRunsManagerOpen')).toBe(false);

    // Called a third time when the manager is closed to update the number of active recurring runs
    expect(listJobsSpy).toHaveBeenCalledTimes(3);
  });

  it('clears the error banner on refresh', async () => {
    TestUtils.makeErrorResponseOnce(getExperimentSpy, 'test error');

    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();

    // Verify that error banner is being shown
    expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ mode: 'error' }));

    (tree.instance() as ExperimentDetails).refresh();

    // Error banner should be cleared
    expect(updateBannerSpy).toHaveBeenLastCalledWith({});
  });

  it('navigates to the compare runs page', async () => {
    const runs = [
      { id: 'run-1-id', name: 'run-1' },
      { id: 'run-2-id', name: 'run-2' },
    ];
    listRunsSpy.mockImplementation(() => ({ runs }));
    await listRunsSpy;

    tree = TestUtils.mountWithRouter(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    tree
      .find('.tableRow')
      .at(0)
      .simulate('click');
    tree
      .find('.tableRow')
      .at(1)
      .simulate('click');

    const compareBtn = (tree.state('runListToolbarProps') as ToolbarProps).actions[
      ButtonKeys.COMPARE
    ];
    await compareBtn!.action();

    expect(historyPushSpy).toHaveBeenCalledWith(
      RoutePage.COMPARE + `?${QUERY_PARAMS.runlist}=run-1-id,run-2-id`,
    );
  });

  it('navigates to the new run page and passes this experiments ID as a query param', async () => {
    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const newRunBtn = (tree.state('runListToolbarProps') as ToolbarProps).actions[
      ButtonKeys.NEW_RUN
    ];
    await newRunBtn!.action();

    expect(historyPushSpy).toHaveBeenCalledWith(
      RoutePage.NEW_RUN + `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}`,
    );
  });

  it('navigates to the new run page with query param indicating it will be a recurring run', async () => {
    tree = shallow(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const newRecurringRunBtn = (tree.state('runListToolbarProps') as ToolbarProps).actions[
      ButtonKeys.NEW_RECURRING_RUN
    ];
    await newRecurringRunBtn!.action();

    expect(historyPushSpy).toHaveBeenCalledWith(
      RoutePage.NEW_RUN +
        `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` +
        `&${QUERY_PARAMS.isRecurring}=1`,
    );
  });

  it('supports cloning a selected run', async () => {
    const runs = [{ id: 'run-1-id', name: 'run-1' }];
    listRunsSpy.mockImplementation(() => ({ runs }));
    await listRunsSpy;

    tree = TestUtils.mountWithRouter(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    // Select the run to clone
    tree.find('.tableRow').simulate('click');

    const cloneBtn = (tree.state('runListToolbarProps') as ToolbarProps).actions[
      ButtonKeys.CLONE_RUN
    ];
    await cloneBtn!.action();

    expect(historyPushSpy).toHaveBeenCalledWith(
      RoutePage.NEW_RUN + `?${QUERY_PARAMS.cloneFromRun}=run-1-id`,
    );
  });

  it('enables the compare runs button only when between 2 and 10 runs are selected', async () => {
    await mockNRuns(12);

    tree = TestUtils.mountWithRouter(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const compareBtn = (tree.state('runListToolbarProps') as ToolbarProps).actions[
      ButtonKeys.COMPARE
    ];

    for (let i = 0; i < 12; i++) {
      if (i < 2 || i > 10) {
        expect(compareBtn!.disabled).toBe(true);
      } else {
        expect(compareBtn!.disabled).toBe(false);
      }
      tree
        .find('.tableRow')
        .at(i)
        .simulate('click');
    }
  });

  it('enables the clone run button only when 1 run is selected', async () => {
    await mockNRuns(4);

    tree = TestUtils.mountWithRouter(<ExperimentDetails {...generateProps()} />);
    await TestUtils.flushPromises();
    tree.update();

    const cloneBtn = (tree.state('runListToolbarProps') as ToolbarProps).actions[
      ButtonKeys.CLONE_RUN
    ];

    for (let i = 0; i < 4; i++) {
      if (i === 1) {
        expect(cloneBtn!.disabled).toBe(false);
      } else {
        expect(cloneBtn!.disabled).toBe(true);
      }
      tree
        .find('.tableRow')
        .at(i)
        .simulate('click');
    }
  });

  describe('EnhancedExperimentDetails', () => {
    it('renders ExperimentDetails initially', () => {
      render(<EnhancedExperimentDetails {...generateProps()}></EnhancedExperimentDetails>);
      expect(getExperimentSpy).toHaveBeenCalledTimes(1);
    });

    it('redirects to ExperimentList page if namespace changes', () => {
      const history = createMemoryHistory();
      const { rerender } = render(
        <Router history={history}>
          <NamespaceContext.Provider value='test-ns-1'>
            <EnhancedExperimentDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      rerender(
        <Router history={history}>
          <NamespaceContext.Provider value='test-ns-2'>
            <EnhancedExperimentDetails {...generateProps()} />
          </NamespaceContext.Provider>
        </Router>,
      );
      expect(history.location.pathname).toEqual(RoutePage.EXPERIMENTS);
    });
  });
});