diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26738bcf9c6..7f52be2c243 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -411,9 +411,9 @@ "dev": true }, "@types/enzyme": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/@types/enzyme/-/enzyme-3.1.14.tgz", - "integrity": "sha512-jvAbagrpoSNAXeZw2kRpP10eTsSIH8vW1IBLCXbN0pbZsYZU8FvTPMMd5OzSWUKWTQfrbXFUY8e6un/W4NpqIA==", + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/@types/enzyme/-/enzyme-3.1.15.tgz", + "integrity": "sha512-6b4JWgV+FNec1c4+8HauGbXg5gRc1oQK93t2+4W+bHjG/PzO+iPvagY6d6bXAZ+t+ps51Zb2F9LQ4vl0S0Epog==", "dev": true, "requires": { "@types/cheerio": "*", @@ -2567,9 +2567,9 @@ } }, "htmlparser2": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz", - "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz", + "integrity": "sha512-J1nEUGv+MkXS0weHNWVKJJ+UrLfePxRWpN3C9bEi9fLxL2+ggW94DQvgYVXsaT30PGwYRIZKNZXuyMhp3Di4bQ==", "dev": true, "requires": { "domelementtype": "^1.3.0", @@ -2577,7 +2577,7 @@ "domutils": "^1.5.1", "entities": "^1.1.1", "inherits": "^2.0.1", - "readable-stream": "^2.0.2" + "readable-stream": "^3.0.6" } }, "parse5": { @@ -2588,6 +2588,17 @@ "requires": { "@types/node": "*" } + }, + "readable-stream": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.0.6.tgz", + "integrity": "sha512-9E1oLoOWfhSXHGv6QlwXJim7uNzd9EVlWK+21tCU9Ju/kR0/p2AZYPz4qSchgO8PlLIH4FpZYfzwS+rEksZjIg==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } } } }, @@ -4320,9 +4331,9 @@ "dev": true }, "enzyme": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.6.0.tgz", - "integrity": "sha512-onsINzVLGqKIapTVfWkkw6bYvm1o4CyJ9s8POExtQhAkVa4qFDW6DGCQGRy/5bfZYk+gmUbMNyayXiWDzTkHFQ==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.7.0.tgz", + "integrity": "sha512-QLWx+krGK6iDNyR1KlH5YPZqxZCQaVF6ike1eDJAOg0HvSkSCVImPsdWaNw6v+VrnK92Kg8jIOYhuOSS9sBpyg==", "dev": true, "requires": { "array.prototype.flat": "^1.2.1", diff --git a/frontend/package.json b/frontend/package.json index 85cfc898c31..60b351cba8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "@types/d3": "^5.0.0", "@types/d3-dsv": "^1.0.33", "@types/dagre": "^0.7.40", - "@types/enzyme": "^3.1.14", + "@types/enzyme": "^3.1.15", "@types/enzyme-adapter-react-16": "^1.0.3", "@types/express": "^4.16.0", "@types/http-proxy-middleware": "^0.17.5", @@ -66,7 +66,7 @@ "@types/react-virtualized": "^9.18.7", "backstopjs": "^3.5.16", "coveralls": "^3.0.2", - "enzyme": "^3.6.0", + "enzyme": "^3.7.0", "enzyme-adapter-react-16": "^1.5.0", "enzyme-to-json": "^3.3.4", "react-router-test-context": "^0.1.0", diff --git a/frontend/src/pages/404.test.tsx b/frontend/src/pages/404.test.tsx new file mode 100644 index 00000000000..02362fd0193 --- /dev/null +++ b/frontend/src/pages/404.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import Page404 from './404'; +import { PageProps } from './Page'; +import { shallow } from 'enzyme'; + +describe('404', () => { + function generateProps(): PageProps { + return { + history: {} as any, + location: { pathname: 'some bad page' } as any, + match: {} as any, + toolbarProps: {} as any, + updateBanner: jest.fn(), + updateDialog: jest.fn(), + updateSnackbar: jest.fn(), + updateToolbar: jest.fn(), + }; + } + + it('renders a 404 page', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/pages/PipelineSelector.test.tsx b/frontend/src/pages/PipelineSelector.test.tsx new file mode 100644 index 00000000000..ab19102bcba --- /dev/null +++ b/frontend/src/pages/PipelineSelector.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import PipelineSelector, { PipelineSelectorProps } from './PipelineSelector'; +import TestUtils from '../TestUtils'; +import { ApiPipeline } from '../apis/pipeline'; +import { ListRequest, Apis } from '../lib/Apis'; +import { shallow } from 'enzyme'; + +describe('PipelineSelector', () => { + class TestPipelineSelector extends PipelineSelector { + public async _loadPipelines(request: ListRequest): Promise { + return super._loadPipelines(request); + } + public _pipelineSelectionChanged(selectedIds: string[]): void { + return super._pipelineSelectionChanged(selectedIds); + } + } + + const updateDialogSpy = jest.fn(); + const pipelineSelectionChangedCbSpy = jest.fn(); + const listPipelinesSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelines'); + const PIPELINES: ApiPipeline[] = [{ + created_at: new Date(2018, 10, 9, 8, 7, 6), + description: 'test pipeline description', + name: 'test pipeline name', + }]; + + function generateProps(): PipelineSelectorProps { + return { + history: {} as any, + location: '' as any, + match: {} as any, + pipelineSelectionChanged: pipelineSelectionChangedCbSpy, + updateDialog: updateDialogSpy, + }; + } + + beforeEach(() => { + listPipelinesSpy.mockReset(); + listPipelinesSpy.mockImplementation(() => ({ pipelines: PIPELINES })); + updateDialogSpy.mockReset(); + pipelineSelectionChangedCbSpy.mockReset(); + }); + + it('calls API to load pipelines', async () => { + const tree = shallow(); + await (tree.instance() as TestPipelineSelector)._loadPipelines({}); + expect(listPipelinesSpy).toHaveBeenCalledTimes(1); + expect(listPipelinesSpy).toHaveBeenLastCalledWith(undefined, undefined, undefined); + expect(tree.state('pipelines')).toEqual(PIPELINES); + expect(tree).toMatchSnapshot(); + tree.unmount(); + }); + + it('shows error dialog if listing fails', async () => { + TestUtils.makeErrorResponseOnce(listPipelinesSpy, 'woops!'); + jest.spyOn(console, 'error').mockImplementation(); + const tree = shallow(); + await (tree.instance() as TestPipelineSelector)._loadPipelines({}); + expect(listPipelinesSpy).toHaveBeenCalledTimes(1); + expect(updateDialogSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + content: 'List pipelines request failed with:\nwoops!', + title: 'Error retrieving pipelines', + })); + expect(tree.state('pipelines')).toEqual([]); + tree.unmount(); + }); + + it('calls selection callback when a pipeline is selected', async () => { + const tree = shallow(); + await (tree.instance() as TestPipelineSelector)._loadPipelines({}); + expect(tree.state('selectedIds')).toEqual([]); + (tree.instance() as TestPipelineSelector)._pipelineSelectionChanged(['pipeline-id']); + expect(pipelineSelectionChangedCbSpy).toHaveBeenLastCalledWith('pipeline-id'); + expect(tree.state('selectedIds')).toEqual(['pipeline-id']); + tree.unmount(); + }); + + it('logs error if more than one pipeline is selected', async () => { + const tree = shallow(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + await (tree.instance() as TestPipelineSelector)._loadPipelines({}); + expect(tree.state('selectedIds')).toEqual([]); + (tree.instance() as TestPipelineSelector)._pipelineSelectionChanged(['pipeline-id', 'pipeline2-id']); + expect(pipelineSelectionChangedCbSpy).not.toHaveBeenCalled(); + expect(tree.state('selectedIds')).toEqual([]); + expect(consoleSpy).toHaveBeenCalled(); + tree.unmount(); + }); +}); diff --git a/frontend/src/pages/PipelineSelector.tsx b/frontend/src/pages/PipelineSelector.tsx index 76e45c276cb..7fff273b2c5 100644 --- a/frontend/src/pages/PipelineSelector.tsx +++ b/frontend/src/pages/PipelineSelector.tsx @@ -23,7 +23,7 @@ import { logger, formatDateString, errorToMessage } from '../lib/Utils'; import { ApiPipeline } from '../apis/pipeline'; import { DialogProps } from '../components/Router'; -interface PipelineSelectorProps extends RouteComponentProps { +export interface PipelineSelectorProps extends RouteComponentProps { pipelineSelectionChanged: (selectedPipelineId: string) => void; updateDialog: (dialogProps: DialogProps) => void; } @@ -36,7 +36,6 @@ interface PipelineSelectorState { class PipelineSelector extends React.Component { protected _isMounted = true; - private _tableRef = React.createRef(); constructor(props: any) { super(props); @@ -74,8 +73,7 @@ class PipelineSelector extends React.Component ); @@ -85,19 +83,13 @@ class PipelineSelector extends React.Component { - if (this._tableRef.current) { - await this._tableRef.current.reload(); - } - } - protected setStateSafe(newState: Partial, cb?: () => void): void { if (this._isMounted) { this.setState(newState as any, cb); } } - private _pipelineSelectionChanged(selectedIds: string[]): void { + protected _pipelineSelectionChanged(selectedIds: string[]): void { if (!Array.isArray(selectedIds) || selectedIds.length !== 1) { logger.error(`${selectedIds.length} pipelines were selected somehow`, selectedIds); return; @@ -106,7 +98,7 @@ class PipelineSelector extends React.Component { + protected async _loadPipelines(request: ListRequest): Promise { let pipelines: ApiPipeline[] = []; let nextPageToken = ''; try { diff --git a/frontend/src/pages/RecurringRunsManager.test.tsx b/frontend/src/pages/RecurringRunsManager.test.tsx new file mode 100644 index 00000000000..cbf516a316e --- /dev/null +++ b/frontend/src/pages/RecurringRunsManager.test.tsx @@ -0,0 +1,209 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import TestUtils from '../TestUtils'; +import { ListRequest, Apis } from '../lib/Apis'; +import { shallow } from 'enzyme'; +import RecurringRunsManager, { RecurringRunListProps } from './RecurringRunsManager'; +import { ApiJob, ApiResourceType } from '../apis/job'; + +describe('RecurringRunsManager', () => { + class TestRecurringRunsManager extends RecurringRunsManager { + public async _loadRuns(request: ListRequest): Promise { + return super._loadRuns(request); + } + public _nameCustomRenderer(value: string, id: string): JSX.Element { + return super._nameCustomRenderer(value, id); + } + public _enabledCustomRenderer(value: boolean | undefined, id: string): JSX.Element { + return super._enabledCustomRenderer(value, id); + } + public _setEnabledState(id: string, enabled: boolean): Promise { + return super._setEnabledState(id, enabled); + } + } + + 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(); + }); + + it('calls API to load recurring runs', async () => { + const tree = shallow(); + await (tree.instance() as TestRecurringRunsManager)._loadRuns({}); + expect(listJobsSpy).toHaveBeenCalledTimes(1); + expect(listJobsSpy).toHaveBeenLastCalledWith( + undefined, + undefined, + undefined, + ApiResourceType.EXPERIMENT, + 'test-experiment'); + expect(tree.state('runs')).toEqual(JOBS); + expect(tree).toMatchSnapshot(); + tree.unmount(); + }); + + it('shows error dialog if listing fails', async () => { + TestUtils.makeErrorResponseOnce(listJobsSpy, 'woops!'); + jest.spyOn(console, 'error').mockImplementation(); + const tree = shallow(); + 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([]); + tree.unmount(); + }); + + it('calls API to enable run', async () => { + const tree = shallow(); + 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 () => { + const tree = shallow(); + 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 () => { + const tree = shallow(); + 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 () => { + const tree = shallow(); + 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', () => { + const tree = TestUtils.mountWithRouter( + TestRecurringRunsManager.prototype._nameCustomRenderer('test-run', 'run-id')); + expect(tree).toMatchSnapshot(); + }); + + it('renders a disable button if the run is enabled, clicking the button calls disable API', async () => { + const tree = TestUtils.mountWithRouter(); + 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 () => { + const tree = TestUtils.mountWithRouter(); + 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 () => { + const tree = TestUtils.mountWithRouter(); + 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 () => { + const tree = TestUtils.mountWithRouter(); + 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); + }); +}); diff --git a/frontend/src/pages/RecurringRunsManager.tsx b/frontend/src/pages/RecurringRunsManager.tsx index be5582eb51c..48257093355 100644 --- a/frontend/src/pages/RecurringRunsManager.tsx +++ b/frontend/src/pages/RecurringRunsManager.tsx @@ -27,7 +27,7 @@ import { SnackbarProps } from '@material-ui/core/Snackbar'; import { commonCss } from '../Css'; import { logger, formatDateString, errorToMessage } from '../lib/Utils'; -interface RecurringRunListProps extends RouteComponentProps { +export interface RecurringRunListProps extends RouteComponentProps { experimentId: string; updateDialog: (dialogProps: DialogProps) => void; updateSnackbar: (snackbarProps: SnackbarProps) => void; @@ -95,7 +95,7 @@ class RecurringRunsManager extends React.Component { + protected async _loadRuns(request: ListRequest): Promise { let runs: ApiJob[] = []; let nextPageToken = ''; try { @@ -122,26 +122,12 @@ class RecurringRunsManager extends React.Component{value}; } - private async _setEnabledState(id: string, enabled: boolean): Promise { - try { - await (enabled ? Apis.jobServiceApi.enableJob(id) : Apis.jobServiceApi.disableJob(id)); - } catch (err) { - const errorMessage = await errorToMessage(err); - this.props.updateDialog({ - buttons: [{ text: 'Dismiss' }], - content: 'Error changing enabled state of recurring run:\n' + errorMessage, - title: 'Error', - }); - logger.error('Error changing enabled state of recurring run', errorMessage); - } - } - - private _enabledCustomRenderer(value: boolean | undefined, id: string): JSX.Element { + protected _enabledCustomRenderer(value: boolean | undefined, id: string): JSX.Element { const isBusy = this.state.busyIds.has(id); return { @@ -156,6 +142,20 @@ class RecurringRunsManager extends React.Component; } + + protected async _setEnabledState(id: string, enabled: boolean): Promise { + try { + await (enabled ? Apis.jobServiceApi.enableJob(id) : Apis.jobServiceApi.disableJob(id)); + } catch (err) { + const errorMessage = await errorToMessage(err); + this.props.updateDialog({ + buttons: [{ text: 'Dismiss' }], + content: 'Error changing enabled state of recurring run:\n' + errorMessage, + title: 'Error', + }); + logger.error('Error changing enabled state of recurring run', errorMessage); + } + } } export default RecurringRunsManager; diff --git a/frontend/src/pages/__snapshots__/404.test.tsx.snap b/frontend/src/pages/__snapshots__/404.test.tsx.snap new file mode 100644 index 00000000000..1b59eb7ee9e --- /dev/null +++ b/frontend/src/pages/__snapshots__/404.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`404 renders a 404 page 1`] = ` +
+
+ 404 +
+
+ Page Not Found: + some bad page +
+
+`; diff --git a/frontend/src/pages/__snapshots__/PipelineSelector.test.tsx.snap b/frontend/src/pages/__snapshots__/PipelineSelector.test.tsx.snap new file mode 100644 index 00000000000..cc9481c24c7 --- /dev/null +++ b/frontend/src/pages/__snapshots__/PipelineSelector.test.tsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelineSelector calls API to load pipelines 1`] = ` + + + + +`; diff --git a/frontend/src/pages/__snapshots__/RecurringRunsManager.test.tsx.snap b/frontend/src/pages/__snapshots__/RecurringRunsManager.test.tsx.snap new file mode 100644 index 00000000000..0ced353e598 --- /dev/null +++ b/frontend/src/pages/__snapshots__/RecurringRunsManager.test.tsx.snap @@ -0,0 +1,622 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RecurringRunsManager calls API to load recurring runs 1`] = ` + + + + +`; + +exports[`RecurringRunsManager reloads the list of runs after enable/disabling 1`] = ` + + + + +`; + +exports[`RecurringRunsManager renders a disable button if the run is enabled, clicking the button calls disable API 1`] = ` + + + + +`; + +exports[`RecurringRunsManager renders an enable button if the run is disabled, clicking the button calls enable API 1`] = ` + + + + +`; + +exports[`RecurringRunsManager renders an enable button if the run's enabled field is undefined, clicking the button calls enable API 1`] = ` + + + + +`; + +exports[`RecurringRunsManager renders run name as link to its details page 1`] = ` + + + test-run + + +`;