From 9ed8ea7a33c3fa8125c2551179b3c360924bc3da Mon Sep 17 00:00:00 2001 From: Joe Farro Date: Fri, 1 Dec 2017 02:40:13 -0500 Subject: [PATCH 1/3] Better TraceSearchForm test coverage Signed-off-by: Joe Farro --- .../SearchTracePage/TraceSearchForm.js | 128 +++---- .../SearchTracePage/TraceSearchForm.test.js | 339 +++++++++++++++--- .../TracePage/KeyboardShortcutsHelp.css | 2 +- .../TracePage/KeyboardShortcutsHelp.js | 20 +- 4 files changed, 371 insertions(+), 118 deletions(-) diff --git a/src/components/SearchTracePage/TraceSearchForm.js b/src/components/SearchTracePage/TraceSearchForm.js index 322ee5ce52..93ca799717 100644 --- a/src/components/SearchTracePage/TraceSearchForm.js +++ b/src/components/SearchTracePage/TraceSearchForm.js @@ -72,7 +72,58 @@ export function convertQueryParamsToFormDates({ start, end }) { }; } -export function TraceSearchFormComponent(props) { +export function submitForm(fields, searchTraces) { + const { + resultsLimit, + service, + startDate, + startDateTime, + endDate, + endDateTime, + operation, + tags, + minDuration, + maxDuration, + lookback, + } = fields; + // Note: traceID is ignored when the form is submitted + store.set('lastSearch', { service, operation }); + + let start; + let end; + if (lookback !== 'custom') { + const unit = lookback[1]; + const now = new Date(); + start = + moment(now) + .subtract(parseInt(lookback, 10), unit) + .valueOf() * 1000; + end = moment(now).valueOf() * 1000; + } else { + const times = getUnixTimeStampInMSFromForm({ + startDate, + startDateTime, + endDate, + endDateTime, + }); + start = times.start; + end = times.end; + } + + searchTraces({ + service, + operation: operation !== 'all' ? operation : undefined, + limit: resultsLimit, + lookback, + start, + end, + tag: tagsToQuery(tags) || undefined, + minDuration: minDuration || null, + maxDuration: maxDuration || null, + }); +} + +export function TraceSearchFormImpl(props) { const { selectedService = '-', selectedLookback, handleSubmit, submitting, services } = props; const selectedServicePayload = services.find(s => s.name === selectedService); const operationsForService = (selectedServicePayload && selectedServicePayload.operations) || []; @@ -129,7 +180,7 @@ export function TraceSearchFormComponent(props) { {selectedLookback === 'custom' && ( -
+
@@ -143,7 +194,7 @@ export function TraceSearchFormComponent(props) { )} {selectedLookback === 'custom' && ( -
+
@@ -177,7 +228,11 @@ export function TraceSearchFormComponent(props) {
- @@ -185,7 +240,7 @@ export function TraceSearchFormComponent(props) { ); } -TraceSearchFormComponent.propTypes = { +TraceSearchFormImpl.propTypes = { handleSubmit: PropTypes.func.isRequired, submitting: PropTypes.bool, services: PropTypes.arrayOf( @@ -198,7 +253,7 @@ TraceSearchFormComponent.propTypes = { selectedLookback: PropTypes.string, }; -TraceSearchFormComponent.defaultProps = { +TraceSearchFormImpl.defaultProps = { services: [], submitting: false, selectedService: null, @@ -207,7 +262,7 @@ TraceSearchFormComponent.defaultProps = { export const searchSideBarFormSelector = formValueSelector('searchSideBar'); -const mapStateToProps = state => { +export function mapStateToProps(state) { const { service, limit, @@ -279,66 +334,17 @@ const mapStateToProps = state => { selectedService: searchSideBarFormSelector(state, 'service'), selectedLookback: searchSideBarFormSelector(state, 'lookback'), }; -}; +} -const mapDispatchToProps = dispatch => { +function mapDispatchToProps(dispatch) { const { searchTraces } = bindActionCreators(jaegerApiActions, dispatch); return { - onSubmit: fields => { - const { - resultsLimit, - service, - startDate, - startDateTime, - endDate, - endDateTime, - operation, - tags, - minDuration, - maxDuration, - lookback, - } = fields; - // Note: traceID is ignored when the form is submitted - - store.set('lastSearch', { service, operation }); - - let start; - let end; - if (lookback !== 'custom') { - const unit = lookback.split('').pop(); - start = - moment() - .subtract(parseInt(lookback, 10), unit) - .valueOf() * 1000; - end = moment().valueOf() * 1000; - } else { - const times = getUnixTimeStampInMSFromForm({ - startDate, - startDateTime, - endDate, - endDateTime, - }); - start = times.start; - end = times.end; - } - - searchTraces({ - service, - operation: operation !== 'all' ? operation : undefined, - limit: resultsLimit, - lookback, - start, - end, - tag: tagsToQuery(tags) || undefined, - minDuration: minDuration || null, - maxDuration: maxDuration || null, - }); - }, + onSubmit: fields => submitForm(fields, searchTraces), }; -}; +} export default connect(mapStateToProps, mapDispatchToProps)( reduxForm({ form: 'searchSideBar', - })(TraceSearchFormComponent) + })(TraceSearchFormImpl) ); diff --git a/src/components/SearchTracePage/TraceSearchForm.test.js b/src/components/SearchTracePage/TraceSearchForm.test.js index e04680c165..a19bad9bb1 100644 --- a/src/components/SearchTracePage/TraceSearchForm.test.js +++ b/src/components/SearchTracePage/TraceSearchForm.test.js @@ -12,64 +12,321 @@ // See the License for the specific language governing permissions and // limitations under the License. +/* eslint-disable import/first */ +jest.mock('store'); + import React from 'react'; import { shallow } from 'enzyme'; import moment from 'moment'; +import queryString from 'query-string'; -const DATE_FORMAT = 'YYYY-MM-DD'; -const TIME_FORMAT = 'HH:mm'; +// use `require` so statement is not hoised above `jest.mock(...)` +const store = require('store'); -const { - TraceSearchFormComponent: TraceSearchForm, - getUnixTimeStampInMSFromForm, +import { convertQueryParamsToFormDates, -} = require('./TraceSearchForm'); + getUnixTimeStampInMSFromForm, + mapStateToProps, + submitForm, + tagsToQuery, + traceIDsToQuery, + TraceSearchFormImpl as TraceSearchForm, +} from './TraceSearchForm'; +function makeDateParams(dateOffset = 0) { + const date = new Date(); + date.setDate(date.getDate() + dateOffset || 0); + date.setSeconds(0); + date.setMilliseconds(0); + return { + date, + dateStr: date.toISOString().slice(0, 10), + dateTimeStr: date.toTimeString().slice(0, 5), + }; +} + +const DATE_FORMAT = 'YYYY-MM-DD'; +const TIME_FORMAT = 'HH:mm'; const defaultProps = { services: [{ name: 'svcA', operations: ['A', 'B'] }, { name: 'svcB', operations: ['A', 'B'] }], dataCenters: ['dc1'], }; -it(' only shows operations when a service is selected', () => { - let wrapper; - wrapper = shallow(); - expect(wrapper.find('.search-form--operation').length).toBe(1); +describe('conversion utils', () => { + describe('getUnixTimeStampInMSFromForm()', () => { + it('converts correctly', () => { + const { date: startSrc, dateStr: startDate, dateTimeStr: startDateTime } = makeDateParams(-1); + const { date: endSrc, dateStr: endDate, dateTimeStr: endDateTime } = makeDateParams(0); + const { start, end } = getUnixTimeStampInMSFromForm({ + startDate, + startDateTime, + endDate, + endDateTime, + }); + expect(start).toBe(`${startSrc.valueOf()}000`); + expect(end).toBe(`${endSrc.valueOf()}000`); + }); + }); + + describe('convertQueryParamsToFormDates()', () => { + it('converts correctly', () => { + const startMoment = moment().subtract(1, 'day'); + const endMoment = moment(); + const params = { + start: `${startMoment.valueOf()}000`, + end: `${endMoment.valueOf()}000`, + }; + + const { + queryStartDate, + queryStartDateTime, + queryEndDate, + queryEndDateTime, + } = convertQueryParamsToFormDates(params); + expect(queryStartDate).toBe(startMoment.format(DATE_FORMAT)); + expect(queryStartDateTime).toBe(startMoment.format(TIME_FORMAT)); + expect(queryEndDate).toBe(endMoment.format(DATE_FORMAT)); + expect(queryEndDateTime).toBe(endMoment.format(TIME_FORMAT)); + }); + }); - wrapper = shallow(); - expect(wrapper.find('.search-form--operation').length).toBe(0); + describe('tagsToQuery()', () => { + it('splits on "|"', () => { + const strs = ['a', 'b', 'c']; + expect(tagsToQuery(strs.join('|'))).toEqual(strs); + }); + }); + + describe('traceIDsToQuery()', () => { + it('splits on ","', () => { + const strs = ['a', 'b', 'c']; + expect(traceIDsToQuery(strs.join(','))).toEqual(strs); + }); + }); }); -it('getUnixTimeStampInMSFromForm converts correctly.', () => { - const startMoment = moment().subtract(1, 'day'); - const endMoment = moment(); - const params = { - startDate: startMoment.format(DATE_FORMAT), - startDateTime: startMoment.format(TIME_FORMAT), - endDate: endMoment.format(DATE_FORMAT), - endDateTime: endMoment.format(TIME_FORMAT), - }; +describe('submitForm()', () => { + const LOOKBACK_VALUE = 2; + const LOOKBACK_UNIT = 'd'; + let searchTraces; + let fields; + + beforeEach(() => { + searchTraces = jest.fn(); + fields = { + lookback: `${LOOKBACK_VALUE}${LOOKBACK_UNIT}`, + operation: 'op-a', + resultsLimit: 20, + service: 'svc-a', + tags: 'a|b', + }; + }); + + it('ignores `fields.operation` when it is "all"', () => { + fields.operation = 'all'; + submitForm(fields, searchTraces); + const { calls } = searchTraces.mock; + expect(calls.length).toBe(1); + const { operation } = calls[0][0]; + expect(operation).toBe(undefined); + }); + + describe('`fields.lookback`', () => { + it('subtracts `lookback` from `fields.end`', () => { + submitForm(fields, searchTraces); + const { calls } = searchTraces.mock; + expect(calls.length).toBe(1); + const { start, end } = calls[0][0]; + const diffMs = (Number(end) - Number(start)) / 1000; + const duration = moment.duration(diffMs); + expect(duration.asDays()).toBe(LOOKBACK_VALUE); + }); + + it('processes form dates when `lookback` is "custom"', () => { + const { date: startSrc, dateStr: startDate, dateTimeStr: startDateTime } = makeDateParams(-1); + const { date: endSrc, dateStr: endDate, dateTimeStr: endDateTime } = makeDateParams(0); + fields = { + ...fields, + startDate, + startDateTime, + endDate, + endDateTime, + lookback: 'custom', + }; + submitForm(fields, searchTraces); + const { calls } = searchTraces.mock; + expect(calls.length).toBe(1); + const { start, end } = calls[0][0]; + expect(start).toBe(`${startSrc.valueOf()}000`); + expect(end).toBe(`${endSrc.valueOf()}000`); + }); + }); - const { start, end } = getUnixTimeStampInMSFromForm(params); - expect(start).toBe(`${startMoment.seconds(0).unix()}000000`); - expect(end).toBe(`${endMoment.seconds(0).unix()}000000`); + describe('`fields.tag`', () => { + it('is ignored when `fields.tags` is falsy', () => { + fields.tags = undefined; + submitForm(fields, searchTraces); + const { calls } = searchTraces.mock; + expect(calls.length).toBe(1); + const { tag } = calls[0][0]; + expect(tag).toBe(undefined); + }); + + it('is parsed `fields.tags` is truthy', () => { + const tagStrs = ['a', 'b']; + fields.tags = tagStrs.join('|'); + submitForm(fields, searchTraces); + const { calls } = searchTraces.mock; + expect(calls.length).toBe(1); + const { tag } = calls[0][0]; + expect(tag).toEqual(tagStrs); + }); + }); + + describe('`fields.{minDuration,maxDuration}', () => { + it('retains values as-is when they are truthy', () => { + fields.minDuration = 'some-min'; + fields.maxDuration = 'some-max'; + submitForm(fields, searchTraces); + const { calls } = searchTraces.mock; + expect(calls.length).toBe(1); + const { minDuration, maxDuration } = calls[0][0]; + expect(minDuration).toBe(fields.minDuration); + expect(maxDuration).toBe(fields.maxDuration); + }); + + it('omits values when they are falsy', () => { + fields.minDuation = undefined; + fields.maxDuation = undefined; + submitForm(fields, searchTraces); + const { calls } = searchTraces.mock; + expect(calls.length).toBe(1); + const { minDuration, maxDuration } = calls[0][0]; + expect(minDuration).toBe(null); + expect(maxDuration).toBe(null); + }); + }); }); -it('convertQueryParamsToFormDates converts correctly.', () => { - const startMoment = moment().subtract(1, 'day'); - const endMoment = moment(); - const params = { - start: `${startMoment.valueOf()}000`, - end: `${endMoment.valueOf()}000`, - }; +describe('', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + }); + + it('shows operations only when a service is selected', () => { + expect(wrapper.find('.search-form--operation').length).toBe(0); + + wrapper = shallow(); + expect(wrapper.find('.search-form--operation').length).toBe(1); + }); + + it('shows custom date inputs when `props.selectedLookback` is "custom"', () => { + function getDateFieldLengths(compWrapper) { + return [compWrapper.find('.js-test-start-input').length, compWrapper.find('.js-test-end-input').length]; + } + expect(getDateFieldLengths(wrapper)).toEqual([0, 0]); + wrapper = shallow(); + expect(getDateFieldLengths(wrapper)).toEqual([1, 1]); + }); + + it('disables the submit button when a service is not selected', () => { + let btn = wrapper.find('.js-test-submit-btn'); + expect(btn.prop('disabled')).toBeTruthy(); + wrapper = shallow(); + btn = wrapper.find('.js-test-submit-btn'); + expect(btn.prop('disabled')).toBeFalsy(); + }); +}); + +describe('mapStateToProps()', () => { + let state; + + beforeEach(() => { + state = { router: { location: { serach: '' } } }; + }); + + it('does not explode when the query string is empty', () => { + expect(() => mapStateToProps(state)).not.toThrow(); + }); + + // tests the green path + it('service and operation fallback to values in `store` when the values are valid', () => { + const oldStoreGet = store.get; + const op = 'some-op'; + const svc = 'some-svc'; + state.services = { + services: [svc, 'something-else'], + operationsForService: { + [svc]: [op, 'some other opertion'], + }, + }; + store.get = () => ({ operation: op, service: svc }); + const { service, operation } = mapStateToProps(state).initialValues; + expect(operation).toBe(op); + expect(service).toBe(svc); + store.get = oldStoreGet; + }); + + it('derives values from `state.router.location.search` when available', () => { + const { date: startSrc, dateStr: startDate, dateTimeStr: startDateTime } = makeDateParams(-1); + const { date: endSrc, dateStr: endDate, dateTimeStr: endDateTime } = makeDateParams(0); + const common = { + lookback: '2h', + maxDuration: null, + minDuration: null, + operation: 'Driver::findNearest', + service: 'driver', + }; + const params = { + ...common, + end: `${endSrc.valueOf()}000`, + limit: '999', + start: `${startSrc.valueOf()}000`, + tag: ['error:true', 'span.kind:client'], + }; + const expected = { + ...common, + endDate, + endDateTime, + startDate, + startDateTime, + resultsLimit: params.limit, + tags: params.tag.join('|'), + traceIDs: null, + }; + + state.router.location.search = queryString.stringify(params); + expect(mapStateToProps(state).initialValues).toEqual(expected); + }); + + it('fallsback to default values', () => { + // convert time string to number of minutes in day + function msDiff(aDate, aTime, bDate, bTime) { + const a = new Date(`${aDate}T${aTime}`); + const b = new Date(`${bDate}T${bTime}`); + return Math.abs(a - b); + } + const dateParams = makeDateParams(0); + const { startDate, startDateTime, endDate, endDateTime, ...values } = mapStateToProps( + state + ).initialValues; - const { - queryStartDate, - queryStartDateTime, - queryEndDate, - queryEndDateTime, - } = convertQueryParamsToFormDates(params); - expect(queryStartDate).toBe(startMoment.format(DATE_FORMAT)); - expect(queryStartDateTime).toBe(startMoment.format(TIME_FORMAT)); - expect(queryEndDate).toBe(endMoment.format(DATE_FORMAT)); - expect(queryEndDateTime).toBe(endMoment.format(TIME_FORMAT)); + expect(values).toEqual({ + service: '-', + resultsLimit: 20, + lookback: '1h', + operation: 'all', + tags: undefined, + minDuration: null, + maxDuration: null, + traceIDs: null, + }); + expect(startDate).toBe(dateParams.dateStr); + expect(startDateTime).toBe('00:00'); + // expect the time differential between our `makeDateparams()` and the mapStateToProps values to be + // within 60 seconds (CI tests run slowly) + expect(msDiff(dateParams.dateStr, '00:00', startDate, startDateTime)).toBeLessThan(60 * 1000); + expect(msDiff(dateParams.dateStr, dateParams.dateTimeStr, endDate, endDateTime)).toBeLessThan(60 * 1000); + }); }); diff --git a/src/components/TracePage/KeyboardShortcutsHelp.css b/src/components/TracePage/KeyboardShortcutsHelp.css index 67ab433e45..35bc1aeb82 100644 --- a/src/components/TracePage/KeyboardShortcutsHelp.css +++ b/src/components/TracePage/KeyboardShortcutsHelp.css @@ -21,4 +21,4 @@ limitations under the License. color: #000; margin-right: 0.4em; padding: 0.25em 0.3em; -} \ No newline at end of file +} diff --git a/src/components/TracePage/KeyboardShortcutsHelp.js b/src/components/TracePage/KeyboardShortcutsHelp.js index 38383d1054..460d3461eb 100644 --- a/src/components/TracePage/KeyboardShortcutsHelp.js +++ b/src/components/TracePage/KeyboardShortcutsHelp.js @@ -53,20 +53,12 @@ export default function KeyboardShortcutsHelp() { const rows = []; Object.keys(kbdMappings).forEach(title => { const keyConfigs = convertKeys(kbdMappings[title]); - const configs = keyConfigs.map(config => + const configs = keyConfigs.map(config => ( - - {config.map(s => - - {s} - - )} - - - {descriptions[title]} - + {config.map(s => {s})} + {descriptions[title]} - ); + )); rows.push(...configs); }); return ( @@ -87,9 +79,7 @@ export default function KeyboardShortcutsHelp() { Description - - {rows} - + {rows} From 4aaa000fe17e068e791fcf0650abe4a880114472 Mon Sep 17 00:00:00 2001 From: Joe Farro Date: Wed, 6 Dec 2017 13:39:13 -0500 Subject: [PATCH 2/3] Bolster test coverage Signed-off-by: Joe Farro --- package.json | 3 +- src/components/App/Page.js | 8 +- src/components/App/Page.test.js | 71 ++++ .../DependencyGraph/DAG.test.js} | 25 +- .../DependencyGraph/DependencyForceGraph.js | 15 +- .../DependencyForceGraph.test.js | 116 +++++++ src/components/DependencyGraph/index.js | 19 +- src/components/DependencyGraph/index.test.js | 100 +++++- .../SearchDropdownInput.test.js | 71 ++++ .../SearchTracePage/TraceSearchForm.test.js | 11 +- src/components/SearchTracePage/index.js | 49 +-- src/components/SearchTracePage/index.test.js | 169 ++++++++-- src/components/TracePage/ScrollManager.js | 2 + .../TracePage/ScrollManager.test.js | 119 +++++-- .../TraceTimelineViewer/ListView/index.js | 3 +- .../ListView/index.test.js | 90 +++++- .../VirtualizedTraceView.js | 7 +- .../VirtualizedTraceView.test.js | 53 ++- .../TraceTimelineViewer/duck.test.js | 50 +++ src/components/TracePage/index.js | 22 +- src/components/TracePage/index.test.js | 246 +++++++++++++- src/components/TracePage/scroll-page.test.js | 159 +++++++++ .../DraggableManager/DraggableManager.test.js | 303 ++++++++++++++++++ src/utils/transform-trace.js | 98 ------ 24 files changed, 1571 insertions(+), 238 deletions(-) create mode 100644 src/components/App/Page.test.js rename src/{constants/graph-colors.js => components/DependencyGraph/DAG.test.js} (59%) create mode 100644 src/components/DependencyGraph/DependencyForceGraph.test.js create mode 100644 src/components/SearchTracePage/SearchDropdownInput.test.js create mode 100644 src/components/TracePage/scroll-page.test.js create mode 100644 src/utils/DraggableManager/DraggableManager.test.js delete mode 100644 src/utils/transform-trace.js diff --git a/package.json b/package.json index 0cbbfa8c0f..cfd60ca420 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,8 @@ "jest": { "collectCoverageFrom": [ "src/**/*.js", - "!src/utils/DraggableManager/demo/*.js" + "!src/utils/DraggableManager/demo/*.js", + "!src/demo/**/*.js" ] }, "prettier": { diff --git a/src/components/App/Page.js b/src/components/App/Page.js index dd51028d85..309da06982 100644 --- a/src/components/App/Page.js +++ b/src/components/App/Page.js @@ -32,7 +32,8 @@ type PageProps = { config: Config, }; -class Page extends React.Component { +// export for tests +export class PageImpl extends React.Component { props: PageProps; componentDidMount() { @@ -61,10 +62,11 @@ class Page extends React.Component { } } -function mapStateToProps(state, ownProps) { +// export for tests +export function mapStateToProps(state: { config: Config, router: { location: Location } }, ownProps: any) { const { config } = state; const { location } = state.router; return { ...ownProps, config, location }; } -export default withRouter(connect(mapStateToProps)(Page)); +export default withRouter(connect(mapStateToProps)(PageImpl)); diff --git a/src/components/App/Page.test.js b/src/components/App/Page.test.js new file mode 100644 index 0000000000..a0f2d8f65c --- /dev/null +++ b/src/components/App/Page.test.js @@ -0,0 +1,71 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// 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 +// +// http://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. + +/* eslint-disable import/first */ +jest.mock('./TopNav', () => () =>
); +jest.mock('../../utils/metrics'); + +import React from 'react'; +import { mount } from 'enzyme'; + +import { mapStateToProps, PageImpl as Page } from './Page'; +import { trackPageView } from '../../utils/metrics'; + +describe('mapStateToProps()', () => { + it('maps state to props', () => { + const state = { + config: {}, + router: { location: {} }, + }; + const ownProps = { a: {} }; + expect(mapStateToProps(state, ownProps)).toEqual({ + config: state.config, + location: state.router.location, + a: ownProps.a, + }); + }); +}); + +describe('', () => { + let props; + let wrapper; + + beforeEach(() => { + trackPageView.mockReset(); + props = { + location: { + pathname: String(Math.random()), + search: String(Math.random()), + }, + config: { menu: [] }, + }; + wrapper = mount(); + }); + + it('does not explode', () => { + expect(wrapper).toBeDefined(); + }); + + it('tracks an initial page-view', () => { + const { pathname, search } = props.location; + expect(trackPageView.mock.calls).toEqual([[pathname, search]]); + }); + + it('tracks a pageView when the location changes', () => { + trackPageView.mockReset(); + const location = { pathname: 'le-path', search: 'searching' }; + wrapper.setProps({ location }); + expect(trackPageView.mock.calls).toEqual([[location.pathname, location.search]]); + }); +}); diff --git a/src/constants/graph-colors.js b/src/components/DependencyGraph/DAG.test.js similarity index 59% rename from src/constants/graph-colors.js rename to src/components/DependencyGraph/DAG.test.js index ab0eec0ff1..60eb6c4aa2 100644 --- a/src/constants/graph-colors.js +++ b/src/components/DependencyGraph/DAG.test.js @@ -12,12 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -const BLUE = '#3683bb'; -const LIGHT_BLUE = '#6eafd4'; -const RED = '#e45629'; -const ORANGE = '#fb8d46'; -const GREEN = '#37a257'; +/* eslint-disable import/first */ +jest.mock('cytoscape'); -const colors = [BLUE, LIGHT_BLUE, RED, ORANGE, GREEN]; +import React from 'react'; +import { mount } from 'enzyme'; -export default colors; +import DAG from './DAG'; + +describe('', () => { + it('does not explode', () => { + const serviceCalls = [ + { + callCount: 1, + child: 'child-id', + parent: 'parent-id', + }, + ]; + expect(mount()).toBeDefined(); + }); +}); diff --git a/src/components/DependencyGraph/DependencyForceGraph.js b/src/components/DependencyGraph/DependencyForceGraph.js index 5346493a65..979dbb956e 100644 --- a/src/components/DependencyGraph/DependencyForceGraph.js +++ b/src/components/DependencyGraph/DependencyForceGraph.js @@ -19,15 +19,14 @@ import { debounce } from 'lodash'; import { nodesPropTypes, linksPropTypes } from '../../propTypes/dependencies'; -const chargeStrength = ({ radius = 5, orphan }) => (orphan ? -20 * radius : -12 * radius); +// export for tests +export const chargeStrength = ({ radius = 5, orphan }) => (orphan ? -20 * radius : -12 * radius); export default class DependencyForceGraph extends Component { - static get propTypes() { - return { - nodes: nodesPropTypes.isRequired, - links: linksPropTypes.isRequired, - }; - } + static propTypes = { + nodes: nodesPropTypes.isRequired, + links: linksPropTypes.isRequired, + }; constructor(props) { super(props); @@ -39,7 +38,7 @@ export default class DependencyForceGraph extends Component { }; } - componentDidMount() { + componentWillMount() { this.onResize(); this.debouncedResize = debounce((...args) => this.onResize(...args), 50); window.addEventListener('resize', this.debouncedResize); diff --git a/src/components/DependencyGraph/DependencyForceGraph.test.js b/src/components/DependencyGraph/DependencyForceGraph.test.js new file mode 100644 index 0000000000..225ad88dbc --- /dev/null +++ b/src/components/DependencyGraph/DependencyForceGraph.test.js @@ -0,0 +1,116 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// 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 +// +// http://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 React from 'react'; +import { shallow } from 'enzyme'; +import { InteractiveForceGraph, ForceGraphNode, ForceGraphLink } from 'react-vis-force'; + +import DependencyForceGraph, { chargeStrength } from './DependencyForceGraph'; + +describe('chargeStrength', () => { + it('returns a number', () => { + expect(chargeStrength({ radius: 1, orphan: false })).toBeLessThan(0); + }); + + it('handles orphan as a special case', () => { + const asOrphan = chargeStrength({ radius: 1, orphan: true }); + const notOrphan = chargeStrength({ radius: 1, orphan: false }); + expect(chargeStrength(asOrphan)).toBeLessThan(0); + expect(chargeStrength(notOrphan)).toBeLessThan(0); + expect(asOrphan).not.toBe(notOrphan); + }); +}); + +describe('', () => { + const nodes = [{ id: 'node-a', radius: 1 }, { id: 'node-b', radius: 1 }]; + const links = [{ source: 'node-a', target: 'node-b', value: 1 }]; + let oldSize; + let wrapper; + + beforeAll(() => { + oldSize = { + width: window.innerWidth, + height: window.innerHeight, + }; + }); + + afterAll(() => { + const { height, width } = oldSize; + window.innerHeight = height; + window.innerWidth = width; + }); + + beforeEach(() => { + window.innerWidth = 1234; + window.innerHeight = 5678; + wrapper = shallow(); + }); + + it('does not explode', () => { + expect(wrapper).toBeDefined(); + expect(wrapper.length).toBe(1); + }); + + it('saves the window dimensions to state', () => { + const { height, width } = wrapper.state(); + expect(height).toBe(window.innerHeight); + expect(width).toBe(window.innerWidth); + }); + + describe('window resize event', () => { + it('adds and removes an event listener on mount and unmount', () => { + const oldFns = { + addFn: window.addEventListener, + removeFn: window.removeEventListener, + }; + window.addEventListener = jest.fn(); + window.removeEventListener = jest.fn(); + wrapper = shallow(); + expect(window.addEventListener.mock.calls.length).toBe(1); + expect(window.removeEventListener.mock.calls.length).toBe(0); + wrapper.unmount(); + expect(window.removeEventListener.mock.calls.length).toBe(1); + window.addEventListener = oldFns.addFn; + window.removeEventListener = oldFns.removeFn; + }); + + it('updates the saved window dimensions on resize', () => { + const { height: preHeight, width: preWidth } = wrapper.state(); + window.innerHeight *= 2; + window.innerWidth *= 2; + // difficult to get JSDom to dispatch the window resize event, so hit + // the listener directly + wrapper.instance().onResize(); + const { height, width } = wrapper.state(); + expect(height).toBe(window.innerHeight); + expect(width).toBe(window.innerWidth); + expect(height).not.toBe(preHeight); + expect(width).not.toBe(preWidth); + }); + }); + + describe('render', () => { + it('renders a InteractiveForceGraph', () => { + expect(wrapper.find(InteractiveForceGraph).length).toBe(1); + }); + + it('renders a for each node', () => { + expect(wrapper.find(ForceGraphNode).length).toBe(nodes.length); + }); + + it('renders a for each link', () => { + expect(wrapper.find(ForceGraphLink).length).toBe(links.length); + }); + }); +}); diff --git a/src/components/DependencyGraph/index.js b/src/components/DependencyGraph/index.js index b508e5bcf3..9afec6e836 100644 --- a/src/components/DependencyGraph/index.js +++ b/src/components/DependencyGraph/index.js @@ -27,6 +27,12 @@ import { nodesPropTypes, linksPropTypes } from '../../propTypes/dependencies'; import DependencyForceGraph from './DependencyForceGraph'; import DAG from './DAG'; +// export for tests +export const GRAPH_TYPES = { + FORCE_DIRECTED: { type: 'FORCE_DIRECTED', name: 'Force Directed Graph' }, + DAG: { type: 'DAG', name: 'DAG' }, +}; + export default class DependencyGraphPage extends Component { static propTypes = { // eslint-disable-next-line react/forbid-prop-types @@ -51,7 +57,7 @@ export default class DependencyGraphPage extends Component { graphType: 'FORCE_DIRECTED', }; } - componentDidMount() { + componentWillMount() { this.props.fetchDependencies(); } @@ -83,10 +89,10 @@ export default class DependencyGraphPage extends Component { ); } - const GRAPH_TYPE_OPTIONS = [{ type: 'FORCE_DIRECTED', name: 'Force Directed Graph' }]; + const GRAPH_TYPE_OPTIONS = [GRAPH_TYPES.FORCE_DIRECTED]; if (dependencies.length <= 100) { - GRAPH_TYPE_OPTIONS.push({ type: 'DAG', name: 'DAG' }); + GRAPH_TYPE_OPTIONS.push(GRAPH_TYPES.DAG); } return (
@@ -119,8 +125,8 @@ export default class DependencyGraphPage extends Component { } } -// export connected component separately -function mapStateToProps(state) { +// export for tests +export function mapStateToProps(state) { const { dependencies, error, loading } = state.dependencies; let links; let nodes; @@ -132,7 +138,8 @@ function mapStateToProps(state) { return { loading, error, nodes, links, dependencies }; } -function mapDispatchToProps(dispatch) { +// export for tests +export function mapDispatchToProps(dispatch) { const { fetchDependencies } = bindActionCreators(jaegerApiActions, dispatch); return { fetchDependencies }; } diff --git a/src/components/DependencyGraph/index.test.js b/src/components/DependencyGraph/index.test.js index f3ebe0a5e1..17578caeb1 100644 --- a/src/components/DependencyGraph/index.test.js +++ b/src/components/DependencyGraph/index.test.js @@ -14,9 +14,103 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { Menu } from 'semantic-ui-react'; -import DependencyGraphPage from './index'; +import DAG from './DAG'; +import DependencyForceGraph from './DependencyForceGraph'; +import DependencyGraph, { GRAPH_TYPES, mapDispatchToProps, mapStateToProps } from './index'; -it('DependencyGraphPage does not explode', () => { - shallow( {}} />); +const childId = 'boomya'; +const parentId = 'elder-one'; +const callCount = 1; +const dependencies = [ + { + callCount, + child: childId, + parent: parentId, + }, +]; +const state = { + dependencies: { + dependencies, + error: null, + loading: false, + }, +}; + +const props = mapStateToProps(state); + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow( {}} />); + }); + + it('does not explode', () => { + expect(wrapper.length).toBe(1); + }); + + it('shows a loading indicator when loading data', () => { + expect(wrapper.find('.loader').length).toBe(0); + wrapper.setProps({ loading: true }); + expect(wrapper.find('.loader').length).toBe(1); + }); + + it('shows an error message when passed error information', () => { + const error = {}; + expect(wrapper.find({ error: expect.anything() }).length).toBe(0); + wrapper.setProps({ error }); + expect(wrapper.find({ error }).length).toBe(1); + }); + + it('shows a message where there is nothing to visualize', () => { + wrapper.setProps({ links: null, nodes: null }); + const matchTest = expect.stringMatching(/no.*?found/i); + expect(wrapper.text()).toEqual(matchTest); + }); + + describe('graph types', () => { + it('renders a menu with options for the graph types', () => { + expect(wrapper.find(Menu).length).toBe(1); + expect(wrapper.find(Menu.Item).length).toBe(Object.keys(GRAPH_TYPES).length); + expect(wrapper.find({ name: GRAPH_TYPES.FORCE_DIRECTED.name }).length).toBe(1); + expect(wrapper.find({ name: GRAPH_TYPES.DAG.name }).length).toBe(1); + }); + + it('renders a force graph when FORCE_GRAPH is the selected type', () => { + const menuItem = wrapper.find({ name: GRAPH_TYPES.FORCE_DIRECTED.name }); + expect(menuItem.length).toBe(1); + menuItem.simulate('click'); + expect(wrapper.state('graphType')).toBe(GRAPH_TYPES.FORCE_DIRECTED.type); + expect(wrapper.find(DependencyForceGraph).length).toBe(1); + }); + + it('renders a DAG graph when DAG is the selected type', () => { + const forceMenuItem = wrapper.find({ name: GRAPH_TYPES.DAG.name }); + expect(forceMenuItem.length).toBe(1); + forceMenuItem.simulate('click'); + expect(wrapper.state('graphType')).toBe(GRAPH_TYPES.DAG.type); + expect(wrapper.find(DAG).length).toBe(1); + }); + }); +}); + +describe('mapStateToProps()', () => { + it('refines state to generate the props', () => { + expect(mapStateToProps(state)).toEqual({ + ...state.dependencies, + nodes: [ + expect.objectContaining({ callCount, orphan: false, id: parentId }), + expect.objectContaining({ callCount, orphan: false, id: childId }), + ], + links: [{ callCount, source: parentId, target: childId, value: 1 }], + }); + }); +}); + +describe('mapDispatchToProps()', () => { + it('providers the `fetchDependencies` prop', () => { + expect(mapDispatchToProps({})).toEqual({ fetchDependencies: expect.any(Function) }); + }); }); diff --git a/src/components/SearchTracePage/SearchDropdownInput.test.js b/src/components/SearchTracePage/SearchDropdownInput.test.js new file mode 100644 index 0000000000..ef505f62a6 --- /dev/null +++ b/src/components/SearchTracePage/SearchDropdownInput.test.js @@ -0,0 +1,71 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// 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 +// +// http://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 React from 'react'; +import { shallow } from 'enzyme'; +import { Dropdown } from 'semantic-ui-react'; + +import SearchDropdownInput from './SearchDropdownInput'; + +function toItem(s) { + return { text: s, value: s }; +} + +const MAX_RESULTS = 3; + +describe('', () => { + let currentItems; + let items; + let props; + let wrapper; + + beforeEach(() => { + items = ['abc', 'bcd', 'cde', 'abc', 'bcd', 'cde', 'abc', 'bcd', 'cde', ...'0123456789'].map(toItem); + currentItems = items.slice(0, MAX_RESULTS); + props = { + items, + maxResults: MAX_RESULTS, + input: { onChange: () => {}, value: null }, + }; + wrapper = shallow(); + }); + + it('does not explode', () => { + expect(wrapper).toBeDefined(); + }); + + it('limits the items via `maxResults`', () => { + const dropdown = wrapper.find(Dropdown); + const { options } = dropdown.props(); + expect(options.length).toBe(MAX_RESULTS); + expect(options).toEqual(currentItems); + }); + + it('adjusts the options when given new items', () => { + items = items.slice().reverse(); + wrapper.setProps({ items }); + const dropdown = wrapper.find(Dropdown); + const { options } = dropdown.props(); + expect(options).toEqual(items.slice(0, MAX_RESULTS)); + }); + + it('filters items by the searchText', () => { + const rx = /b/; + const dropdown = wrapper.find(Dropdown); + const { search } = dropdown.props(); + const filtered = search(null, rx.source); + const spec = items.filter(item => rx.test(item.text)).slice(0, MAX_RESULTS); + expect(filtered).toEqual(spec); + }); +}); diff --git a/src/components/SearchTracePage/TraceSearchForm.test.js b/src/components/SearchTracePage/TraceSearchForm.test.js index a19bad9bb1..175fc6c37f 100644 --- a/src/components/SearchTracePage/TraceSearchForm.test.js +++ b/src/components/SearchTracePage/TraceSearchForm.test.js @@ -19,9 +19,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import moment from 'moment'; import queryString from 'query-string'; - -// use `require` so statement is not hoised above `jest.mock(...)` -const store = require('store'); +import store from 'store'; import { convertQueryParamsToFormDates, @@ -38,9 +36,14 @@ function makeDateParams(dateOffset = 0) { date.setDate(date.getDate() + dateOffset || 0); date.setSeconds(0); date.setMilliseconds(0); + const month = date.getMonth() + 1; + const day = date.getDate(); + const dateStr = [date.getFullYear(), '-', month < 10 ? '0' : '', month, '-', day < 10 ? '0' : '', day].join( + '' + ); return { date, - dateStr: date.toISOString().slice(0, 10), + dateStr, dateTimeStr: date.toTimeString().slice(0, 5), }; } diff --git a/src/components/SearchTracePage/index.js b/src/components/SearchTracePage/index.js index c47378ded2..e46f4793f3 100644 --- a/src/components/SearchTracePage/index.js +++ b/src/components/SearchTracePage/index.js @@ -37,27 +37,29 @@ import prefixUrl from '../../utils/prefix-url'; /** * Contains the dropdown to sort and filter trace search results */ -let TraceResultsFilterForm = () => ( -
-
- - - - - - - - +function TraceResultsFilterFormImpl() { + return ( +
+
+ + + + + + + + +
-
-); + ); +} -TraceResultsFilterForm = reduxForm({ +const TraceResultsFilterForm = reduxForm({ form: 'traceResultsFilters', initialValues: { sortBy: orderBy.MOST_RECENT, }, -})(TraceResultsFilterForm); +})(TraceResultsFilterFormImpl); const traceResultsFiltersFormSelector = formValueSelector('traceResultsFilters'); @@ -95,16 +97,16 @@ export default class SearchTracePage extends Component { ) : (
-
+
)}
- {loadingTraces &&
} + {loadingTraces &&
} {errorMessage && !loadingTraces && ( -
+
There was an error querying for traces:
{errorMessage}
@@ -113,7 +115,7 @@ export default class SearchTracePage extends Component { !hasTraceResults && (
- presentation + presentation
)} @@ -121,7 +123,9 @@ export default class SearchTracePage extends Component { !hasTraceResults && !loadingTraces && !errorMessage && ( -
No trace results. Try another query.
+
+ No trace results. Try another query. +
)} {hasTraceResults && !loadingTraces && ( @@ -138,7 +142,7 @@ export default class SearchTracePage extends Component { name: t.traceName, }))} onValueClick={t => { - this.props.history.push(`/trace/${t.traceID}`); + this.props.history.push(prefixUrl(`/trace/${t.traceID}`)); }} />
@@ -226,7 +230,8 @@ const stateServicesXformer = getLastXformCacher(stateServices => { return { loadingServices, services, serviceError }; }); -function mapStateToProps(state) { +// export to test +export function mapStateToProps(state) { const query = queryString.parse(state.router.location.search); const isHomepage = !Object.keys(query).length; const { traces, maxDuration, traceError, loadingTraces } = stateTraceXformer(state.trace); diff --git a/src/components/SearchTracePage/index.test.js b/src/components/SearchTracePage/index.test.js index 7c3a2a77eb..dd46bcfbb3 100644 --- a/src/components/SearchTracePage/index.test.js +++ b/src/components/SearchTracePage/index.test.js @@ -12,34 +12,155 @@ // See the License for the specific language governing permissions and // limitations under the License. +jest.mock('redux-form', () => { + function reduxForm() { + return component => component; + } + function formValueSelector() { + return () => null; + } + const Field = () =>
; + return { Field, formValueSelector, reduxForm }; +}); + +jest.mock('react-router-dom'); +jest.mock('store'); + +/* eslint-disable import/first */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; +import store from 'store'; -import SearchTracePage from './index'; -/* eslint-disable */ +import SearchTracePage, { mapStateToProps } from './index'; import TraceResultsScatterPlot from './TraceResultsScatterPlot'; -/* eslint-enable */ - -const SCATTER_PLOT = 'SCATTER_PLOT'; -const defaultProps = { - sortTracesBy: 'LONGEST_FIRST', - traceResultsChartType: SCATTER_PLOT, - traceResults: [{ traceID: 'a', spans: [], processes: {} }, { traceID: 'b', spans: [], processes: {} }], - numberOfTraceResults: 0, - maxTraceDuration: 100, -}; - -it('should show default message when there are no results', () => { - const wrapper = shallow(); - expect(wrapper.find('.trace-search--no-results').length).toBe(1); -}); +import TraceSearchForm from './TraceSearchForm'; +import TraceSearchResult from './TraceSearchResult'; +import traceGenerator from '../../demo/trace-generators'; +import { MOST_RECENT } from '../../model/order-by'; +import transformTraceData from '../../model/transform-trace-data'; + +describe('', () => { + let wrapper; + let traceResults; + let props; + + beforeEach(() => { + traceResults = [{ traceID: 'a', spans: [], processes: {} }, { traceID: 'b', spans: [], processes: {} }]; + props = { + traceResults, + isHomepage: false, + loadingServices: false, + loadingTraces: false, + maxTraceDuration: 100, + numberOfTraceResults: traceResults.length, + services: null, + sortTracesBy: MOST_RECENT, + urlQueryParams: { service: 'svc-a' }, + // actions + fetchServiceOperations: jest.fn(), + fetchServices: jest.fn(), + searchTraces: jest.fn(), + }; + wrapper = shallow(); + }); + + it('searches for traces if `service` or `traceID` are in the query string', () => { + wrapper = mount(); + expect(props.searchTraces.mock.calls.length).toBe(1); + }); + + it('loads the services and operations if a service is stored', () => { + const oldFn = store.get; + store.get = jest.fn(() => ({ service: 'svc-b' })); + wrapper = mount(); + expect(props.fetchServices.mock.calls.length).toBe(1); + expect(props.fetchServiceOperations.mock.calls.length).toBe(1); + store.get = oldFn; + }); + + describe('loading', () => { + it('shows a loading indicator if loading services', () => { + wrapper.setProps({ loadingServices: true }); + expect(wrapper.find('.js-test-search-loader').length).toBe(1); + }); -it('renders the a SCATTER_PLOT chart', () => { - const wrapper = shallow(); - expect(wrapper.find(TraceResultsScatterPlot).length).toBe(1); + it('shows a loading indicator if loading traces', () => { + wrapper.setProps({ loadingTraces: true }); + expect(wrapper.find('.js-test-traces-loader').length).toBe(1); + }); + }); + + it('shows a search form when services are loaded', () => { + const services = [{ name: 'svc-a', operations: ['op-a'] }]; + wrapper.setProps({ services }); + expect(wrapper.find(TraceSearchForm).length).toBe(1); + }); + + it('shows an error message if there is an error message', () => { + wrapper.setProps({ errorMessage: 'big-error' }); + expect(wrapper.find('.js-test-error-message').length).toBe(1); + }); + + it('shows the logo prior to searching', () => { + wrapper.setProps({ isHomepage: true, traceResults: [] }); + expect(wrapper.find('.js-test-logo').length).toBe(1); + }); + + it('shows the "no results" message when the search result is empty', () => { + wrapper.setProps({ traceResults: [] }); + expect(wrapper.find('.js-test-no-results').length).toBe(1); + }); + + describe('search finished with results', () => { + it('shows a scatter plot', () => { + expect(wrapper.find(TraceResultsScatterPlot).length).toBe(1); + }); + + it('shows the results filter form', () => { + expect(wrapper.find('TraceResultsFilterFormImpl').length).toBe(1); + }); + + it('shows a result entry for each trace', () => { + expect(wrapper.find(TraceSearchResult).length).toBe(traceResults.length); + }); + }); }); -it('shows loader when loading', () => { - const wrapper = shallow(); - expect(wrapper.find('.loader').length).toBe(1); +describe('mapStateToProps()', () => { + it('converts state to the necessary props', () => { + const trace = transformTraceData(traceGenerator.trace({})); + const stateTrace = { traces: [trace], loading: false, error: null }; + const stateServices = { + loading: false, + services: ['svc-a'], + operationsForService: {}, + error: null, + }; + const state = { + router: { location: { search: '' } }, + trace: stateTrace, + services: stateServices, + }; + + const { maxTraceDuration, traceResults, numberOfTraceResults, ...rest } = mapStateToProps(state); + expect(traceResults.length).toBe(stateTrace.traces.length); + expect(traceResults[0].traceID).toBe(trace.traceID); + expect(maxTraceDuration).toBe(trace.duration / 1000); + + expect(rest).toEqual({ + isHomepage: true, + // the redux-form `formValueSelector` mock returns `null` for "sortBy" + sortTracesBy: null, + urlQueryParams: {}, + services: [ + { + name: stateServices.services[0], + operations: [], + }, + ], + loadingTraces: false, + loadingServices: false, + errorMessage: '', + }); + }); }); diff --git a/src/components/TracePage/ScrollManager.js b/src/components/TracePage/ScrollManager.js index df1923d0e5..e7d151bf54 100644 --- a/src/components/TracePage/ScrollManager.js +++ b/src/components/TracePage/ScrollManager.js @@ -98,6 +98,7 @@ export default class ScrollManager { _scrollPast(rowIndex: number, direction: 1 | -1) { const xrs = this._accessors; + /* istanbul ignore next */ if (!xrs) { throw new Error('Accessors not set'); } @@ -121,6 +122,7 @@ export default class ScrollManager { _scrollToVisibleSpan(direction: 1 | -1) { const xrs = this._accessors; + /* istanbul ignore next */ if (!xrs) { throw new Error('Accessors not set'); } diff --git a/src/components/TracePage/ScrollManager.test.js b/src/components/TracePage/ScrollManager.test.js index 20b19c3156..7f676d825f 100644 --- a/src/components/TracePage/ScrollManager.test.js +++ b/src/components/TracePage/ScrollManager.test.js @@ -74,6 +74,19 @@ describe('ScrollManager', () => { expect(manager._scrollPast).toThrow(); }); + it('is a noop if an invalid rowPosition is returned by the accessors', () => { + // eslint-disable-next-line no-console + const oldWarn = console.warn; + // eslint-disable-next-line no-console + console.warn = () => {}; + manager._scrollPast(null, null); + expect(accessors.getRowPosition.mock.calls.length).toBe(1); + expect(accessors.getViewHeight.mock.calls.length).toBe(0); + expect(scrollTo.mock.calls.length).toBe(0); + // eslint-disable-next-line no-console + console.warn = oldWarn; + }); + it('scrolls up with direction is `-1`', () => { const y = 10; const expectTo = y - 0.5 * accessors.getViewHeight(); @@ -148,38 +161,90 @@ describe('ScrollManager', () => { expect(scrollPastMock).lastCalledWith(4, -1); }); - it('skips spans that are hidden because their parent is collapsed', () => { - const getRefs = spanID => [{ refType: 'CHILD_OF', spanID }]; - // change spans so 0 and 4 are top-level and their children are collapsed - const spans = trace.spans; - let parentID; - for (let i = 0; i < spans.length; i++) { - switch (i) { - case 0: - case 4: - parentID = spans[i].spanID; - break; - default: - spans[i].references = getRefs(parentID); - } + describe('scrollToNextVisibleSpan() and scrollToPrevVisibleSpan()', () => { + function getRefs(spanID) { + return [{ refType: 'CHILD_OF', spanID }]; } - accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); - accessors.getBottomRowIndexVisible.mockReturnValue(0); - accessors.getCollapsedChildren.mockReturnValue(new Set([spans[0].spanID, spans[4].spanID])); - manager.scrollToNextVisibleSpan(); - expect(scrollPastMock).lastCalledWith(4, 1); - manager.scrollToPrevVisibleSpan(); - expect(scrollPastMock).lastCalledWith(4, -1); + + beforeEach(() => { + // change spans so 0 and 4 are top-level and their children are collapsed + const spans = trace.spans; + let parentID; + for (let i = 0; i < spans.length; i++) { + switch (i) { + case 0: + case 4: + parentID = spans[i].spanID; + break; + default: + spans[i].references = getRefs(parentID); + } + } + // set which spans are "in-view" and which have collapsed children + accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); + accessors.getBottomRowIndexVisible.mockReturnValue(0); + accessors.getCollapsedChildren.mockReturnValue(new Set([spans[0].spanID, spans[4].spanID])); + }); + + it('skips spans that are hidden because their parent is collapsed', () => { + manager.scrollToNextVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, 1); + manager.scrollToPrevVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + + it('ignores references with unknown types', () => { + // modify spans[2] so that it has an unknown refType + const spans = trace.spans; + spans[2].references = [{ refType: 'OTHER' }]; + manager.scrollToNextVisibleSpan(); + expect(scrollPastMock).lastCalledWith(2, 1); + manager.scrollToPrevVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, -1); + }); + + it('handles more than one level of ancestry', () => { + // modify spans[2] so that it has an unknown refType + const spans = trace.spans; + spans[2].references = getRefs(spans[1].spanID); + manager.scrollToNextVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, 1); + manager.scrollToPrevVisibleSpan(); + expect(scrollPastMock).lastCalledWith(4, -1); + }); }); }); - it('scrolls down by ~viewHeight when scrollPageDown is invoked', () => { - manager.scrollPageDown(); - expect(scrollBy).lastCalledWith(0.95 * accessors.getViewHeight(), true); + describe('scrollPageDown() and scrollPageUp()', () => { + it('scrolls by +/~ viewHeight when invoked', () => { + manager.scrollPageDown(); + expect(scrollBy).lastCalledWith(0.95 * accessors.getViewHeight(), true); + manager.scrollPageUp(); + expect(scrollBy).lastCalledWith(-0.95 * accessors.getViewHeight(), true); + }); + + it('is a no-op if _accessors or _scroller is not defined', () => { + manager._accessors = null; + manager.scrollPageDown(); + manager.scrollPageUp(); + expect(scrollBy.mock.calls.length).toBe(0); + manager._accessors = accessors; + manager._scroller = null; + manager.scrollPageDown(); + manager.scrollPageUp(); + expect(scrollBy.mock.calls.length).toBe(0); + }); }); - it('scrolls up by ~viewHeight when scrollPageUp is invoked', () => { - manager.scrollPageUp(); - expect(scrollBy).lastCalledWith(-0.95 * accessors.getViewHeight(), true); + describe('destroy()', () => { + it('disposes', () => { + expect(manager._trace).toBeDefined(); + expect(manager._accessors).toBeDefined(); + expect(manager._scroller).toBeDefined(); + manager.destroy(); + expect(manager._trace).not.toBeDefined(); + expect(manager._accessors).not.toBeDefined(); + expect(manager._scroller).not.toBeDefined(); + }); }); }); diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/index.js b/src/components/TracePage/TraceTimelineViewer/ListView/index.js index ac0306ff82..ead13feacb 100644 --- a/src/components/TracePage/TraceTimelineViewer/ListView/index.js +++ b/src/components/TracePage/TraceTimelineViewer/ListView/index.js @@ -265,6 +265,7 @@ export default class ListView extends React.Component { const useRoot = this.props.windowScroller; // funky if statement is to satisfy flow if (!useRoot) { + /* istanbul ignore next */ if (!this._wrapperElm) { this._viewHeight = -1; this._startIndex = 0; @@ -301,8 +302,6 @@ export default class ListView extends React.Component { ? this._endIndex + this.props.viewBufferMin : this.props.dataLength - 1; if (maxStart < this._startIndexDrawn || minEnd > this._endIndexDrawn) { - // console.time('force update'); - // setTimeout(() => console.timeEnd('force update'), 0); this.forceUpdate(); } }; diff --git a/src/components/TracePage/TraceTimelineViewer/ListView/index.test.js b/src/components/TracePage/TraceTimelineViewer/ListView/index.test.js index 6045aabc4a..3b9cc9e221 100644 --- a/src/components/TracePage/TraceTimelineViewer/ListView/index.test.js +++ b/src/components/TracePage/TraceTimelineViewer/ListView/index.test.js @@ -55,8 +55,6 @@ describe('', () => { viewBuffer: 10, viewBufferMin: 5, windowScroller: false, - // eslint-disable-next-line no-return-assign - ref: c => (instance = c), }; describe('shallow tests', () => { @@ -95,29 +93,83 @@ describe('', () => { }); describe('mount tests', () => { - beforeEach(() => { - wrapper = mount( -
- -
- ); - }); + describe('accessor functions', () => { + const clientHeight = 2; + const scrollTop = 3; + + let oldRender; + let oldInitWrapper; + const initWrapperMock = jest.fn(elm => { + if (elm != null) { + // jsDom requires `defineProperties` instead of just setting the props + Object.defineProperties(elm, { + clientHeight: { + get: () => clientHeight, + }, + scrollTop: { + get: () => scrollTop, + }, + }); + } + oldInitWrapper.call(this, elm); + }); - it('renders without exploding', () => { - expect(wrapper).toBeDefined(); + beforeAll(() => { + oldRender = ListView.prototype.render; + // `_initWrapper` is not on the prototype, so it needs to be mocked + // on each instance, use `render()` as a hook to do that + ListView.prototype.render = function altRender() { + if (this._initWrapper !== initWrapperMock) { + oldInitWrapper = this._initWrapper; + this._initWrapper = initWrapperMock; + } + return oldRender.call(this); + }; + }); + + afterAll(() => { + ListView.prototype.render = oldRender; + }); + + beforeEach(() => { + initWrapperMock.mockClear(); + wrapper = mount(); + instance = wrapper.instance(); + }); + + it('getViewHeight() returns the viewHeight', () => { + expect(instance.getViewHeight()).toBe(clientHeight); + }); + + it('getBottomVisibleIndex() returns a number', () => { + const n = instance.getBottomVisibleIndex(); + expect(isNaN(n)).toBe(false); + expect(n).toEqual(expect.any(Number)); + }); + + it('getTopVisibleIndex() returns a number', () => { + const n = instance.getTopVisibleIndex(); + expect(isNaN(n)).toBe(false); + expect(n).toEqual(expect.any(Number)); + }); + + it('getRowPosition() returns a number', () => { + const { height, y } = instance.getRowPosition(2); + expect(height).toEqual(expect.any(Number)); + expect(y).toEqual(expect.any(Number)); + }); }); describe('windowScroller', () => { let windowAddListenerSpy; + let windowRmListenerSpy; beforeEach(() => { windowAddListenerSpy = jest.spyOn(window, 'addEventListener'); + windowRmListenerSpy = jest.spyOn(window, 'removeEventListener'); const wsProps = { ...props, windowScroller: true }; - wrapper = mount( -
- -
- ); + wrapper = mount(); + instance = wrapper.instance(); }); afterEach(() => { @@ -129,6 +181,12 @@ describe('', () => { expect(windowAddListenerSpy).toHaveBeenLastCalledWith('scroll', instance._onScroll); }); + it('removes the onScroll listener from window when unmounting', () => { + expect(windowRmListenerSpy.mock.calls).toEqual([]); + wrapper.unmount(); + expect(windowRmListenerSpy.mock.calls).toEqual([['scroll', instance._onScroll]]); + }); + it('calls _positionList when the document is scrolled', async () => { const event = new Event('scroll'); const fn = jest.spyOn(instance, '_positionList'); diff --git a/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js b/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js index e58e609b05..4591c3e130 100644 --- a/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js +++ b/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js @@ -65,7 +65,8 @@ type VirtualizedTraceViewProps = { trace: Trace, }; -const DEFAULT_HEIGHTS = { +// export for tests +export const DEFAULT_HEIGHTS = { bar: 21, detail: 169, detailWithLogs: 223, @@ -176,7 +177,7 @@ export class VirtualizedTraceViewImpl extends React.PureComponent', () => { return spans; } + function updateSpan(srcTrace, spanIndex, update) { + const span = { ...srcTrace.spans[spanIndex], ...update }; + const spans = [...srcTrace.spans.slice(0, spanIndex), span, ...srcTrace.spans.slice(spanIndex + 1)]; + return { ...srcTrace, spans }; + } + beforeEach(() => { Object.keys(props).forEach(key => { if (typeof props[key] === 'function') { @@ -86,6 +92,11 @@ describe('', () => { expect(wrapper).toBeDefined(); }); + it('renders when a trace is not set', () => { + wrapper.setProps({ trace: null }); + expect(wrapper).toBeDefined(); + }); + it('renders a ListView', () => { expect(wrapper.find(ListView)).toBeDefined(); }); @@ -272,6 +283,30 @@ describe('', () => { }); }); + describe('getRowHeight()', () => { + it('returns the expected height for non-detail rows', () => { + expect(instance.getRowHeight(0)).toBe(DEFAULT_HEIGHTS.bar); + }); + + it('returns the expected height for detail rows that do not have logs', () => { + expandRow(0); + expect(instance.getRowHeight(1)).toBe(DEFAULT_HEIGHTS.detail); + }); + + it('returns the expected height for detail rows that do have logs', () => { + const logs = [ + { + timestamp: Date.now(), + fields: traceGenerator.tags(), + }, + ]; + const altTrace = updateSpan(trace, 0, { logs }); + expandRow(0); + wrapper.setProps({ trace: altTrace }); + expect(instance.getRowHeight(1)).toBe(DEFAULT_HEIGHTS.detailWithLogs); + }); + }); + describe('renderRow()', () => { it('renders a SpanBarRow when it is not a detail', () => { const span = trace.spans[1]; @@ -301,6 +336,20 @@ describe('', () => { ).toBe(true); }); + it('renders a SpanBarRow with a RPC span if the row is collapsed and a client span', () => { + const clientTags = [{ key: 'span.kind', value: 'client' }, ...trace.spans[0].tags]; + const serverTags = [{ key: 'span.kind', value: 'server' }, ...trace.spans[1].tags]; + let altTrace = updateSpan(trace, 0, { tags: clientTags }); + altTrace = updateSpan(altTrace, 1, { tags: serverTags }); + const childrenHiddenIDs = new Set([altTrace.spans[0].spanID]); + wrapper.setProps({ childrenHiddenIDs, trace: altTrace }); + + const rowWrapper = mount(instance.renderRow('some-key', {}, 0, {})); + const spanBarRow = rowWrapper.find(SpanBarRow); + expect(spanBarRow.length).toBe(1); + expect(spanBarRow.prop('rpc')).toBeDefined(); + }); + it('renders a SpanDetailRow when it is a detail', () => { const detailState = expandRow(1); const span = trace.spans[1]; diff --git a/src/components/TracePage/TraceTimelineViewer/duck.test.js b/src/components/TracePage/TraceTimelineViewer/duck.test.js index 6bbd14215a..b63c488a4d 100644 --- a/src/components/TracePage/TraceTimelineViewer/duck.test.js +++ b/src/components/TracePage/TraceTimelineViewer/duck.test.js @@ -40,6 +40,56 @@ describe('TraceTimelineViewer/duck', () => { expect(state.detailStates).toEqual(new Map()); }); + it('sets the span column width', () => { + const n = 0.5; + let width = store.getState().spanNameColumnWidth; + expect(width).toBeGreaterThanOrEqual(0); + expect(width).toBeLessThanOrEqual(1); + const action = actions.setSpanNameColumnWidth(n); + store.dispatch(action); + width = store.getState().spanNameColumnWidth; + expect(width).toBe(n); + }); + + it('retains all state when setting to the same traceID', () => { + const state = store.getState(); + const action = actions.setTrace(trace.traceID); + store.dispatch(action); + expect(store.getState()).toBe(state); + }); + + it('retains only the spanNameColumnWidth when changing traceIDs', () => { + let action; + const width = 0.5; + const id = 'some-id'; + const { spanID, uniqueText } = searchSetup; + + action = actions.childrenToggle(id); + store.dispatch(action); + action = actions.detailToggle(id); + store.dispatch(action); + action = actions.find(trace, uniqueText); + store.dispatch(action); + action = actions.setSpanNameColumnWidth(width); + store.dispatch(action); + + let state = store.getState(); + expect(state.traceID).toBe(trace.traceID); + expect(state.findMatchesIDs).toEqual(new Set([spanID])); + expect(state.childrenHiddenIDs).not.toEqual(new Set()); + expect(state.detailStates).not.toEqual(new Map()); + expect(state.spanNameColumnWidth).toBe(width); + + action = actions.setTrace(id); + store.dispatch(action); + state = store.getState(); + expect(state.traceID).toBe(id); + expect(state.findMatchesIDs).toBe(null); + expect(state.childrenHiddenIDs).toEqual(new Set()); + expect(state.detailStates).toEqual(new Map()); + expect(state.spanNameColumnWidth).toBe(width); + }); + describe('toggles children and details', () => { const parentID = trace.spans[0].spanID; const tests = [ diff --git a/src/components/TracePage/index.js b/src/components/TracePage/index.js index 794132dae7..9c3b724de7 100644 --- a/src/components/TracePage/index.js +++ b/src/components/TracePage/index.js @@ -20,6 +20,7 @@ import _mapValues from 'lodash/mapValues'; import _maxBy from 'lodash/maxBy'; import _values from 'lodash/values'; import { connect } from 'react-redux'; +import type { Match } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import type { CombokeysHandler, ShortcutCallbacks } from './keyboard-shortcuts'; @@ -51,11 +52,13 @@ type TracePageState = { viewRange: ViewRange, }; -const VIEW_MIN_RANGE = 0.01; +// export for tests +export const VIEW_MIN_RANGE = 0.01; const VIEW_CHANGE_BASE = 0.005; const VIEW_CHANGE_FAST = 0.05; -const shortcutConfig = { +// export for tests +export const shortcutConfig = { panLeft: [-VIEW_CHANGE_BASE, -VIEW_CHANGE_BASE], panLeftFast: [-VIEW_CHANGE_FAST, -VIEW_CHANGE_FAST], panRight: [VIEW_CHANGE_BASE, VIEW_CHANGE_BASE], @@ -66,7 +69,8 @@ const shortcutConfig = { zoomOutFast: [-VIEW_CHANGE_FAST, VIEW_CHANGE_FAST], }; -function makeShortcutCallbacks(adjRange): ShortcutCallbacks { +// export for tests +export function makeShortcutCallbacks(adjRange: (number, number) => void): ShortcutCallbacks { function getHandler([startChange, endChange]): CombokeysHandler { return function combokeyHandler(event: SyntheticKeyboardEvent) { event.preventDefault(); @@ -102,6 +106,7 @@ export default class TracePage extends React.PureComponent { + let adjRange; + + beforeEach(() => { + adjRange = jest.fn(); + }); + + it('has props from `shortcutConfig`', () => { + const callbacks = makeShortcutCallbacks(adjRange); + expect(Object.keys(callbacks)).toEqual(Object.keys(shortcutConfig)); + }); + + it('returns callbacsks that adjust the range based on the `shortcutConfig` values', () => { + const fakeEvent = { preventDefault: () => {} }; + const callbacks = makeShortcutCallbacks(adjRange); + Object.keys(shortcutConfig).forEach((key, i) => { + callbacks[key](fakeEvent); + expect(adjRange).toHaveBeenCalledTimes(i + 1); + expect(adjRange).toHaveBeenLastCalledWith(...shortcutConfig[key]); + }); + }); +}); + describe('', () => { + TraceTimelineViewer.prototype.shouldComponentUpdate.mockReturnValue(false); + const trace = transformTraceData(traceGenerator.trace({})); const defaultProps = { trace, @@ -45,18 +87,22 @@ describe('', () => { }); it('renders an empty page when not provided a trace', () => { - wrapper = shallow(); + wrapper.setProps({ trace: null }); const isEmpty = wrapper.matchesElement(
); expect(isEmpty).toBe(true); }); + it('renders an error message when given an error', () => { + wrapper.setProps({ trace: new Error('some-error') }); + expect(wrapper.find(NotFound).length).toBe(1); + }); + it('renders a loading indicator when loading', () => { - wrapper = shallow(); + wrapper.setProps({ trace: null, loading: true }); const loading = wrapper.find('.loader'); expect(loading.length).toBe(1); }); - // can't do mount tests in standard tape run. it('fetches the trace if necessary', () => { const fetchTrace = sinon.spy(); wrapper = mount(); @@ -66,8 +112,194 @@ describe('', () => { it("doesn't fetch the trace if already present", () => { const fetchTrace = sinon.spy(); - wrapper = shallow(); - wrapper.instance().componentDidMount(); + wrapper = mount(); expect(fetchTrace.called).toBeFalsy(); }); + + it('resets the view range when the trace changes', () => { + const altTrace = { ...trace, traceID: 'some-other-id' }; + // mount because `.componentDidUpdate()` + wrapper = mount(); + wrapper.setState({ viewRange: { time: [0.2, 0.8] } }); + wrapper.setProps({ trace: altTrace }); + expect(wrapper.state('viewRange')).toEqual({ time: { current: [0, 1] } }); + }); + + it('updates _scrollManager when recieving props', () => { + wrapper = shallow(); + const scrollManager = wrapper.instance()._scrollManager; + scrollManager.setTrace = jest.fn(); + wrapper.setProps({ trace }); + expect(scrollManager.setTrace.mock.calls).toEqual([[trace]]); + }); + + it('performs misc cleanup when unmounting', () => { + wrapper = shallow(); + const scrollManager = wrapper.instance()._scrollManager; + scrollManager.destroy = jest.fn(); + wrapper.unmount(); + expect(scrollManager.destroy.mock.calls).toEqual([[]]); + expect(resetShortcuts.mock.calls).toEqual([[]]); + expect(cancelScroll.mock.calls).toEqual([[]]); + }); + + describe('_adjustViewRange()', () => { + let instance; + let time; + let state; + + const cases = [ + { + message: 'stays within the [0, 1] range', + timeViewRange: [0, 1], + change: [-0.1, 0.1], + result: [0, 1], + }, + { + message: 'start does not exceed 0.99', + timeViewRange: [0, 1], + change: [0.991, 0], + result: [0.99, 1], + }, + { + message: 'end remains greater than 0.01', + timeViewRange: [0, 1], + change: [0, -0.991], + result: [0, 0.01], + }, + { + message: `maintains a range of at least ${VIEW_MIN_RANGE} when panning left`, + timeViewRange: [0.495, 0.505], + change: [-0.001, -0.005], + result: [0.494, 0.504], + }, + { + message: `maintains a range of at least ${VIEW_MIN_RANGE} when panning right`, + timeViewRange: [0.495, 0.505], + change: [0.005, 0.001], + result: [0.5, 0.51], + }, + { + message: `maintains a range of at least ${VIEW_MIN_RANGE} when contracting`, + timeViewRange: [0.495, 0.505], + change: [0.1, -0.1], + result: [0.495, 0.505], + }, + ]; + + beforeEach(() => { + wrapper = shallow(); + instance = wrapper.instance(); + time = { current: null }; + state = { viewRange: { time } }; + }); + + cases.forEach(testCase => { + const { message, timeViewRange, change, result } = testCase; + it(message, () => { + time.current = timeViewRange; + wrapper.setState(state); + instance._adjustViewRange(...change); + const { current } = wrapper.state('viewRange').time; + expect(current).toEqual(result); + }); + }); + }); + + describe('manages various UI state', () => { + let header; + let spanGraph; + let timeline; + + beforeEach(() => { + wrapper = mount(); + // use the method directly because it is a `ref` prop + wrapper.instance().setHeaderHeight({ clientHeight: 1 }); + header = wrapper.find(TracePageHeader); + spanGraph = wrapper.find(SpanGraph); + timeline = wrapper.find(TraceTimelineViewer); + }); + + it('propagates headerHeight changes', () => { + const h = 100; + const section = wrapper.find('section[style]'); + const { setHeaderHeight } = wrapper.instance(); + // use the method directly because it is a `ref` prop + setHeaderHeight({ clientHeight: h }); + expect(section.prop('style')).toEqual({ paddingTop: h }); + expect(section.containsMatchingElement()).toBe(true); + setHeaderHeight(null); + expect(section.prop('style')).not.toBeDefined(); + expect(section.containsMatchingElement()).toBe(false); + }); + + it('propagates textFilter changes', () => { + const s = 'abc'; + const { updateTextFilter } = header.props(); + expect(header.prop('textFilter')).toBe(''); + updateTextFilter(s); + expect(header.prop('textFilter')).toBe(s); + expect(timeline.prop('textFilter')).toBe(s); + }); + + it('propagates slimView changes', () => { + const { onSlimViewClicked } = header.props(); + expect(header.prop('slimView')).toBe(false); + expect(spanGraph.type()).toBeDefined(); + onSlimViewClicked(true); + expect(header.prop('slimView')).toBe(true); + expect(spanGraph.type()).not.toBeDefined(); + }); + + it('propagates viewRange changes', () => { + const viewRange = { + time: { current: [0, 1] }, + }; + const cursor = 123; + const current = [0.25, 0.75]; + const { updateViewRangeTime, updateNextViewRangeTime } = spanGraph.props(); + expect(spanGraph.prop('viewRange')).toEqual(viewRange); + expect(timeline.prop('viewRange')).toEqual(viewRange); + updateNextViewRangeTime({ cursor }); + viewRange.time.cursor = cursor; + expect(spanGraph.prop('viewRange')).toEqual(viewRange); + expect(timeline.prop('viewRange')).toEqual(viewRange); + updateViewRangeTime(...current); + viewRange.time = { current }; + expect(spanGraph.prop('viewRange')).toEqual(viewRange); + expect(timeline.prop('viewRange')).toEqual(viewRange); + }); + }); +}); + +describe('mapDispatchToProps()', () => { + it('creates the actions correctly', () => { + expect(mapDispatchToProps(() => {})).toEqual({ fetchTrace: expect.any(Function) }); + }); +}); + +describe('mapStateToProps()', () => { + it('maps state to props correctly', () => { + const id = 'abc'; + const trace = {}; + const state = { + trace: { + loading: false, + traces: { + [id]: trace, + }, + }, + }; + const ownProps = { + match: { + params: { id }, + }, + }; + const props = mapStateToProps(state, ownProps); + expect(props).toEqual({ + id, + trace, + loading: state.trace.loading, + }); + }); }); diff --git a/src/components/TracePage/scroll-page.test.js b/src/components/TracePage/scroll-page.test.js new file mode 100644 index 0000000000..f04f7aebf8 --- /dev/null +++ b/src/components/TracePage/scroll-page.test.js @@ -0,0 +1,159 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// 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 +// +// http://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. + +/* eslint-disable import/first */ +jest.mock('./Tween'); + +import { scrollBy, scrollTo, cancel } from './scroll-page'; +import Tween from './Tween'; + +// keep track of instances, manually +// https://github.com/facebook/jest/issues/5019 +const tweenInstances = []; + +describe('scroll-by', () => { + beforeEach(() => { + window.scrollY = 100; + tweenInstances.length = 0; + Tween.mockClear(); + Tween.mockImplementation(opts => { + const rv = { to: opts.to, onUpdate: opts.onUpdate }; + Object.keys(Tween.prototype).forEach(name => { + if (name !== 'constructor') { + rv[name] = jest.fn(); + } + }); + tweenInstances.push(rv); + return rv; + }); + }); + + afterEach(() => { + cancel(); + }); + + describe('scrollBy()', () => { + describe('when `appendToLast` is `false`', () => { + it('scrolls from `window.scrollY` to `window.scrollY + yDelta`', () => { + const yDelta = 10; + scrollBy(yDelta); + const spec = expect.objectContaining({ to: window.scrollY + yDelta }); + expect(Tween.mock.calls).toEqual([[spec]]); + }); + }); + + describe('when `appendToLast` is true', () => { + it('is the same as `appendToLast === false` without an in-progress scroll', () => { + const yDelta = 10; + scrollBy(yDelta, true); + expect(Tween.mock.calls.length).toBe(1); + scrollBy(yDelta, false); + expect(Tween.mock.calls[0]).toEqual(Tween.mock.calls[1]); + }); + + it('is additive when an in-progress scroll is the same direction', () => { + const yDelta = 10; + const spec = expect.objectContaining({ to: window.scrollY + 2 * yDelta }); + scrollBy(yDelta); + scrollBy(yDelta, true); + expect(Tween.mock.calls.length).toBe(2); + expect(Tween.mock.calls[1]).toEqual([spec]); + }); + + it('ignores the in-progress scroll is the other direction', () => { + const yDelta = 10; + const spec = expect.objectContaining({ to: window.scrollY - yDelta }); + scrollBy(yDelta); + scrollBy(-yDelta, true); + expect(Tween.mock.calls.length).toBe(2); + expect(Tween.mock.calls[1]).toEqual([spec]); + }); + }); + }); + + describe('scrollTo', () => { + it('scrolls to `y`', () => { + const to = 10; + const spec = expect.objectContaining({ to }); + scrollTo(to); + expect(Tween.mock.calls).toEqual([[spec]]); + }); + + it('ignores the in-progress scroll', () => { + const to = 10; + const spec = expect.objectContaining({ to }); + scrollTo(Math.random()); + scrollTo(to); + expect(Tween.mock.calls.length).toBe(2); + expect(Tween.mock.calls[1]).toEqual([spec]); + }); + }); + + describe('cancel', () => { + it('cancels the in-progress scroll', () => { + scrollTo(10); + // there is now an in-progress tween + expect(tweenInstances.length).toBe(1); + const tw = tweenInstances[0]; + cancel(); + expect(tw.cancel.mock.calls).toEqual([[]]); + }); + + it('is a noop if there is not an in-progress scroll', () => { + scrollTo(10); + // there is now an in-progress tween + expect(tweenInstances.length).toBe(1); + const tw = tweenInstances[0]; + cancel(); + expect(tw.cancel.mock.calls).toEqual([[]]); + tw.cancel.mockReset(); + // now, we check to see if `cancel()` has an effect on the last created tween + cancel(); + expect(tw.cancel.mock.calls.length).toBe(0); + }); + }); + + describe('_onTweenUpdate', () => { + let oldScrollTo; + + beforeEach(() => { + oldScrollTo = window.scrollTo; + window.scrollTo = jest.fn(); + }); + + afterEach(() => { + window.scrollTo = oldScrollTo; + }); + + it('scrolls to `value`', () => { + const value = 123; + // cause a `Tween` to be created to get a reference to _onTweenUpdate + scrollTo(10); + const { onUpdate } = tweenInstances[0]; + onUpdate({ value, done: false }); + expect(window.scrollTo.mock.calls.length).toBe(1); + expect(window.scrollTo.mock.calls[0][1]).toBe(value); + }); + + it('discards the in-progress scroll if the scroll is done', () => { + // cause a `Tween` to be created to get a reference to _onTweenUpdate + scrollTo(10); + const { onUpdate, cancel: twCancel } = tweenInstances[0]; + onUpdate({ value: 123, done: true }); + // if the tween is not discarded, `cancel()` will cancel it + cancel(); + expect(twCancel.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/src/utils/DraggableManager/DraggableManager.test.js b/src/utils/DraggableManager/DraggableManager.test.js new file mode 100644 index 0000000000..34ce0affd4 --- /dev/null +++ b/src/utils/DraggableManager/DraggableManager.test.js @@ -0,0 +1,303 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// 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 +// +// http://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 DraggableManager from './DraggableManager'; +import updateTypes from './update-types'; + +describe('DraggableManager', () => { + const baseClientX = 100; + // left button mouse events have `.button === 0` + const baseMouseEvt = { button: 0, clientX: baseClientX }; + const tag = 'some-tag'; + let bounds; + let getBounds; + let ctorOpts; + let instance; + + function startDragging(dragManager) { + dragManager.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(dragManager.isDragging()).toBe(true); + } + + beforeEach(() => { + bounds = { + clientXLeft: 50, + maxValue: 0.9, + minValue: 0.1, + width: 100, + }; + getBounds = jest.fn(() => bounds); + ctorOpts = { + getBounds, + tag, + onMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + onMouseMove: jest.fn(), + onDragStart: jest.fn(), + onDragMove: jest.fn(), + onDragEnd: jest.fn(), + resetBoundsOnResize: false, + }; + instance = new DraggableManager(ctorOpts); + }); + + describe('_getPosition()', () => { + it('invokes the getBounds ctor argument', () => { + instance._getPosition(0); + expect(ctorOpts.getBounds.mock.calls).toEqual([[tag]]); + }); + + it('converts clientX to x and [0, 1] value', () => { + const left = 100; + const pos = instance._getPosition(left); + expect(pos).toEqual({ + x: left - bounds.clientXLeft, + value: (left - bounds.clientXLeft) / bounds.width, + }); + }); + + it('clamps x and [0, 1] value based on getBounds().minValue', () => { + const left = 0; + const pos = instance._getPosition(left); + expect(pos).toEqual({ + x: bounds.minValue * bounds.width, + value: bounds.minValue, + }); + }); + + it('clamps x and [0, 1] value based on getBounds().maxValue', () => { + const left = 10000; + const pos = instance._getPosition(left); + expect(pos).toEqual({ + x: bounds.maxValue * bounds.width, + value: bounds.maxValue, + }); + }); + }); + + describe('window resize event listener', () => { + it('is added in the ctor iff `resetBoundsOnResize` param is truthy', () => { + const oldFn = window.addEventListener; + window.addEventListener = jest.fn(); + + ctorOpts.resetBoundsOnResize = false; + instance = new DraggableManager(ctorOpts); + expect(window.addEventListener.mock.calls).toEqual([]); + ctorOpts.resetBoundsOnResize = true; + instance = new DraggableManager(ctorOpts); + expect(window.addEventListener.mock.calls).toEqual([['resize', expect.any(Function)]]); + + window.addEventListener = oldFn; + }); + + it('is removed in `.dispose()` iff `resetBoundsOnResize` param is truthy', () => { + const oldFn = window.removeEventListener; + window.removeEventListener = jest.fn(); + + ctorOpts.resetBoundsOnResize = false; + instance = new DraggableManager(ctorOpts); + instance.dispose(); + expect(window.removeEventListener.mock.calls).toEqual([]); + ctorOpts.resetBoundsOnResize = true; + instance = new DraggableManager(ctorOpts); + instance.dispose(); + expect(window.removeEventListener.mock.calls).toEqual([['resize', expect.any(Function)]]); + + window.removeEventListener = oldFn; + }); + }); + + describe('minor mouse events', () => { + it('throws an error for invalid event types', () => { + const type = 'invalid-event-type'; + const throwers = [ + () => instance.handleMouseEnter({ ...baseMouseEvt, type }), + () => instance.handleMouseMove({ ...baseMouseEvt, type }), + () => instance.handleMouseLeave({ ...baseMouseEvt, type }), + ]; + throwers.forEach(thrower => expect(thrower).toThrow()); + }); + + it('does nothing if already dragging', () => { + startDragging(instance); + expect(getBounds.mock.calls.length).toBe(1); + + instance.handleMouseEnter({ ...baseMouseEvt, type: 'mouseenter' }); + instance.handleMouseMove({ ...baseMouseEvt, type: 'mousemove' }); + instance.handleMouseLeave({ ...baseMouseEvt, type: 'mouseleave' }); + expect(ctorOpts.onMouseEnter).not.toHaveBeenCalled(); + expect(ctorOpts.onMouseMove).not.toHaveBeenCalled(); + expect(ctorOpts.onMouseLeave).not.toHaveBeenCalled(); + + const evt = { ...baseMouseEvt, type: 'invalid-type' }; + expect(() => instance.handleMouseEnter(evt)).not.toThrow(); + + expect(getBounds.mock.calls.length).toBe(1); + }); + + it('passes data based on the mouse event type to callbacks', () => { + const x = baseClientX - bounds.clientXLeft; + const value = (baseClientX - bounds.clientXLeft) / bounds.width; + const cases = [ + { + type: 'mouseenter', + handler: instance.handleMouseEnter, + callback: ctorOpts.onMouseEnter, + updateType: updateTypes.MOUSE_ENTER, + }, + { + type: 'mousemove', + handler: instance.handleMouseMove, + callback: ctorOpts.onMouseMove, + updateType: updateTypes.MOUSE_MOVE, + }, + { + type: 'mouseleave', + handler: instance.handleMouseLeave, + callback: ctorOpts.onMouseLeave, + updateType: updateTypes.MOUSE_LEAVE, + }, + ]; + + cases.forEach(testCase => { + const { type, handler, callback, updateType } = testCase; + const event = { ...baseMouseEvt, type }; + handler(event); + expect(callback.mock.calls).toEqual([ + [{ event, tag, value, x, manager: instance, type: updateType }], + ]); + }); + }); + }); + + describe('drag events', () => { + let realWindowAddEvent; + let realWindowRmEvent; + + beforeEach(() => { + realWindowAddEvent = window.addEventListener; + realWindowRmEvent = window.removeEventListener; + window.addEventListener = jest.fn(); + window.removeEventListener = jest.fn(); + }); + + afterEach(() => { + window.addEventListener = realWindowAddEvent; + window.removeEventListener = realWindowRmEvent; + }); + + it('throws an error for invalid event types', () => { + expect(() => instance.handleMouseDown({ ...baseMouseEvt, type: 'invalid-event-type' })).toThrow(); + }); + + describe('mousedown', () => { + it('is ignored if already dragging', () => { + startDragging(instance); + window.addEventListener.mockReset(); + ctorOpts.onDragStart.mockReset(); + + expect(getBounds.mock.calls.length).toBe(1); + instance.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(getBounds.mock.calls.length).toBe(1); + + expect(window.addEventListener).not.toHaveBeenCalled(); + expect(ctorOpts.onDragStart).not.toHaveBeenCalled(); + }); + + it('sets `isDragging()` to true', () => { + instance.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(instance.isDragging()).toBe(true); + }); + + it('adds the window mouse listener events', () => { + instance.handleMouseDown({ ...baseMouseEvt, type: 'mousedown' }); + expect(window.addEventListener.mock.calls).toEqual([ + ['mousemove', expect.any(Function)], + ['mouseup', expect.any(Function)], + ]); + }); + }); + + describe('mousemove', () => { + it('is ignored if not already dragging', () => { + instance._handleDragEvent({ ...baseMouseEvt, type: 'mousemove' }); + expect(ctorOpts.onDragMove).not.toHaveBeenCalled(); + startDragging(instance); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mousemove' }); + expect(ctorOpts.onDragMove).toHaveBeenCalled(); + }); + }); + + describe('mouseup', () => { + it('is ignored if not already dragging', () => { + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(ctorOpts.onDragEnd).not.toHaveBeenCalled(); + startDragging(instance); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(ctorOpts.onDragEnd).toHaveBeenCalled(); + }); + + it('sets `isDragging()` to false', () => { + startDragging(instance); + expect(instance.isDragging()).toBe(true); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(instance.isDragging()).toBe(false); + }); + + it('removes the window mouse listener events', () => { + startDragging(instance); + expect(window.removeEventListener).not.toHaveBeenCalled(); + instance._handleDragEvent({ ...baseMouseEvt, type: 'mouseup' }); + expect(window.removeEventListener.mock.calls).toEqual([ + ['mousemove', expect.any(Function)], + ['mouseup', expect.any(Function)], + ]); + }); + }); + + it('passes drag event data to the callbacks', () => { + const x = baseClientX - bounds.clientXLeft; + const value = (baseClientX - bounds.clientXLeft) / bounds.width; + const cases = [ + { + type: 'mousedown', + handler: instance.handleMouseDown, + callback: ctorOpts.onDragStart, + updateType: updateTypes.DRAG_START, + }, + { + type: 'mousemove', + handler: instance._handleDragEvent, + callback: ctorOpts.onDragMove, + updateType: updateTypes.DRAG_MOVE, + }, + { + type: 'mouseup', + handler: instance._handleDragEvent, + callback: ctorOpts.onDragEnd, + updateType: updateTypes.DRAG_END, + }, + ]; + + cases.forEach(testCase => { + const { type, handler, callback, updateType } = testCase; + const event = { ...baseMouseEvt, type }; + handler(event); + expect(callback.mock.calls).toEqual([ + [{ event, tag, value, x, manager: instance, type: updateType }], + ]); + }); + }); + }); +}); diff --git a/src/utils/transform-trace.js b/src/utils/transform-trace.js deleted file mode 100644 index 55bfd5f231..0000000000 --- a/src/utils/transform-trace.js +++ /dev/null @@ -1,98 +0,0 @@ -// @flow - -// Copyright (c) 2017 Uber Technologies, Inc. -// -// 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 -// -// http://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 cytoscape from 'cytoscape'; -import type { Trace, Span } from '../types'; - -/** - * Sorts spans by startTime - */ -export function sortSpansByStartTime(spans: Array): Array { - return spans.sort((a, b) => a.startTime - b.startTime); -} - -export default function transformTrace( - trace: Trace -): { - startTime: number, - duration: number, - spans: any, - depth: number, -} { - const cy = cytoscape({ headless: true }); - - const spans = sortSpansByStartTime(trace.spans); - - // Create tree - const nodes = []; - const edges = []; - spans.forEach(span => { - nodes.push({ - group: 'nodes', - data: { id: span.spanID, data: span }, - }); - if (span.references) { - span.references.forEach(ref => { - if (ref.spanID === span.spanID) { - return; - } - edges.push({ - group: 'edges', - data: { - id: `${ref.spanID}=>${span.spanID}`, - source: ref.spanID, - target: span.spanID, - }, - }); - }); - } - }); - cy.add([...nodes, ...edges]); - - // Find all root nodes (really there should only be 1) - const rootNodes = []; - cy - .nodes() - .roots() - .forEach(root => { - rootNodes.push(root); - }); - const rootNode = rootNodes[0]; - const rootSpan = rootNode.data().data; - - // Add depth and order trace - const sortedSpans = []; - let depth = 0; - cy.elements().dfs({ - root: rootNode, - visit(i, currentDepth, currentNode) { - if (currentDepth > depth) { - depth = currentDepth; - } - sortedSpans.push({ - depth: currentDepth, - ...currentNode.data().data, - }); - }, - }); - - return { - startTime: rootSpan.startTime, - duration: rootSpan.duration, - spans: sortedSpans, - depth, - }; -} From 78cfe880801ac393d2e44a019130375b18fb360c Mon Sep 17 00:00:00 2001 From: Joe Farro Date: Mon, 11 Dec 2017 19:03:06 -0500 Subject: [PATCH 3/3] Enable codecov.io Signed-off-by: Joe Farro --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 248e980d84..6514240380 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,5 +9,5 @@ script: - npm run coverage - npm run build after_success: - - npm install -g coveralls - - cat coverage/lcov.info | coveralls + - npm install -g codecov + - codecov