diff --git a/frontend/src/components/CollapseButton.test.tsx b/frontend/src/components/CollapseButton.test.tsx index b9ce853a3d0..3afdfba9448 100644 --- a/frontend/src/components/CollapseButton.test.tsx +++ b/frontend/src/components/CollapseButton.test.tsx @@ -30,20 +30,23 @@ describe('CollapseButton', () => { it('initial render', () => { const tree = shallow( - ); + ); expect(tree).toMatchSnapshot(); }); it('renders the button collapsed if in collapsedSections', () => { compareComponent.state.collapseSections.testSection = true; const tree = shallow( - ); + ); expect(tree).toMatchSnapshot(); }); it('collapses given section when clicked', () => { const tree = shallow( - ); + ); tree.find('WithStyles(Button)').simulate('click'); expect(compareComponent.setState).toHaveBeenCalledWith( { collapseSections: { testSection: true } }); @@ -52,7 +55,8 @@ describe('CollapseButton', () => { it('expands given section when clicked if it is collapsed', () => { compareComponent.state.collapseSections.testSection = true; const tree = shallow( - ); + ); tree.find('WithStyles(Button)').simulate('click'); expect(compareComponent.setState).toHaveBeenCalledWith( { collapseSections: { testSection: false } }); diff --git a/frontend/src/components/CollapseButton.tsx b/frontend/src/components/CollapseButton.tsx index 74ddc33181f..adddab6bc64 100644 --- a/frontend/src/components/CollapseButton.tsx +++ b/frontend/src/components/CollapseButton.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import Compare from '../pages/Compare'; +import { CompareState } from '../pages/Compare'; import Button from '@material-ui/core/Button'; import ExpandedIcon from '@material-ui/icons/ArrowDropUp'; import { stylesheet, classes } from 'typestyle'; @@ -38,23 +38,29 @@ const css = stylesheet({ }); interface CollapseButtonProps { - compareComponent: Compare; + collapseSections: { [key: string]: boolean }; + compareSetState: (state: Partial) => void; sectionName: string; } -export default (props: CollapseButtonProps) => { - const collapseSections = props.compareComponent.state.collapseSections; - const sectionName = props.sectionName; - return
- -
; -}; +class CollapseButton extends React.Component { + + public render(): JSX.Element { + const { collapseSections, compareSetState } = this.props; + const sectionName = this.props.sectionName; + return
+ +
; + } +} + +export default CollapseButton; diff --git a/frontend/src/pages/Compare.test.tsx b/frontend/src/pages/Compare.test.tsx new file mode 100644 index 00000000000..53f958519fa --- /dev/null +++ b/frontend/src/pages/Compare.test.tsx @@ -0,0 +1,494 @@ +/* + * 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 Compare, { TaggedViewerConfig } from './Compare'; +import TestUtils from '../TestUtils'; +import { ReactWrapper, ShallowWrapper, shallow } from 'enzyme'; +import { Apis } from '../lib/Apis'; +import { PageProps } from './Page'; +import { RoutePage, QUERY_PARAMS } from '../components/Router'; +import { ApiRunDetail } from '../apis/run'; +import { PlotType } from '../components/viewers/Viewer'; +import { OutputArtifactLoader } from '../lib/OutputArtifactLoader'; +import { Workflow } from '../../third_party/argo-ui/argo_template'; + +class TestCompare extends Compare { + public _selectionChanged(selectedIds: string[]): void { + return super._selectionChanged(selectedIds); + } +} + +describe('Compare', () => { + + let tree: ReactWrapper | ShallowWrapper; + + 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 getRunSpy = jest.spyOn(Apis.runServiceApi, 'getRun'); + const outputArtifactLoaderSpy = jest.spyOn(OutputArtifactLoader, 'load'); + + function generateProps(): PageProps { + return { + history: { push: historyPushSpy } as any, + location: { + pathname: RoutePage.COMPARE, + search: `?${QUERY_PARAMS.runlist}=${MOCK_RUN_1_ID},${MOCK_RUN_2_ID},${MOCK_RUN_3_ID}` + } as any, + match: { params: {} } as any, + toolbarProps: Compare.prototype.getInitialToolbarState(), + updateBanner: updateBannerSpy, + updateDialog: updateDialogSpy, + updateSnackbar: updateSnackbarSpy, + updateToolbar: updateToolbarSpy, + }; + } + + const MOCK_RUN_1_ID = 'mock-run-1-id'; + const MOCK_RUN_2_ID = 'mock-run-2-id'; + const MOCK_RUN_3_ID = 'mock-run-3-id'; + + let runs: ApiRunDetail[] = []; + + function newMockRun(id?: string): ApiRunDetail { + return { + pipeline_runtime: { + workflow_manifest: '{}', + }, + run: { + id: id || 'test-run-id', + name: 'test run ' + id, + } + }; + } + + /** + * After calling this function, the global 'tree' will be a Compare instance with a table viewer + * and a tensorboard viewer. + */ + async function setUpViewersAndShallowMount(): Promise { + + // Simulate returning a tensorboard and table viewer + outputArtifactLoaderSpy.mockImplementation(() => [ + { type: PlotType.TENSORBOARD, url: 'gs://path' }, + { data: [[]], labels: ['col1, col2'], type: PlotType.TABLE }, + ]); + + const workflow = { + status: { + nodes: { + node1: { + outputs: { + artifacts: [{ + name: 'mlpipeline-ui-metadata', + s3: { bucket: 'test bucket', key: 'test key' } + }] + } + } + } + } + }; + const run1 = newMockRun('run-with-workflow-1'); + run1.pipeline_runtime!.workflow_manifest = JSON.stringify(workflow); + const run2 = newMockRun('run-with-workflow-2'); + run2.pipeline_runtime!.workflow_manifest = JSON.stringify(workflow); + runs.push(run1, run2); + + const props = generateProps(); + props.location.search = `?${QUERY_PARAMS.runlist}=run-with-workflow-1,run-with-workflow-2`; + + tree = shallow(); + await TestUtils.flushPromises(); + } + + beforeEach(async () => { + // Reset mocks + consoleErrorSpy.mockReset(); + updateBannerSpy.mockReset(); + updateDialogSpy.mockReset(); + updateSnackbarSpy.mockReset(); + updateToolbarSpy.mockReset(); + historyPushSpy.mockReset(); + outputArtifactLoaderSpy.mockReset(); + + getRunSpy.mockClear(); + + runs = [newMockRun(MOCK_RUN_1_ID), newMockRun(MOCK_RUN_2_ID), newMockRun(MOCK_RUN_3_ID)]; + + getRunSpy.mockImplementation((id: string) => runs.find((r) => r.run!.id === id)); + }); + + afterEach(() => { + tree.unmount(); + }); + + it('clears banner upon initial load', () => { + tree = shallow(); + expect(updateBannerSpy).toHaveBeenCalledTimes(1); + expect(updateBannerSpy).toHaveBeenLastCalledWith({}); + }); + + it('renders a page with no runs', async () => { + const props = generateProps(); + // Ensure there are no run IDs in the query + props.location.search = ''; + tree = shallow(); + await TestUtils.flushPromises(); + + expect(updateBannerSpy).toHaveBeenCalledTimes(1); + expect(updateBannerSpy).toHaveBeenLastCalledWith({}); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a page with multiple runs', async () => { + const props = generateProps(); + // Ensure there are run IDs in the query + props.location.search = + `?${QUERY_PARAMS.runlist}=${MOCK_RUN_1_ID},${MOCK_RUN_2_ID},${MOCK_RUN_3_ID}`; + + tree = shallow(); + await TestUtils.flushPromises(); + expect(tree).toMatchSnapshot(); + }); + + it('fetches a run for each ID in query params', async () => { + runs.push(newMockRun('run-1'), newMockRun('run-2'), newMockRun('run-2')); + const props = generateProps(); + props.location.search = `?${QUERY_PARAMS.runlist}=run-1,run-2,run-3`; + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(getRunSpy).toHaveBeenCalledTimes(3); + expect(getRunSpy).toHaveBeenCalledWith('run-1'); + expect(getRunSpy).toHaveBeenCalledWith('run-2'); + expect(getRunSpy).toHaveBeenCalledWith('run-3'); + }); + + it('shows an error banner if fetching any run fails', async () => { + TestUtils.makeErrorResponseOnce(getRunSpy, 'test error'); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + additionalInfo: 'test error', + message: 'Error: failed loading 1 runs. Click Details for more information.', + mode: 'error', + })); + }); + + it('shows an error banner indicating the number of getRun calls that failed', async () => { + getRunSpy.mockImplementation(() => { + throw { + text: () => Promise.resolve('test error'), + }; + }); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + additionalInfo: 'test error', + message: `Error: failed loading ${runs.length} runs. Click Details for more information.`, + mode: 'error', + })); + }); + + it('clears the error banner on refresh', async () => { + TestUtils.makeErrorResponseOnce(getRunSpy, 'test error'); + + tree = shallow(); + await TestUtils.flushPromises(); + + // Verify that error banner is being shown + expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ mode: 'error' })); + + (tree.instance() as Compare).refresh(); + + // Error banner should be cleared + expect(updateBannerSpy).toHaveBeenLastCalledWith({}); + }); + + it('displays run\'s parameters if the run has any', async () => { + const workflow = { + spec: { + arguments: { + parameters: [{ name: 'param1', value: 'value1', }, { name: 'param2', value: 'value2', }], + }, + }, + } as Workflow; + + const run = newMockRun('run-with-parameters'); + run.pipeline_runtime!.workflow_manifest = JSON.stringify(workflow); + runs.push(run); + + const props = generateProps(); + props.location.search = `?${QUERY_PARAMS.runlist}=run-with-parameters`; + + tree = shallow(); + await TestUtils.flushPromises(); + tree.update(); + + expect(tree.state('paramsCompareProps')).toEqual({ + rows: [['value1'], ['value2']], + xLabels: ['test run run-with-parameters'], + yLabels: ['param1', 'param2'] + }); + expect(tree).toMatchSnapshot(); + }); + + it('displays parameters from multiple runs', async () => { + const run1Workflow = { + spec: { + arguments: { + parameters: [ + { name: 'r1-unique-param', value: 'r1-unique-val1' }, + { name: 'shared-param', value: 'r1-shared-val2' } + ], + }, + }, + } as Workflow; + const run2Workflow = { + spec: { + arguments: { + parameters: [ + { name: 'r2-unique-param1', value: 'r2-unique-val1' }, + { name: 'shared-param', value: 'r2-shared-val2' } + ], + }, + }, + } as Workflow; + + const run1 = newMockRun('run1'); + run1.pipeline_runtime!.workflow_manifest = JSON.stringify(run1Workflow); + const run2 = newMockRun('run2'); + run2.pipeline_runtime!.workflow_manifest = JSON.stringify(run2Workflow); + runs.push(run1, run2); + + const props = generateProps(); + props.location.search = `?${QUERY_PARAMS.runlist}=run1,run2`; + + tree = shallow(); + await TestUtils.flushPromises(); + tree.update(); + + expect(tree).toMatchSnapshot(); + }); + + it('creates a map of viewers', async () => { + // Simulate returning a tensorboard and table viewer + outputArtifactLoaderSpy.mockImplementationOnce(() => [ + { type: PlotType.TENSORBOARD, url: 'gs://path' }, + { data: [['test']], labels: ['col1, col2'], type: PlotType.TABLE }, + ]); + + const workflow = { + status: { + nodes: { + node1: { + outputs: { + artifacts: [{ + name: 'mlpipeline-ui-metadata', + s3: { bucket: 'test bucket', key: 'test key' } + }] + } + } + } + } + }; + const run = newMockRun('run-with-workflow'); + run.pipeline_runtime!.workflow_manifest = JSON.stringify(workflow); + runs.push(run); + + const props = generateProps(); + props.location.search = `?${QUERY_PARAMS.runlist}=run-with-workflow`; + + tree = shallow(); + await TestUtils.flushPromises(); + + const expectedViewerMap = new Map([ + [ + PlotType.TABLE, + [{ + config: { data: [['test']], labels: ['col1, col2'], type: PlotType.TABLE }, + runId: run.run!.id, + runName: run.run!.name + } as TaggedViewerConfig], + ], + [ + PlotType.TENSORBOARD, + [{ + config: { type: PlotType.TENSORBOARD, url: 'gs://path' }, + runId: run.run!.id, + runName: run.run!.name + } as TaggedViewerConfig] + ], + ]); + expect((tree.state('viewersMap') as Map)) + .toEqual(expectedViewerMap); + + expect(tree).toMatchSnapshot(); + }); + + it('collapses all sections', async () => { + await setUpViewersAndShallowMount(); + const instance = tree.instance() as Compare; + const collapseBtn = + instance.getInitialToolbarState().actions.find(b => b.title === 'Collapse all'); + + expect(tree.state('collapseSections')).toEqual({}); + + collapseBtn!.action(); + + expect(tree.state('collapseSections')).toEqual({ + 'Parameters': true, + 'Run overview': true, + 'Table': true, + 'Tensorboard': true + }); + + expect(tree).toMatchSnapshot(); + }); + + it('expands all sections if they were collapsed', async () => { + await setUpViewersAndShallowMount(); + const instance = tree.instance() as Compare; + const collapseBtn = + instance.getInitialToolbarState().actions.find(b => b.title === 'Collapse all'); + const expandBtn = + instance.getInitialToolbarState().actions.find(b => b.title === 'Expand all'); + + expect(tree.state('collapseSections')).toEqual({}); + + collapseBtn!.action(); + + expect(tree.state('collapseSections')).toEqual({ + 'Parameters': true, + 'Run overview': true, + 'Table': true, + 'Tensorboard': true + }); + + expandBtn!.action(); + + expect(tree.state('collapseSections')).toEqual({}); + + expect(tree).toMatchSnapshot(); + }); + + it('allows individual viewers to be collapsed and expanded', async () => { + tree = TestUtils.mountWithRouter(); + await TestUtils.flushPromises(); + + expect(tree.state('collapseSections')).toEqual({}); + + // Collapse run overview + tree.find('CollapseButton').at(0).find('button').simulate('click'); + + expect(tree.state('collapseSections')).toEqual({ 'Run overview': true }); + + // Collapse run parameters + tree.find('CollapseButton').at(1).find('button').simulate('click'); + + expect(tree.state('collapseSections')).toEqual({ + 'Parameters': true, + 'Run overview': true, + }); + + // Re-expand run overview and parameters + tree.find('CollapseButton').at(0).find('button').simulate('click'); + tree.find('CollapseButton').at(1).find('button').simulate('click'); + + expect(tree.state('collapseSections')).toEqual({ + 'Parameters': false, + 'Run overview': false, + }); + }); + + it('allows individual runs to be selected and deselected', async () => { + tree = TestUtils.mountWithRouter(); + await TestUtils.flushPromises(); + tree.update(); + + expect(tree.state('selectedIds')).toEqual(['mock-run-1-id', 'mock-run-2-id', 'mock-run-3-id']); + + tree.find('RunList').find('.tableRow').at(0).simulate('click'); + tree.find('RunList').find('.tableRow').at(2).simulate('click'); + + expect(tree.state('selectedIds')).toEqual(['mock-run-2-id']); + + tree.find('RunList').find('.tableRow').at(0).simulate('click'); + + expect(tree.state('selectedIds')).toEqual(['mock-run-2-id', 'mock-run-1-id']); + }); + + it('does not show viewers for deselected runs', async () => { + await setUpViewersAndShallowMount(); + + // We call _selectionChanged() rather than using setState because _selectionChanged has a + // callback which is needed to properly update the run parameters section + (tree.instance() as TestCompare)._selectionChanged([]); + tree.update(); + + expect(tree).toMatchSnapshot(); + }); + + it('creates an extra aggregation plot for compatible viewers', async () => { + // Tensorboard and ROC curves are the only viewers that currently support aggregation + outputArtifactLoaderSpy.mockImplementation(() => [ + { type: PlotType.TENSORBOARD, url: 'gs://path' }, + { data: [], type: PlotType.ROC } + ]); + const workflow = { + status: { + nodes: { + node1: { + outputs: { + artifacts: [{ + name: 'mlpipeline-ui-metadata', + s3: { bucket: 'test bucket', key: 'test key' } + }] + } + } + } + } + }; + const run1 = newMockRun('run1-id'); + run1.pipeline_runtime!.workflow_manifest = JSON.stringify(workflow); + const run2 = newMockRun('run2-id'); + run2.pipeline_runtime!.workflow_manifest = JSON.stringify(workflow); + runs.push(run1, run2); + + const props = generateProps(); + props.location.search = `?${QUERY_PARAMS.runlist}=run1-id,run2-id`; + + tree = shallow(); + await TestUtils.flushPromises(); + + // 6 plot cards because there are (2 runs * 2 plots per run) + 2 aggregated plots, one for + // Tensorboard and one for ROC. + expect(tree.find('PlotCard').length).toBe(6); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/pages/Compare.tsx b/frontend/src/pages/Compare.tsx index ae916cffd4b..37cce8adfd4 100644 --- a/frontend/src/pages/Compare.tsx +++ b/frontend/src/pages/Compare.tsx @@ -46,13 +46,13 @@ const css = stylesheet({ }, }); -interface TaggedViewerConfig { +export interface TaggedViewerConfig { config: ViewerConfig; runId: string; runName: string; } -interface CompareState { +export interface CompareState { collapseSections: { [key: string]: boolean }; fullscreenViewerConfig: PlotCardProps | null; paramsCompareProps: CompareTableProps; @@ -116,7 +116,8 @@ class Compare extends Page<{}, CompareState> { return (
{/* Overview section */} - + {!collapseSections[overviewSectionName] && (
{ {/* Parameters section */} - + {!collapseSections[paramsSectionName] && (
@@ -139,35 +141,37 @@ class Compare extends Page<{}, CompareState> { - {Array.from(viewersMap.keys()).map((viewerType, i) =>
- - {!collapseSections[componentMap[viewerType].prototype.getDisplayName()] && - -
- {/* If the component allows aggregation, add one more card for - its aggregated view. Only do this if there is more than one - output, filtering out any unselected runs. */} - {(componentMap[viewerType].prototype.isAggregatable() && ( - runsPerViewerType(viewerType).length > 1) && ( - t.config)} maxDimension={400} - title='Aggregated view' /> - ) - )} - - {runsPerViewerType(viewerType).map((taggedConfig, c) => ( - - ))} - - -
-
-
- } - -
+ {Array.from(viewersMap.keys()).map((viewerType, i) => +
+ + {!collapseSections[componentMap[viewerType].prototype.getDisplayName()] && ( + +
+ {/* If the component allows aggregation, add one more card for + its aggregated view. Only do this if there is more than one + output, filtering out any unselected runs. */} + {(componentMap[viewerType].prototype.isAggregatable() && ( + runsPerViewerType(viewerType).length > 1) && ( + t.config)} maxDimension={400} + title='Aggregated view' /> + ) + )} + + {runsPerViewerType(viewerType).map((taggedConfig, c) => ( + + ))} + + +
+
+
+ )} + +
)}
); } @@ -189,6 +193,7 @@ class Compare extends Page<{}, CompareState> { const workflowObjects: Workflow[] = []; const failingRuns: string[] = []; let lastError: Error | null = null; + await Promise.all(runIds.map(async id => { try { const run = await Apis.runServiceApi.getRun(id); @@ -201,19 +206,16 @@ class Compare extends Page<{}, CompareState> { })); if (lastError) { - await this.showPageError( - `Error: failed loading ${failingRuns.length} runs.`, - lastError, - ); + await this.showPageError(`Error: failed loading ${failingRuns.length} runs.`, lastError); logger.error( `Failed loading ${failingRuns.length} runs, last failed with the error: ${lastError}`); return; } - this.setState({ - runs, - selectedIds: runs.map(r => r.run!.id!), - workflowObjects, + this.setStateSafe({ + runs, + selectedIds: runs.map(r => r.run!.id!), + workflowObjects, }, () => this._loadParameters()); const outputPathsList = workflowObjects.map( @@ -238,8 +240,12 @@ class Compare extends Page<{}, CompareState> { } })); - // For each output artifact type, list all artifact instances in all runs - this.setState({ viewersMap }); + // For each output artifact type, list all artifact instances in all runs + this.setStateSafe({ viewersMap }); + } + + protected _selectionChanged(selectedIds: string[]): void { + this.setState({ selectedIds }, () => this._loadParameters()); } private _collapseAllSections(): void { @@ -248,13 +254,7 @@ class Compare extends Page<{}, CompareState> { const sectionName = componentMap[t].prototype.getDisplayName(); collapseSections[sectionName] = true; }); - this.setState({ - collapseSections, - }); - } - - private _selectionChanged(selectedIds: string[]): void { - this.setState({ selectedIds }, () => this._loadParameters()); + this.setState({ collapseSections }); } private _loadParameters(): void { @@ -264,8 +264,7 @@ class Compare extends Page<{}, CompareState> { const filteredRuns = runs.filter((_, i) => selectedIndices.indexOf(i) > -1); const filteredWorkflows = workflowObjects.filter((_, i) => selectedIndices.indexOf(i) > -1); - const paramsCompareProps = CompareUtils.getParamsCompareProps( - filteredRuns, filteredWorkflows); + const paramsCompareProps = CompareUtils.getParamsCompareProps(filteredRuns, filteredWorkflows); this.setState({ paramsCompareProps }); } diff --git a/frontend/src/pages/ExperimentDetails.test.tsx b/frontend/src/pages/ExperimentDetails.test.tsx index 88f2f94ae1f..9822b3ab57d 100644 --- a/frontend/src/pages/ExperimentDetails.test.tsx +++ b/frontend/src/pages/ExperimentDetails.test.tsx @@ -56,8 +56,7 @@ describe('ExperimentDetails', () => { return { history: { push: historyPushSpy } as any, location: '' as any, - // 'eid' here corresponds to RouteParams.experimentId - match: { params: { eid: MOCK_EXPERIMENT.id } } as any, + match: { params: { [RouteParams.experimentId]: MOCK_EXPERIMENT.id } } as any, toolbarProps: ExperimentDetails.prototype.getInitialToolbarState(), updateBanner: updateBannerSpy, updateDialog: updateDialogSpy, @@ -182,7 +181,7 @@ describe('ExperimentDetails', () => { it('calls getExperiment with the experiment ID in props', async () => { const props = generateProps(); - props.match = { params: { eid: 'test exp ID' } } as any; + props.match = { params: { [RouteParams.experimentId]: 'test exp ID' } } as any; tree = shallow(); await TestUtils.flushPromises(); expect(getExperimentSpy).toHaveBeenCalledTimes(1); diff --git a/frontend/src/pages/__snapshots__/Compare.test.tsx.snap b/frontend/src/pages/__snapshots__/Compare.test.tsx.snap new file mode 100644 index 00000000000..40939b894bf --- /dev/null +++ b/frontend/src/pages/__snapshots__/Compare.test.tsx.snap @@ -0,0 +1,1614 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Compare collapses all sections 1`] = ` +
+ + + + +
+ + +
+
+ + +
+
+`; + +exports[`Compare creates a map of viewers 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+ +
+ + +
+ + +
+
+ +
+ + +
+ + +
+
+`; + +exports[`Compare creates an extra aggregation plot for compatible viewers 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+ +
+ + + + +
+ + +
+
+ +
+ + + + +
+ + +
+
+`; + +exports[`Compare displays parameters from multiple runs 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+`; + +exports[`Compare displays run's parameters if the run has any 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+`; + +exports[`Compare does not show viewers for deselected runs 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+`; + +exports[`Compare expands all sections if they were collapsed 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+ +
+ + + + +
+ + +
+
+ +
+ + + +
+ + +
+
+`; + +exports[`Compare renders a page with multiple runs 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+`; + +exports[`Compare renders a page with no runs 1`] = ` +
+ +
+ +
+ + +
+ + + +
+ +
+`;