diff options
Diffstat (limited to 'web/src')
94 files changed, 2592 insertions, 520 deletions
diff --git a/web/src/css/app.less b/web/src/css/app.less index 353e412a..1f246b19 100644 --- a/web/src/css/app.less +++ b/web/src/css/app.less @@ -14,8 +14,8 @@ html { @import (less) "flowtable.less"; @import (less) "flowdetail.less"; @import (less) "flowview.less"; -@import (less) "prompt.less"; @import (less) "eventlog.less"; @import (less) "footer.less"; @import (less) "codemirror.less"; @import (less) "contentview.less"; +@import (less) "modal.less"; diff --git a/web/src/css/modal.less b/web/src/css/modal.less new file mode 100644 index 00000000..8d578f03 --- /dev/null +++ b/web/src/css/modal.less @@ -0,0 +1,13 @@ +.modal-visible { + display: block; +} + + +.modal-dialog { + overflow-y: initial !important; +} + +.modal-body { + max-height: calc(100vh - 20px); + overflow-y: auto; +} diff --git a/web/src/css/prompt.less b/web/src/css/prompt.less deleted file mode 100644 index eee0426d..00000000 --- a/web/src/css/prompt.less +++ /dev/null @@ -1,27 +0,0 @@ -.prompt-dialog { - top: 0; - bottom: 0; - left: 0; - right: 0; - position: fixed; - z-index: 100; - background-color: rgba(0, 0, 0, 0.1); -} - -.prompt-content { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 25px; - padding: 2px 5px; - background-color: white; - box-shadow: 0 -1px 3px lightgray; - - .option { - cursor: pointer; - &:not(:last-child)::after { - content: ", "; - } - } -}
\ No newline at end of file diff --git a/web/src/js/__tests__/components/ContentView/ContentLoaderSpec.js b/web/src/js/__tests__/components/ContentView/ContentLoaderSpec.js new file mode 100644 index 00000000..80b40c72 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/ContentLoaderSpec.js @@ -0,0 +1,74 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import withContentLoader from '../../../components/ContentView/ContentLoader' +import { TFlow } from '../../ducks/tutils' +import TestUtils from 'react-dom/test-utils' +import mockXMLHttpRequest from 'mock-xmlhttprequest' + +global.XMLHttpRequest = mockXMLHttpRequest +class tComponent extends React.Component { + constructor(props, context){ + super(props, context) + } + render() { + return (<p>foo</p>) + } +} + +let tflow = new TFlow(), + ContentLoader = withContentLoader(tComponent) + +describe('ContentLoader Component', () => { + it('should render correctly', () => { + let contentLoader = renderer.create(<ContentLoader flow={tflow} message={tflow.response}/>), + tree = contentLoader.toJSON() + expect(tree).toMatchSnapshot() + }) + + let contentLoader = TestUtils.renderIntoDocument(<ContentLoader flow={tflow} message={tflow.response}/>) + + it('should handle updateContent', () => { + tflow.response.content = 'foo' + contentLoader.updateContent({flow: tflow, message: tflow.response}) + expect(contentLoader.state.request).toEqual(undefined) + expect(contentLoader.state.content).toEqual('foo') + // when content length is 0 or null + tflow.response.contentLength = 0 + tflow.response.content = undefined + contentLoader.updateContent({flow: tflow, message: tflow.response}) + expect(contentLoader.state.request).toEqual(undefined) + expect(contentLoader.state.content).toEqual('') + }) + + it('should handle componentWillReceiveProps', () => { + contentLoader.updateContent = jest.fn() + contentLoader.componentWillReceiveProps({flow: tflow, message: tflow.request}) + expect(contentLoader.updateContent).toBeCalled() + }) + + it('should handle requestComplete', () => { + expect(contentLoader.requestComplete(tflow.request, {})).toEqual(undefined) + // request == this.state.request + contentLoader.state.request = tflow.request + contentLoader.requestComplete(tflow.request, {}) + expect(contentLoader.state.content).toEqual(tflow.request.responseText) + expect(contentLoader.state.request).toEqual(undefined) + }) + + it('should handle requestFailed', () => { + console.error = jest.fn() + expect(contentLoader.requestFailed(tflow.request, {})).toEqual(undefined) + //request == this.state.request + contentLoader.state.request = tflow.request + contentLoader.requestFailed(tflow.request, 'foo error') + expect(contentLoader.state.content).toEqual('Error getting content.') + expect(contentLoader.state.request).toEqual(undefined) + expect(console.error).toBeCalledWith('foo error') + }) + + it('should handle componentWillUnmount', () => { + contentLoader.state.request = { abort : jest.fn() } + contentLoader.componentWillUnmount() + expect(contentLoader.state.request.abort).toBeCalled() + }) +}) diff --git a/web/src/js/__tests__/components/ContentView/ContentViewSpec.js b/web/src/js/__tests__/components/ContentView/ContentViewSpec.js new file mode 100644 index 00000000..f519185d --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/ContentViewSpec.js @@ -0,0 +1,85 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import TestUtils from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { ViewServer, ViewImage, PureViewServer, Edit } from '../../../components/ContentView/ContentViews' +import { TFlow, TStore } from '../../ducks/tutils' +import mockXMLHttpRequest from 'mock-xmlhttprequest' + +global.XMLHttpRequest = mockXMLHttpRequest +let tflow = new TFlow() + +describe('ViewImage Component', () => { + let viewImage = renderer.create(<ViewImage flow={tflow} message={tflow.response}/>), + tree = viewImage.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) +}) + +describe('ViewServer Component', () => { + let store = TStore(), + setContentViewDescFn = jest.fn(), + setContentFn = jest.fn() + + it('should render correctly and connect to state', () => { + let provider = renderer.create( + <Provider store={store}> + <ViewServer + setContentViewDescription={setContentViewDescFn} + setContent={setContentFn} + flow={tflow} + message={tflow.response} + /> + </Provider>), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + + let viewServer = renderer.create( + <PureViewServer + showFullContent={true} + maxLines={10} + setContentViewDescription={setContentViewDescFn} + setContent={setContentViewDescFn} + flow={tflow} + message={tflow.response} + content={JSON.stringify({lines: [['k1', 'v1']]})} + /> + ) + tree = viewServer.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should handle componentWillReceiveProps', () => { + // case of fail to parse content + let viewSever = TestUtils.renderIntoDocument( + <PureViewServer + showFullContent={true} + maxLines={10} + setContentViewDescription={setContentViewDescFn} + setContent={setContentViewDescFn} + flow={tflow} + message={tflow.response} + content={JSON.stringify({lines: [['k1', 'v1']]})} + /> + ) + viewSever.componentWillReceiveProps({...viewSever.props, content: '{foo' }) + let e = '' + try {JSON.parse('{foo') } catch(err){ e = err.message} + expect(viewSever.data).toEqual({ description: e, lines: [] }) + }) +}) + +describe('Edit Component', () => { + it('should render correctly', () => { + let edit = renderer.create(<Edit + content="foo" + onChange={jest.fn} + flow={tflow} + message={tflow.response} + />), + tree = edit.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/ContentView/DownloadContentButtonSpec.js b/web/src/js/__tests__/components/ContentView/DownloadContentButtonSpec.js new file mode 100644 index 00000000..fd00627d --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/DownloadContentButtonSpec.js @@ -0,0 +1,15 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import DownloadContentButton from '../../../components/ContentView/DownloadContentButton' +import { TFlow } from '../../ducks/tutils' + +let tflow = new TFlow() +describe('DownloadContentButton Component', () => { + it('should render correctly', () => { + let downloadContentButton = renderer.create( + <DownloadContentButton flow={tflow} message={tflow.response}/> + ), + tree = downloadContentButton.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/ContentView/MetaViewsSpec.js b/web/src/js/__tests__/components/ContentView/MetaViewsSpec.js new file mode 100644 index 00000000..26b2a27c --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/MetaViewsSpec.js @@ -0,0 +1,37 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import { ContentEmpty, ContentMissing, ContentTooLarge } from '../../../components/ContentView/MetaViews' +import { TFlow } from '../../ducks/tutils' + +let tflow = new TFlow() + +describe('ContentEmpty Components', () => { + it('should render correctly', () => { + let contentEmpty = renderer.create(<ContentEmpty flow={tflow} message={tflow.response}/>), + tree = contentEmpty.toJSON() + expect(tree).toMatchSnapshot() + }) +}) + +describe('ContentMissing Components', () => { + it('should render correctly', () => { + let contentMissing = renderer.create(<ContentMissing flow={tflow} message={tflow.response}/>), + tree = contentMissing.toJSON() + expect(tree).toMatchSnapshot() + }) +}) + +describe('ContentTooLarge Components', () => { + it('should render correctly', () => { + let clickFn = jest.fn(), + uploadContentFn = jest.fn(), + contentTooLarge = renderer.create(<ContentTooLarge + flow={tflow} + message={tflow.response} + onClick={clickFn} + uploadContent={uploadContentFn} + />), + tree = contentTooLarge.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/ContentView/ShowFullContentButtonSpec.js b/web/src/js/__tests__/components/ContentView/ShowFullContentButtonSpec.js new file mode 100644 index 00000000..14871f13 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/ShowFullContentButtonSpec.js @@ -0,0 +1,39 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import { Provider } from 'react-redux' +import ConnectedComponent, { ShowFullContentButton } from '../../../components/ContentView/ShowFullContentButton' +import { TStore } from '../../ducks/tutils' + + +describe('ShowFullContentButton Component', () => { + let setShowFullContentFn = jest.fn(), + showFullContentButton = renderer.create( + <ShowFullContentButton + setShowFullContent={setShowFullContentFn} + showFullContent={false} + visibleLines={10} + contentLines={20} + /> + ), + tree = showFullContentButton.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle click', () => { + tree.children[0].props.onClick() + expect(setShowFullContentFn).toBeCalled() + }) + + it('should connect to state', () => { + let store = TStore(), + provider = renderer.create( + <Provider store={store}> + <ConnectedComponent/> + </Provider> + ), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/ContentView/UploadContentButtonSpec.js b/web/src/js/__tests__/components/ContentView/UploadContentButtonSpec.js new file mode 100644 index 00000000..3695be72 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/UploadContentButtonSpec.js @@ -0,0 +1,12 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import UploadContentButton from '../../../components/ContentView/UploadContentButton' + +describe('UpdateContentButton Component', () => { + it('should render correctly', () => { + let uploadContentFn = jest.fn(), + uploadContentButton = renderer.create(<UploadContentButton uploadContent={uploadContentFn}/>), + tree = uploadContentButton.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/ContentView/ViewSelectorSpec.js b/web/src/js/__tests__/components/ContentView/ViewSelectorSpec.js new file mode 100644 index 00000000..9e87e2f0 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/ViewSelectorSpec.js @@ -0,0 +1,38 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import ConnectedComponent, { ViewSelector } from '../../../components/ContentView/ViewSelector' +import { Provider } from 'react-redux' +import { TStore } from '../../ducks/tutils' + + +describe('ViewSelector Component', () => { + let contentViews = ['Auto', 'Raw', 'Text'], + activeView = 'Auto', + setContentViewFn = jest.fn(), + viewSelector = renderer.create( + <ViewSelector contentViews={contentViews} activeView={activeView} setContentView={setContentViewFn}/> + ), + tree = viewSelector.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle click', () => { + let mockEvent = { preventDefault: jest.fn() }, + tab = tree.children[1].children[0].children[1] + tab.props.onClick(mockEvent) + expect(mockEvent.preventDefault).toBeCalled() + }) + + it('should connect to state', () => { + let store = TStore(), + provider = renderer.create( + <Provider store={store}> + <ConnectedComponent/> + </Provider> + ), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/ContentLoaderSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/ContentLoaderSpec.js.snap new file mode 100644 index 00000000..88d4a380 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/ContentLoaderSpec.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContentLoader Component should render correctly 1`] = ` +<div + className="text-center" +> + <i + className="fa fa-spinner fa-spin" + /> +</div> +`; diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/ContentViewOptionsSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/ContentViewOptionsSpec.js.snap index e3561ec1..57368de2 100644 --- a/web/src/js/__tests__/components/ContentView/__snapshots__/ContentViewOptionsSpec.js.snap +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/ContentViewOptionsSpec.js.snap @@ -13,7 +13,7 @@ exports[`ContentViewOptions Component should render correctly 1`] = `  <a className="btn btn-default btn-xs" - href="/flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content" + href="./flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content.data" title="Download the content of the flow." > <i diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/ContentViewSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/ContentViewSpec.js.snap new file mode 100644 index 00000000..ff651081 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/ContentViewSpec.js.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Edit Component should render correctly 1`] = ` +<div + className="text-center" +> + <i + className="fa fa-spinner fa-spin" + /> +</div> +`; + +exports[`ViewImage Component should render correctly 1`] = ` +<div + className="flowview-image" +> + <img + alt="preview" + className="img-thumbnail" + src="./flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content.data" + /> +</div> +`; + +exports[`ViewServer Component should render correctly and connect to state 1`] = ` +<div + className="text-center" +> + <i + className="fa fa-spinner fa-spin" + /> +</div> +`; + +exports[`ViewServer Component should render correctly and connect to state 2`] = ` +<div> + <pre> + <div> + <span + className="k" + > + 1 + </span> + <span + className="v" + > + 1 + </span> + </div> + </pre> +</div> +`; diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/DownloadContentButtonSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/DownloadContentButtonSpec.js.snap new file mode 100644 index 00000000..76f21ce1 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/DownloadContentButtonSpec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DownloadContentButton Component should render correctly 1`] = ` +<a + className="btn btn-default btn-xs" + href="./flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content.data" + title="Download the content of the flow." +> + <i + className="fa fa-download" + /> +</a> +`; diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/MetaViewsSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/MetaViewsSpec.js.snap new file mode 100644 index 00000000..ad722abf --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/MetaViewsSpec.js.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContentEmpty Components should render correctly 1`] = ` +<div + className="alert alert-info" +> + No + response + content. +</div> +`; + +exports[`ContentMissing Components should render correctly 1`] = ` +<div + className="alert alert-info" +> + Response + content missing. +</div> +`; + +exports[`ContentTooLarge Components should render correctly 1`] = ` +<div> + <div + className="alert alert-warning" + > + <button + className="btn btn-xs btn-warning pull-right" + onClick={[Function]} + > + Display anyway + </button> + 7b + content size. + </div> + <div + className="view-options text-center" + > + <a + className="btn btn-default btn-xs" + href="#" + onClick={[Function]} + title="Upload a file to replace the content." + > + <i + className="fa fa-fw fa-upload" + /> + <input + className="hidden" + onChange={[Function]} + type="file" + /> + </a> +  + <a + className="btn btn-default btn-xs" + href="./flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content.data" + title="Download the content of the flow." + > + <i + className="fa fa-download" + /> + </a> + </div> +</div> +`; diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/ShowFullContentButtonSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/ShowFullContentButtonSpec.js.snap new file mode 100644 index 00000000..e0532154 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/ShowFullContentButtonSpec.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShowFullContentButton Component should connect to state 1`] = `null`; + +exports[`ShowFullContentButton Component should render correctly 1`] = ` +<div> + <div + className="view-all-content-btn btn-xs btn btn-default" + disabled={undefined} + onClick={[Function]} + title={undefined} + > + Show full content + </div> + <span + className="pull-right" + > + + 10 + / + 20 + are visible  + </span> +</div> +`; diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/UploadContentButtonSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/UploadContentButtonSpec.js.snap new file mode 100644 index 00000000..f642d731 --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/UploadContentButtonSpec.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UpdateContentButton Component should render correctly 1`] = ` +<a + className="btn btn-default btn-xs" + href="#" + onClick={[Function]} + title="Upload a file to replace the content." +> + <i + className="fa fa-fw fa-upload" + /> + <input + className="hidden" + onChange={[Function]} + type="file" + /> +</a> +`; diff --git a/web/src/js/__tests__/components/ContentView/__snapshots__/ViewSelectorSpec.js.snap b/web/src/js/__tests__/components/ContentView/__snapshots__/ViewSelectorSpec.js.snap new file mode 100644 index 00000000..481bd56a --- /dev/null +++ b/web/src/js/__tests__/components/ContentView/__snapshots__/ViewSelectorSpec.js.snap @@ -0,0 +1,123 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ViewSelector Component should connect to state 1`] = ` +<div + className="dropup pull-left" +> + <a + className="btn btn-default btn-xs" + href="#" + onClick={[Function]} + > + <span> + + <b> + View: + </b> + + auto + + <span + className="caret" + /> + + </span> + </a> + <ul + className="dropdown-menu" + role="menu" + > + <li> + + <a + href="#" + onClick={[Function]} + > + auto + </a> + + </li> + <li> + + <a + href="#" + onClick={[Function]} + > + raw + </a> + + </li> + <li> + + <a + href="#" + onClick={[Function]} + > + text + </a> + + </li> + </ul> +</div> +`; + +exports[`ViewSelector Component should render correctly 1`] = ` +<div + className="dropup pull-left" +> + <a + className="btn btn-default btn-xs" + href="#" + onClick={[Function]} + > + <span> + + <b> + View: + </b> + + auto + + <span + className="caret" + /> + + </span> + </a> + <ul + className="dropdown-menu" + role="menu" + > + <li> + + <a + href="#" + onClick={[Function]} + > + auto + </a> + + </li> + <li> + + <a + href="#" + onClick={[Function]} + > + raw + </a> + + </li> + <li> + + <a + href="#" + onClick={[Function]} + > + text + </a> + + </li> + </ul> +</div> +`; diff --git a/web/src/js/__tests__/components/ContentViewSpec.js b/web/src/js/__tests__/components/ContentViewSpec.js new file mode 100644 index 00000000..a654870e --- /dev/null +++ b/web/src/js/__tests__/components/ContentViewSpec.js @@ -0,0 +1,62 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import ContentView from '../../components/ContentView' +import { TStore, TFlow } from '../ducks/tutils' +import { Provider } from 'react-redux' +import mockXMLHttpRequest from 'mock-xmlhttprequest' + +global.XMLHttpRequest = mockXMLHttpRequest + +describe('ContentView Component', () => { + let store = TStore() + + it('should render correctly', () => { + store.getState().ui.flow.contentView = 'Edit' + let tflow = TFlow(), + provider = renderer.create( + <Provider store={store}> + <ContentView flow={tflow} message={tflow.request}/> + </Provider>), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should render correctly with empty content', () => { + let tflow = TFlow() + tflow.response.contentLength = 0 + let provider = renderer.create( + <Provider store={store}> + <ContentView flow={tflow} message={tflow.response} readonly={true}/> + </Provider>), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should render correctly with missing content', () => { + let tflow = TFlow() + tflow.response.contentLength = null + let provider = renderer.create( + <Provider store={store}> + <ContentView flow={tflow} message={tflow.response} readonly={true}/> + </Provider>), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should render correctly with content too large', () => { + let tflow = TFlow() + tflow.response.contentLength = 1024 * 1024 * 100 + let provider = renderer.create( + <Provider store={store}> + <ContentView + flow={tflow} + message={tflow.response} + readonly={true} + uploadContent={jest.fn()} + onOpenFile={jest.fn()} + /> + </Provider>), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/EventLogSpec.js b/web/src/js/__tests__/components/EventLogSpec.js new file mode 100644 index 00000000..8510de55 --- /dev/null +++ b/web/src/js/__tests__/components/EventLogSpec.js @@ -0,0 +1,57 @@ +jest.mock('../../components/EventLog/EventList') + +import React from 'react' +import renderer from 'react-test-renderer' +import TestUtils from 'react-dom/test-utils' +import EventLog, { PureEventLog } from '../../components/EventLog' +import { Provider } from 'react-redux' +import { TStore } from '../ducks/tutils' + +window.addEventListener = jest.fn() +window.removeEventListener = jest.fn() + +describe('EventLog Component', () => { + let store = TStore(), + provider = renderer.create( + <Provider store={store}> + <EventLog/> + </Provider>), + tree = provider.toJSON() + + it('should connect to state and render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle toggleFilter', () => { + let debugToggleButton = tree.children[0].children[1].children[0] + debugToggleButton.props.onClick() + }) + + provider = TestUtils.renderIntoDocument( + <Provider store={store}><EventLog/></Provider>) + let eventLog = TestUtils.findRenderedComponentWithType(provider, PureEventLog), + mockEvent = { preventDefault: jest.fn() } + + it('should handle DragStart', () => { + eventLog.onDragStart(mockEvent) + expect(mockEvent.preventDefault).toBeCalled() + expect(window.addEventListener).toBeCalledWith('mousemove', eventLog.onDragMove) + expect(window.addEventListener).toBeCalledWith('mouseup', eventLog.onDragStop) + expect(window.addEventListener).toBeCalledWith('dragend', eventLog.onDragStop) + mockEvent.preventDefault.mockClear() + }) + + it('should handle DragMove', () => { + eventLog.onDragMove(mockEvent) + expect(mockEvent.preventDefault).toBeCalled() + mockEvent.preventDefault.mockClear() + }) + + console.error = jest.fn() // silent the error. + it('should handle DragStop', () => { + eventLog.onDragStop(mockEvent) + expect(mockEvent.preventDefault).toBeCalled() + expect(window.removeEventListener).toBeCalledWith('mousemove', eventLog.onDragMove) + }) + +}) diff --git a/web/src/js/__tests__/components/FlowTableSpec.js b/web/src/js/__tests__/components/FlowTableSpec.js new file mode 100644 index 00000000..f63b28fc --- /dev/null +++ b/web/src/js/__tests__/components/FlowTableSpec.js @@ -0,0 +1,50 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import {PureFlowTable as FlowTable} from '../../components/FlowTable' +import TestUtils from 'react-dom/test-utils' +import { TFlow, TStore } from '../ducks/tutils' +import { Provider } from 'react-redux' + +window.addEventListener = jest.fn() + +describe('FlowTable Component', () => { + let selectFn = jest.fn(), + tflow = TFlow(), + store = TStore() + + it('should render correctly', () => { + let provider = renderer.create( + <Provider store={store}> + <FlowTable onSelect={selectFn} flows={[tflow]}/> + </Provider>), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) + + let provider = TestUtils.renderIntoDocument( + <Provider store={store} > + <FlowTable onSelect={selectFn} flows={[tflow]}/> + </Provider>), + flowTable = TestUtils.findRenderedComponentWithType(provider, FlowTable) + + it('should handle componentWillUnmount', () => { + flowTable.componentWillUnmount() + expect(window.addEventListener).toBeCalledWith('resize', flowTable.onViewportUpdate) + }) + + it('should handle componentDidUpdate', () => { + // flowTable.shouldScrollIntoView == false + expect(flowTable.componentDidUpdate()).toEqual(undefined) + // rowTop - headHeight < viewportTop + flowTable.shouldScrollIntoView = true + flowTable.componentDidUpdate() + // rowBottom > viewportTop + viewportHeight + flowTable.shouldScrollIntoView = true + flowTable.componentDidUpdate() + }) + + it('should handle componentWillReceiveProps', () => { + flowTable.componentWillReceiveProps({selected: tflow}) + expect(flowTable.shouldScrollIntoView).toBeTruthy() + }) +}) diff --git a/web/src/js/__tests__/components/FlowView/ToggleEditSpec.js b/web/src/js/__tests__/components/FlowView/ToggleEditSpec.js index 4578fdc8..ec3a8462 100644 --- a/web/src/js/__tests__/components/FlowView/ToggleEditSpec.js +++ b/web/src/js/__tests__/components/FlowView/ToggleEditSpec.js @@ -6,6 +6,7 @@ import { Provider } from 'react-redux' import { startEdit, stopEdit } from '../../../ducks/ui/flow' import { TFlow, TStore } from '../../ducks/tutils' +global.fetch = jest.fn() let tflow = new TFlow() describe('ToggleEdit Component', () => { @@ -24,7 +25,7 @@ describe('ToggleEdit Component', () => { it('should handle click on stopEdit', () => { tree.children[0].props.onClick() - expect(store.getActions()).toEqual([stopEdit(tflow, true)]) + expect(fetch).toBeCalled() }) it('should handle click on startEdit', () => { diff --git a/web/src/js/__tests__/components/FlowView/__snapshots__/DetailsSpec.js.snap b/web/src/js/__tests__/components/FlowView/__snapshots__/DetailsSpec.js.snap index dcee1895..6e01ae85 100644 --- a/web/src/js/__tests__/components/FlowView/__snapshots__/DetailsSpec.js.snap +++ b/web/src/js/__tests__/components/FlowView/__snapshots__/DetailsSpec.js.snap @@ -12,7 +12,7 @@ exports[`ConnectionInfo Component should render correctly 1`] = ` Address: </td> <td> - address:22 + 127.0.0.1:22 </td> </tr> <tr> @@ -47,7 +47,7 @@ exports[`Details Component should render correctly 1`] = ` Address: </td> <td> - address:22 + 127.0.0.1:22 </td> </tr> <tr> diff --git a/web/src/js/__tests__/components/FlowView/__snapshots__/MessagesSpec.js.snap b/web/src/js/__tests__/components/FlowView/__snapshots__/MessagesSpec.js.snap index c8290e45..cc021f13 100644 --- a/web/src/js/__tests__/components/FlowView/__snapshots__/MessagesSpec.js.snap +++ b/web/src/js/__tests__/components/FlowView/__snapshots__/MessagesSpec.js.snap @@ -265,7 +265,7 @@ exports[`Request Component should render correctly 1`] = `  <a className="btn btn-default btn-xs" - href="/flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/request/content" + href="./flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/request/content.data" title="Download the content of the flow." > <i @@ -528,7 +528,7 @@ exports[`Response Component should render correctly 1`] = `  <a className="btn btn-default btn-xs" - href="/flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content" + href="./flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content.data" title="Download the content of the flow." > <i diff --git a/web/src/js/__tests__/components/Header/FileMenuSpec.js b/web/src/js/__tests__/components/Header/FileMenuSpec.js index 65b4647a..2f4e746d 100644 --- a/web/src/js/__tests__/components/Header/FileMenuSpec.js +++ b/web/src/js/__tests__/components/Header/FileMenuSpec.js @@ -8,13 +8,20 @@ describe('FileMenu Component', () => { let clearFn = jest.fn(), loadFn = jest.fn(), saveFn = jest.fn(), + openModalFn = jest.fn(), mockEvent = { preventDefault: jest.fn(), target: { files: ["foo", "bar "] } }, createNodeMock = () => { return { click: jest.fn() }}, fileMenu = renderer.create( - <FileMenu clearFlows={clearFn} loadFlows={loadFn} saveFlows={saveFn}/>, { createNodeMock }), + <FileMenu + clearFlows={clearFn} + loadFlows={loadFn} + saveFlows={saveFn} + openModal={openModalFn} + />, + { createNodeMock }), tree = fileMenu.toJSON() it('should render correctly', () => { diff --git a/web/src/js/__tests__/components/Header/FlowMenuSpec.js b/web/src/js/__tests__/components/Header/FlowMenuSpec.js index 1278d8ee..65fde213 100644 --- a/web/src/js/__tests__/components/Header/FlowMenuSpec.js +++ b/web/src/js/__tests__/components/Header/FlowMenuSpec.js @@ -7,7 +7,6 @@ import { TFlow, TStore }from '../../ducks/tutils' import { MessageUtils } from "../../../flow/utils" import { Provider } from 'react-redux' - describe('FlowMenu Component', () => { let actions = { resumeFlow: jest.fn(), diff --git a/web/src/js/__tests__/components/Header/OptionMenuSpec.js b/web/src/js/__tests__/components/Header/OptionMenuSpec.js index b84fce6e..980285ef 100644 --- a/web/src/js/__tests__/components/Header/OptionMenuSpec.js +++ b/web/src/js/__tests__/components/Header/OptionMenuSpec.js @@ -4,7 +4,6 @@ import { Provider } from 'react-redux' import OptionMenu from '../../../components/Header/OptionMenu' import { TStore } from '../../ducks/tutils' - describe('OptionMenu Component', () => { it('should render correctly', () => { let store = TStore(), diff --git a/web/src/js/__tests__/components/Header/__snapshots__/FileMenuSpec.js.snap b/web/src/js/__tests__/components/Header/__snapshots__/FileMenuSpec.js.snap index 13ecf3f5..f4018fcd 100644 --- a/web/src/js/__tests__/components/Header/__snapshots__/FileMenuSpec.js.snap +++ b/web/src/js/__tests__/components/Header/__snapshots__/FileMenuSpec.js.snap @@ -66,10 +66,6 @@ exports[`FileMenu Component should render correctly 1`] = ` <hr className="divider" /> - - </li> - <li> - <a href="http://mitm.it/" target="_blank" diff --git a/web/src/js/__tests__/components/Header/__snapshots__/FlowMenuSpec.js.snap b/web/src/js/__tests__/components/Header/__snapshots__/FlowMenuSpec.js.snap index b0b28f1b..2ed3600e 100644 --- a/web/src/js/__tests__/components/Header/__snapshots__/FlowMenuSpec.js.snap +++ b/web/src/js/__tests__/components/Header/__snapshots__/FlowMenuSpec.js.snap @@ -33,7 +33,7 @@ exports[`FlowMenu Component should connect to state 1`] = ` <div className="btn btn-default" disabled={true} - onClick={false} + onClick={undefined} title="revert changes to flow [V]" > <i @@ -92,7 +92,7 @@ exports[`FlowMenu Component should connect to state 1`] = ` <div className="btn btn-default" disabled={true} - onClick={false} + onClick={undefined} title="[a]ccept intercepted flow" > <i @@ -103,7 +103,7 @@ exports[`FlowMenu Component should connect to state 1`] = ` <div className="btn btn-default" disabled={true} - onClick={false} + onClick={undefined} title="kill intercepted flow [x]" > <i diff --git a/web/src/js/__tests__/components/Header/__snapshots__/OptionMenuSpec.js.snap b/web/src/js/__tests__/components/Header/__snapshots__/OptionMenuSpec.js.snap index 9299e69f..83d9355c 100644 --- a/web/src/js/__tests__/components/Header/__snapshots__/OptionMenuSpec.js.snap +++ b/web/src/js/__tests__/components/Header/__snapshots__/OptionMenuSpec.js.snap @@ -9,46 +9,24 @@ exports[`OptionMenu Component should render correctly 1`] = ` className="menu-content" > <div - className="menu-entry" - > - <label> - <input - checked={false} - onChange={[Function]} - type="checkbox" - /> - HTTP/2.0 - </label> - </div> - <div - className="menu-entry" - > - <label> - <input - checked={false} - onChange={[Function]} - type="checkbox" - /> - WebSockets - </label> - </div> - <div - className="menu-entry" + className="btn btn-default" + disabled={undefined} + onClick={[Function]} + title="Open Options" > - <label> - <input - checked={false} - onChange={[Function]} - type="checkbox" - /> - Raw TCP - </label> + <i + className="fa fa-fw fa-cogs text-primary" + /> + Edit Options + <sup> + alpha + </sup> </div> </div> <div className="menu-legend" > - Protocol Support + Options Editor </div> </div> <div @@ -66,7 +44,7 @@ exports[`OptionMenu Component should render correctly 1`] = ` onChange={[Function]} type="checkbox" /> - Disable Caching + Strip cache headers <a href="http://docs.mitmproxy.org/en/stable/features/anticache.html" target="_blank" @@ -86,18 +64,26 @@ exports[`OptionMenu Component should render correctly 1`] = ` onChange={[Function]} type="checkbox" /> - Disable Compression - <i - className="fa fa-question-circle" - title="Do not forward Accept-Encoding headers to the server to force an uncompressed response." + Use host header for display + </label> + </div> + <div + className="menu-entry" + > + <label> + <input + checked={false} + onChange={[Function]} + type="checkbox" /> + Verify server certificates </label> </div> </div> <div className="menu-legend" > - HTTP Options + Quick Options </div> </div> <div @@ -111,22 +97,6 @@ exports[`OptionMenu Component should render correctly 1`] = ` > <label> <input - checked={false} - onChange={[Function]} - type="checkbox" - /> - Use Host Header - <i - className="fa fa-question-circle" - title="Use the Host header to construct URLs for display." - /> - </label> - </div> - <div - className="menu-entry" - > - <label> - <input checked={true} onChange={[Function]} type="checkbox" diff --git a/web/src/js/__tests__/components/Modal/ModalSpec.js b/web/src/js/__tests__/components/Modal/ModalSpec.js new file mode 100644 index 00000000..e4880d08 --- /dev/null +++ b/web/src/js/__tests__/components/Modal/ModalSpec.js @@ -0,0 +1,30 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import Modal from '../../../components/Modal/Modal' +import { Provider } from 'react-redux' +import { TStore } from '../../ducks/tutils' + +describe('Modal Component', () => { + let store = TStore() + + it('should render correctly', () => { + // hide modal by default + let provider = renderer.create( + <Provider store={store}> + <Modal/> + </Provider> + ), + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + + // option modal show up + store.getState().ui.modal.activeModal = 'OptionModal' + provider = renderer.create( + <Provider store={store}> + <Modal/> + </Provider> + ) + tree = provider.toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/web/src/js/__tests__/components/Modal/OptionModalSpec.js b/web/src/js/__tests__/components/Modal/OptionModalSpec.js new file mode 100644 index 00000000..dd4e70a2 --- /dev/null +++ b/web/src/js/__tests__/components/Modal/OptionModalSpec.js @@ -0,0 +1,54 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import { PureOptionDefault } from '../../../components/Modal/OptionModal' + +describe('PureOptionDefault Component', () => { + + it('should return null when the value is default', () => { + let pureOptionDefault = renderer.create( + <PureOptionDefault value="foo" defaultVal="foo"/> + ), + tree = pureOptionDefault.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should handle boolean type', () => { + let pureOptionDefault = renderer.create( + <PureOptionDefault value={true} defaultVal={false}/> + ), + tree = pureOptionDefault.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should handle array', () => { + let a = [""], b = [], c = ['c'], + pureOptionDefault = renderer.create( + <PureOptionDefault value={a} defaultVal={b}/> + ), + tree = pureOptionDefault.toJSON() + expect(tree).toMatchSnapshot() + + pureOptionDefault = renderer.create( + <PureOptionDefault value={a} defaultVal={c}/> + ) + tree = pureOptionDefault.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should handle string', () => { + let pureOptionDefault = renderer.create( + <PureOptionDefault value="foo" defaultVal=""/> + ), + tree = pureOptionDefault.toJSON() + expect(tree).toMatchSnapshot() + }) + + it('should handle null value', () => { + let pureOptionDefault = renderer.create( + <PureOptionDefault value="foo" defaultVal={null}/> + ), + tree = pureOptionDefault.toJSON() + expect(tree).toMatchSnapshot() + }) + +}) diff --git a/web/src/js/__tests__/components/Modal/OptionSpec.js b/web/src/js/__tests__/components/Modal/OptionSpec.js new file mode 100644 index 00000000..a275aee6 --- /dev/null +++ b/web/src/js/__tests__/components/Modal/OptionSpec.js @@ -0,0 +1,99 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import { Options, ChoicesOption } from '../../../components/Modal/Option' + +describe('BooleanOption Component', () => { + let BooleanOption = Options['bool'], + onChangeFn = jest.fn(), + booleanOption = renderer.create( + <BooleanOption value={true} onChange={onChangeFn}/> + ), + tree = booleanOption.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle onChange', () => { + let input = tree.children[0].children[0], + mockEvent = { target: { checked: true }} + input.props.onChange(mockEvent) + expect(onChangeFn).toBeCalledWith(mockEvent.target.checked) + }) +}) + +describe('StringOption Component', () => { + let StringOption = Options['str'], + onChangeFn = jest.fn(), + stringOption = renderer.create( + <StringOption value="foo" onChange={onChangeFn}/> + ), + tree = stringOption.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle onChange', () => { + let mockEvent = { target: { value: 'bar' }} + tree.props.onChange(mockEvent) + expect(onChangeFn).toBeCalledWith(mockEvent.target.value) + }) + +}) + +describe('NumberOption Component', () => { + let NumberOption = Options['int'], + onChangeFn = jest.fn(), + numberOption = renderer.create( + <NumberOption value={1} onChange={onChangeFn}/> + ), + tree = numberOption.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle onChange', () => { + let mockEvent = {target: { value: '2'}} + tree.props.onChange(mockEvent) + expect(onChangeFn).toBeCalledWith(2) + }) +}) + +describe('ChoiceOption Component', () => { + let onChangeFn = jest.fn(), + choiceOption = renderer.create( + <ChoicesOption value='a' choices={['a', 'b', 'c']} onChange={onChangeFn}/> + ), + tree = choiceOption.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle onChange', () => { + let mockEvent = { target: {value: 'b'} } + tree.props.onChange(mockEvent) + expect(onChangeFn).toBeCalledWith(mockEvent.target.value) + }) +}) + +describe('StringOption Component', () => { + let onChangeFn = jest.fn(), + StringSequenceOption = Options['sequence of str'], + stringSequenceOption = renderer.create( + <StringSequenceOption value={['a', 'b']} onChange={onChangeFn}/> + ), + tree = stringSequenceOption.toJSON() + + it('should render correctly', () => { + expect(tree).toMatchSnapshot() + }) + + it('should handle onChange', () => { + let mockEvent = { target: {value: 'a\nb\nc\n'}} + tree.props.onChange(mockEvent) + expect(onChangeFn).toBeCalledWith(['a', 'b', 'c', '']) + }) +}) diff --git a/web/src/js/__tests__/components/Modal/__snapshots__/ModalSpec.js.snap b/web/src/js/__tests__/components/Modal/__snapshots__/ModalSpec.js.snap new file mode 100644 index 00000000..8d9271f1 --- /dev/null +++ b/web/src/js/__tests__/components/Modal/__snapshots__/ModalSpec.js.snap @@ -0,0 +1,255 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal Component should render correctly 1`] = `<div />`; + +exports[`Modal Component should render correctly 2`] = ` +<div> + <div + className="modal-backdrop fade in" + /> + <div + aria-labelledby="options" + className="modal modal-visible" + id="optionsModal" + role="dialog" + tabIndex="-1" + > + <div + className="modal-dialog modal-lg" + role="document" + > + <div + className="modal-content" + > + <div> + <div + className="modal-header" + > + <button + className="close" + data-dismiss="modal" + onClick={[Function]} + type="button" + > + <i + className="fa fa-fw fa-times" + /> + </button> + <div + className="modal-title" + > + <h4> + Options + </h4> + </div> + </div> + <div + className="modal-body" + > + <div + className="form-horizontal" + > + <div + className="form-group" + > + <div + className="col-xs-6" + > + <label + htmlFor="booleanOption" + > + booleanOption + </label> + <div + className="help-block small" + > + foo + </div> + </div> + <div + className="col-xs-6" + > + <div + className="" + > + <div + className="checkbox" + > + <label> + <input + checked={false} + name="booleanOption" + onChange={[Function]} + onKeyDown={[Function]} + type="checkbox" + /> + Enable + </label> + </div> + </div> + </div> + </div> + <div + className="form-group" + > + <div + className="col-xs-6" + > + <label + htmlFor="choiceOption" + > + choiceOption + </label> + <div + className="help-block small" + > + foo + </div> + </div> + <div + className="col-xs-6" + > + <div + className="" + > + <select + className="form-control" + name="choiceOption" + onChange={[Function]} + onKeyDown={[Function]} + value="b" + > + <option + value="a" + > + a + </option> + <option + value="b" + > + b + </option> + <option + value="c" + > + c + </option> + </select> + </div> + <div + className="small" + > + Default: + <strong> + + a + + </strong> + + </div> + </div> + </div> + <div + className="form-group" + > + <div + className="col-xs-6" + > + <label + htmlFor="intOption" + > + intOption + </label> + <div + className="help-block small" + > + foo + </div> + </div> + <div + className="col-xs-6" + > + <div + className="" + > + <input + className="form-control" + name="intOption" + onChange={[Function]} + onKeyDown={[Function]} + type="number" + value={1} + /> + </div> + <div + className="small" + > + Default: + <strong> + + 0 + + </strong> + + </div> + </div> + </div> + <div + className="form-group" + > + <div + className="col-xs-6" + > + <label + htmlFor="strOption" + > + strOption + </label> + <div + className="help-block small" + > + foo + </div> + </div> + <div + className="col-xs-6" + > + <div + className="has-error" + > + <input + className="form-control" + name="strOption" + onChange={[Function]} + onKeyDown={[Function]} + type="text" + value="str content" + /> + </div> + <div + className="small text-danger" + /> + <div + className="small" + > + Default: + <strong> + + null + + </strong> + + </div> + </div> + </div> + </div> + </div> + <div + className="modal-footer" + /> + </div> + </div> + </div> + </div> +</div> +`; diff --git a/web/src/js/__tests__/components/Modal/__snapshots__/OptionModalSpec.js.snap b/web/src/js/__tests__/components/Modal/__snapshots__/OptionModalSpec.js.snap new file mode 100644 index 00000000..68f1c9fc --- /dev/null +++ b/web/src/js/__tests__/components/Modal/__snapshots__/OptionModalSpec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PureOptionDefault Component should handle array 1`] = `null`; + +exports[`PureOptionDefault Component should handle array 2`] = ` +<div + className="small" +> + Default: + <strong> + + [ ] + + </strong> + +</div> +`; + +exports[`PureOptionDefault Component should handle boolean type 1`] = ` +<div + className="small" +> + Default: + <strong> + + false + + </strong> + +</div> +`; + +exports[`PureOptionDefault Component should handle null value 1`] = ` +<div + className="small" +> + Default: + <strong> + + null + + </strong> + +</div> +`; + +exports[`PureOptionDefault Component should handle string 1`] = ` +<div + className="small" +> + Default: + <strong> + + "" + + </strong> + +</div> +`; + +exports[`PureOptionDefault Component should return null when the value is default 1`] = `null`; diff --git a/web/src/js/__tests__/components/Modal/__snapshots__/OptionSpec.js.snap b/web/src/js/__tests__/components/Modal/__snapshots__/OptionSpec.js.snap new file mode 100644 index 00000000..257bddce --- /dev/null +++ b/web/src/js/__tests__/components/Modal/__snapshots__/OptionSpec.js.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BooleanOption Component should render correctly 1`] = ` +<div + className="checkbox" +> + <label> + <input + checked={true} + onChange={[Function]} + type="checkbox" + /> + Enable + </label> +</div> +`; + +exports[`ChoiceOption Component should render correctly 1`] = ` +<select + onChange={[Function]} + value="a" +> + <option + value="a" + > + a + </option> + <option + value="b" + > + b + </option> + <option + value="c" + > + c + </option> +</select> +`; + +exports[`NumberOption Component should render correctly 1`] = ` +<input + onChange={[Function]} + type="number" + value={1} +/> +`; + +exports[`StringOption Component should render correctly 1`] = ` +<input + onChange={[Function]} + type="text" + value="foo" +/> +`; + +exports[`StringOption Component should render correctly 2`] = ` +<textarea + onChange={[Function]} + rows={2} + value="a +b" +/> +`; diff --git a/web/src/js/__tests__/components/__snapshots__/ContentViewSpec.js.snap b/web/src/js/__tests__/components/__snapshots__/ContentViewSpec.js.snap new file mode 100644 index 00000000..1ee71d52 --- /dev/null +++ b/web/src/js/__tests__/components/__snapshots__/ContentViewSpec.js.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContentView Component should render correctly 1`] = ` +<div + className="contentview" +> + <div + className="text-center" + > + <i + className="fa fa-spinner fa-spin" + /> + </div> +</div> +`; + +exports[`ContentView Component should render correctly with content too large 1`] = ` +<div> + <div + className="alert alert-warning" + > + <button + className="btn btn-xs btn-warning pull-right" + onClick={[Function]} + > + Display anyway + </button> + 100mb + content size. + </div> + <div + className="view-options text-center" + > + <a + className="btn btn-default btn-xs" + href="#" + onClick={[Function]} + title="Upload a file to replace the content." + > + <i + className="fa fa-fw fa-upload" + /> + <input + className="hidden" + onChange={[Function]} + type="file" + /> + </a> +  + <a + className="btn btn-default btn-xs" + href="./flows/d91165be-ca1f-4612-88a9-c0f8696f3e29/response/content.data" + title="Download the content of the flow." + > + <i + className="fa fa-download" + /> + </a> + </div> +</div> +`; + +exports[`ContentView Component should render correctly with empty content 1`] = ` +<div + className="alert alert-info" +> + No + response + content. +</div> +`; + +exports[`ContentView Component should render correctly with missing content 1`] = ` +<div + className="alert alert-info" +> + Response + content missing. +</div> +`; diff --git a/web/src/js/__tests__/components/__snapshots__/EventLogSpec.js.snap b/web/src/js/__tests__/components/__snapshots__/EventLogSpec.js.snap new file mode 100644 index 00000000..11c3a29e --- /dev/null +++ b/web/src/js/__tests__/components/__snapshots__/EventLogSpec.js.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EventLog Component should connect to state and render correctly 1`] = ` +<div + className="eventlog" + style={ + Object { + "height": 200, + } + } +> + <div + onMouseDown={[Function]} + > + Eventlog + <div + className="pull-right" + > + <div + className="btn btn-toggle btn-primary" + onClick={[Function]} + > + <i + className="fa fa-fw fa-check-square-o" + /> +  + debug + </div> + <div + className="btn btn-toggle btn-primary" + onClick={[Function]} + > + <i + className="fa fa-fw fa-check-square-o" + /> +  + info + </div> + <div + className="btn btn-toggle btn-default" + onClick={[Function]} + > + <i + className="fa fa-fw fa-square-o" + /> +  + web + </div> + <div + className="btn btn-toggle btn-primary" + onClick={[Function]} + > + <i + className="fa fa-fw fa-check-square-o" + /> +  + warn + </div> + <div + className="btn btn-toggle btn-primary" + onClick={[Function]} + > + <i + className="fa fa-fw fa-check-square-o" + /> +  + error + </div> + <i + className="fa fa-close" + onClick={[Function]} + /> + </div> + </div> +</div> +`; diff --git a/web/src/js/__tests__/components/__snapshots__/FlowTableSpec.js.snap b/web/src/js/__tests__/components/__snapshots__/FlowTableSpec.js.snap new file mode 100644 index 00000000..7149903c --- /dev/null +++ b/web/src/js/__tests__/components/__snapshots__/FlowTableSpec.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FlowTable Component should render correctly 1`] = ` +<div + className="flow-table" + onScroll={[Function]} +> + <table> + <thead + style={ + Object { + "transform": "translateY(undefinedpx)", + } + } + > + <tr> + <th + className="col-tls" + onClick={[Function]} + > + + </th> + <th + className="col-icon" + onClick={[Function]} + > + + </th> + <th + className="col-path sort-desc" + onClick={[Function]} + > + Path + </th> + <th + className="col-method" + onClick={[Function]} + > + Method + </th> + <th + className="col-status" + onClick={[Function]} + > + Status + </th> + <th + className="col-size" + onClick={[Function]} + > + Size + </th> + <th + className="col-time" + onClick={[Function]} + > + Time + </th> + </tr> + </thead> + <tbody> + <tr + style={ + Object { + "height": 0, + } + } + /> + <tr + style={ + Object { + "height": 0, + } + } + /> + </tbody> + </table> +</div> +`; diff --git a/web/src/js/__tests__/components/common/__snapshots__/ButtonSpec.js.snap b/web/src/js/__tests__/components/common/__snapshots__/ButtonSpec.js.snap index 1d403b2d..8b1c9d6d 100644 --- a/web/src/js/__tests__/components/common/__snapshots__/ButtonSpec.js.snap +++ b/web/src/js/__tests__/components/common/__snapshots__/ButtonSpec.js.snap @@ -4,7 +4,7 @@ exports[`Button Component should be able to be disabled 1`] = ` <div className="classname btn btn-default" disabled="true" - onClick={false} + onClick={undefined} title={undefined} > <a> diff --git a/web/src/js/__tests__/ducks/_tflow.js b/web/src/js/__tests__/ducks/_tflow.js index f6a382bd..44b32342 100644 --- a/web/src/js/__tests__/ducks/_tflow.js +++ b/web/src/js/__tests__/ducks/_tflow.js @@ -2,7 +2,7 @@ export default function(){ return { "client_conn": { "address": [ - "address", + "127.0.0.1", 22 ], "alpn_proto_negotiated": "http/1.1", diff --git a/web/src/js/__tests__/ducks/flowsSpec.js b/web/src/js/__tests__/ducks/flowsSpec.js index 5bd866f2..d749d9e1 100644 --- a/web/src/js/__tests__/ducks/flowsSpec.js +++ b/web/src/js/__tests__/ducks/flowsSpec.js @@ -157,7 +157,9 @@ describe('flows actions', () => { file = new window.Blob(['foo'], { type: 'plain/text' }) body.append('file', file) store.dispatch(flowActions.uploadContent(tflow, 'foo', 'foo')) - expect(fetchApi).toBeCalledWith('/flows/1/foo/content', { method: 'POST', body}) + // window.Blob's lastModified is always the current time, + // which causes flaky tests on comparison. + expect(fetchApi).toBeCalledWith('/flows/1/foo/content', { method: 'POST', body: expect.anything()}) }) it('should handle clear action', () => { diff --git a/web/src/js/__tests__/ducks/optionsSpec.js b/web/src/js/__tests__/ducks/optionsSpec.js new file mode 100644 index 00000000..d3f9b8e5 --- /dev/null +++ b/web/src/js/__tests__/ducks/optionsSpec.js @@ -0,0 +1,66 @@ +import reduceOptions, * as OptionsActions from '../../ducks/options' +import configureStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import * as OptionsEditorActions from '../../ducks/ui/optionsEditor' + +const mockStore = configureStore([ thunk ]) + +describe('option reducer', () => { + it('should return initial state', () => { + expect(reduceOptions(undefined, {})).toEqual({}) + }) + + it('should handle receive action', () => { + let action = { type: OptionsActions.RECEIVE, data: 'foo' } + expect(reduceOptions(undefined, action)).toEqual('foo') + }) + + it('should handle update action', () => { + let action = {type: OptionsActions.UPDATE, data: {id: 1} } + expect(reduceOptions(undefined, action)).toEqual({id: 1}) + }) +}) + +let store = mockStore() + +describe('option actions', () => { + + it('should be possible to update option', () => { + let mockResponse = { status: 200 }, + promise = Promise.resolve(mockResponse) + global.fetch = r => { return promise } + store.dispatch(OptionsActions.update('foo', 'bar')) + expect(store.getActions()).toEqual([ + { type: OptionsEditorActions.OPTION_UPDATE_START, option: 'foo', value: 'bar'} + ]) + store.clearActions() + }) +}) + +describe('sendUpdate', () => { + + it('should handle error', () => { + let mockResponse = { status: 400, text: p => Promise.resolve('error') }, + promise = Promise.resolve(mockResponse) + global.fetch = r => { return promise } + OptionsActions.pureSendUpdate('bar', 'error') + expect(store.getActions()).toEqual([ + { type: OptionsEditorActions.OPTION_UPDATE_SUCCESS, option: 'foo'} + ]) + }) +}) + +describe('save', () => { + + it('should dump options', () => { + global.fetch = jest.fn() + store.dispatch(OptionsActions.save()) + expect(fetch).toBeCalledWith( + './options/save?_xsrf=undefined', + { + credentials: "same-origin", + method: "POST" + } + ) + }) +}) diff --git a/web/src/js/__tests__/ducks/tutils.js b/web/src/js/__tests__/ducks/tutils.js index 2a79ede0..22240448 100644 --- a/web/src/js/__tests__/ducks/tutils.js +++ b/web/src/js/__tests__/ducks/tutils.js @@ -32,6 +32,15 @@ export function TStore(){ }, header: { tab: 'Start' + }, + modal: { + activeModal: undefined + }, + optionsEditor: { + booleanOption: { isUpdating: true, error: false }, + strOption: { error: true }, + intOption: {}, + choiceOption: {}, } }, settings: { @@ -39,6 +48,36 @@ export function TStore(){ anticache: true, anticomp: false }, + options: { + booleanOption: { + choices: null, + default: false, + help: "foo", + type: "bool", + value: false + }, + strOption: { + choices: null, + default: null, + help: "foo", + type: "str", + value: "str content" + }, + intOption: { + choices: null, + default: 0, + help: "foo", + type: "int", + value: 1 + }, + choiceOption: { + choices: ['a', 'b', 'c'], + default: 'a', + help: "foo", + type: "str", + value: "b" + }, + }, flows: { selected: ["d91165be-ca1f-4612-88a9-c0f8696f3e29"], byId: {"d91165be-ca1f-4612-88a9-c0f8696f3e29": tflow}, @@ -47,7 +86,8 @@ export function TStore(){ sort: { desc: true, column: 'PathColumn' - } + }, + view: [ tflow ] }, connection: { state: ConnectionState.ESTABLISHED diff --git a/web/src/js/__tests__/ducks/ui/flowSpec.js b/web/src/js/__tests__/ducks/ui/flowSpec.js index cd6ffa2f..11ca021e 100644 --- a/web/src/js/__tests__/ducks/ui/flowSpec.js +++ b/web/src/js/__tests__/ducks/ui/flowSpec.js @@ -11,7 +11,7 @@ import reducer, { displayLarge } from '../../../ducks/ui/flow' -import { select, updateFlow } from '../../../ducks/flows' +import * as flowActions from '../../../ducks/flows' describe('flow reducer', () => { it('should return initial state', () => { @@ -61,11 +61,11 @@ describe('flow reducer', () => { }) it('should not change the contentview mode', () => { - expect(reducer({contentView: 'foo'}, select(1)).contentView).toEqual('foo') + expect(reducer({contentView: 'foo'}, flowActions.select(1)).contentView).toEqual('foo') }) it('should change the contentview mode to auto after editing when a new flow will be selected', () => { - expect(reducer({contentView: 'foo', modifiedFlow : 'test_flow'}, select(1)).contentView).toEqual('Auto') + expect(reducer({contentView: 'foo', modifiedFlow : 'test_flow'}, flowActions.select(1)).contentView).toEqual('Auto') }) it('should set update and merge the modifiedflow with the update values', () => { @@ -84,7 +84,11 @@ describe('flow reducer', () => { it('should stop editing when the selected flow is updated', () => { let modifiedFlow = {id: 1} let updatedFlow = {id: 1} - expect(reducer({modifiedFlow}, stopEdit(updatedFlow, modifiedFlow)).modifiedFlow).toBeFalsy() + expect(reducer( + { modifiedFlow }, + {type: flowActions.UPDATE, data: modifiedFlow} + ).modifiedFlow + ).toBeFalsy() }) it('should set content view', () => { diff --git a/web/src/js/__tests__/ducks/ui/keyboardSpec.js b/web/src/js/__tests__/ducks/ui/keyboardSpec.js index 500733cb..cf17943f 100644 --- a/web/src/js/__tests__/ducks/ui/keyboardSpec.js +++ b/web/src/js/__tests__/ducks/ui/keyboardSpec.js @@ -6,6 +6,7 @@ import reduceFlows from '../../../ducks/flows' import reduceUI from '../../../ducks/ui/index' import * as flowsActions from '../../../ducks/flows' import * as UIActions from '../../../ducks/ui/flow' +import * as modalActions from '../../../ducks/ui/modal' import configureStore from 'redux-mock-store' import thunk from 'redux-thunk' import { fetchApi } from '../../../utils' @@ -154,4 +155,10 @@ describe('onKeyDown', () => { expect(fetchApi).not.toBeCalled() }) + it('should close modal', () => { + store.getState().ui.modal.activeModal = true + store.dispatch(createKeyEvent(Key.ESC)) + expect(store.getActions()).toEqual([ {type: modalActions.HIDE_MODAL} ]) + }) + }) diff --git a/web/src/js/__tests__/ducks/ui/modalSpec.js b/web/src/js/__tests__/ducks/ui/modalSpec.js new file mode 100644 index 00000000..30c39760 --- /dev/null +++ b/web/src/js/__tests__/ducks/ui/modalSpec.js @@ -0,0 +1,25 @@ +import reduceModal, * as ModalActions from '../../../ducks/ui/modal' + +describe('modal reducer', () => { + let state = undefined + + it('should return the initial state', () => { + expect(reduceModal(undefined, {})).toEqual( + { activeModal: undefined } + ) + }) + + it('should handle setActiveModal action', () => { + state = reduceModal(undefined, ModalActions.setActiveModal('foo')) + expect(state).toEqual( + { activeModal: 'foo' } + ) + }) + + it('should handle hideModal action', () => { + state = reduceModal(state, ModalActions.hideModal()) + expect(state).toEqual( + { activeModal: undefined } + ) + }) +}) diff --git a/web/src/js/__tests__/ducks/ui/optionEditorSpec.js b/web/src/js/__tests__/ducks/ui/optionEditorSpec.js new file mode 100644 index 00000000..df9161a4 --- /dev/null +++ b/web/src/js/__tests__/ducks/ui/optionEditorSpec.js @@ -0,0 +1,32 @@ +import reduceOptionsEditor, * as optionsEditorActions from '../../../ducks/ui/optionsEditor' +import { HIDE_MODAL } from '../../../ducks/ui/modal' + +describe('optionsEditor reducer', () => { + + it('should return initial state', () => { + expect(reduceOptionsEditor(undefined, {})).toEqual({}) + }) + + let state = undefined + it('should handle option update start', () => { + state = reduceOptionsEditor(undefined, optionsEditorActions.startUpdate('foo', 'bar')) + expect(state).toEqual({ foo: {error: false, isUpdating: true, value: 'bar'}}) + }) + + it('should handle option update success', () => { + expect(reduceOptionsEditor(state, optionsEditorActions.updateSuccess('foo'))).toEqual({foo: undefined}) + }) + + it('should handle option update error', () => { + state = reduceOptionsEditor(state, optionsEditorActions.updateError('foo', 'errorMsg')) + expect(state).toEqual({ foo: {error: 'errorMsg', isUpdating: false, value: 'bar'}}) + // boolean type + state = reduceOptionsEditor(undefined, optionsEditorActions.startUpdate('foo', true)) + state = reduceOptionsEditor(state, optionsEditorActions.updateError('foo', 'errorMsg')) + expect(state).toEqual({ foo: {error: 'errorMsg', isUpdating: false, value: false}}) + }) + + it('should handle hide modal', () => { + expect(reduceOptionsEditor(undefined, {type: HIDE_MODAL})).toEqual({}) + }) +}) diff --git a/web/src/js/__tests__/flow/utilsSpec.js b/web/src/js/__tests__/flow/utilsSpec.js index 2d8f0456..dc84001e 100644 --- a/web/src/js/__tests__/flow/utilsSpec.js +++ b/web/src/js/__tests__/flow/utilsSpec.js @@ -25,15 +25,15 @@ describe('MessageUtils', () => { let msg = "foo", view = "bar", flow = { request: msg, id: 1} expect(utils.MessageUtils.getContentURL(flow, msg, view)).toEqual( - "/flows/1/request/content/bar" + "./flows/1/request/content/bar.json" ) expect(utils.MessageUtils.getContentURL(flow, msg, '')).toEqual( - "/flows/1/request/content" + "./flows/1/request/content.data" ) // response flow = {response: msg, id: 2} expect(utils.MessageUtils.getContentURL(flow, msg, view)).toEqual( - "/flows/2/response/content/bar" + "./flows/2/response/content/bar.json" ) }) }) diff --git a/web/src/js/__tests__/utilsSpec.js b/web/src/js/__tests__/utilsSpec.js index 9a1a0750..d0beca14 100644 --- a/web/src/js/__tests__/utilsSpec.js +++ b/web/src/js/__tests__/utilsSpec.js @@ -83,11 +83,6 @@ describe('pure', () => { expect(utils.pure(tFunc).displayName).toEqual('tFunc') }) - it('should suggest when should component update', () => { - expect(f.shouldComponentUpdate('foo')).toBeTruthy() - expect(f.shouldComponentUpdate('bar')).toBeFalsy() - }) - it('should render properties', () => { expect(f.render()).toEqual(tFunc('bar')) }) diff --git a/web/src/js/app.jsx b/web/src/js/app.jsx index a94d2ef6..ee660fd6 100644 --- a/web/src/js/app.jsx +++ b/web/src/js/app.jsx @@ -9,13 +9,14 @@ import rootReducer from './ducks/index' import { add as addLog } from './ducks/eventLog' import useUrlState from './urlState' import WebSocketBackend from './backends/websocket' +import StaticBackend from './backends/static' +import { logger } from 'redux-logger' const middlewares = [thunk]; if (process.env.NODE_ENV !== 'production') { - const createLogger = require('redux-logger'); - middlewares.push(createLogger()); + middlewares.push(logger); } // logger must be last @@ -25,7 +26,11 @@ const store = createStore( ) useUrlState(store) -window.backend = new WebSocketBackend(store) +if (MITMWEB_STATIC) { + window.backend = new StaticBackend(store) +} else { + window.backend = new WebSocketBackend(store) +} window.addEventListener('error', msg => { store.dispatch(addLog(msg)) diff --git a/web/src/js/backends/static.js b/web/src/js/backends/static.js new file mode 100644 index 00000000..1da222ce --- /dev/null +++ b/web/src/js/backends/static.js @@ -0,0 +1,32 @@ +/* + * This backend uses the REST API only to host static instances, + * without any Websocket connection. + */ +import { fetchApi } from "../utils" + +export default class StaticBackend { + constructor(store) { + this.store = store + this.onOpen() + } + + onOpen() { + this.fetchData("flows") + this.fetchData("settings") + // this.fetchData("events") # TODO: Add events log to static viewer. + } + + fetchData(resource) { + fetchApi(`./${resource}`) + .then(res => res.json()) + .then(json => { + this.receive(resource, json) + }) + } + + receive(resource, data) { + let type = `${resource}_RECEIVE`.toUpperCase() + this.store.dispatch({ type, cmd: "receive", resource, data }) + } + +} diff --git a/web/src/js/backends/websocket.js b/web/src/js/backends/websocket.js index 01094ac4..08136617 100644 --- a/web/src/js/backends/websocket.js +++ b/web/src/js/backends/websocket.js @@ -27,13 +27,14 @@ export default class WebsocketBackend { this.fetchData("settings") this.fetchData("flows") this.fetchData("events") + this.fetchData("options") this.store.dispatch(connectionActions.startFetching()) } fetchData(resource) { let queue = [] this.activeFetches[resource] = queue - fetchApi(`/${resource}`) + fetchApi(`./${resource}`) .then(res => res.json()) .then(json => { // Make sure that we are not superseded yet by the server sending a RESET. diff --git a/web/src/js/components/ContentView.jsx b/web/src/js/components/ContentView.jsx index a79bf9e5..cb4749c5 100644 --- a/web/src/js/components/ContentView.jsx +++ b/web/src/js/components/ContentView.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import * as ContentViews from './ContentView/ContentViews' +import { Edit, ViewServer, ViewImage } from './ContentView/ContentViews' import * as MetaViews from './ContentView/MetaViews' import ShowFullContentButton from './ContentView/ShowFullContentButton' @@ -16,7 +16,7 @@ ContentView.propTypes = { message: PropTypes.object.isRequired, } -ContentView.isContentTooLarge = msg => msg.contentLength > 1024 * 1024 * (ContentViews.ViewImage.matches(msg) ? 10 : 0.2) +ContentView.isContentTooLarge = msg => msg.contentLength > 1024 * 1024 * (ViewImage.matches(msg) ? 10 : 0.2) function ContentView(props) { const { flow, message, contentView, isDisplayLarge, displayLarge, onContentChange, readonly } = props @@ -33,10 +33,15 @@ function ContentView(props) { return <MetaViews.ContentTooLarge {...props} onClick={displayLarge}/> } - const View = ContentViews[contentView] || ContentViews['ViewServer'] + let view; + if(contentView === "Edit") { + view = <Edit flow={flow} message={message} onChange={onContentChange}/> + } else { + view = <ViewServer flow={flow} message={message} contentView={contentView}/> + } return ( <div className="contentview"> - <View flow={flow} message={message} contentView={contentView} readonly={readonly} onChange={onContentChange}/> + {view} <ShowFullContentButton/> </div> ) diff --git a/web/src/js/components/ContentView/ContentLoader.jsx b/web/src/js/components/ContentView/ContentLoader.jsx index 4cafde28..44716e12 100644 --- a/web/src/js/components/ContentView/ContentLoader.jsx +++ b/web/src/js/components/ContentView/ContentLoader.jsx @@ -2,98 +2,100 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { MessageUtils } from '../../flow/utils.js' -export default View => class extends React.Component { +export default function withContentLoader(View) { + + return class extends React.Component { + static displayName = View.displayName || View.name + static matches = View.matches - static displayName = View.displayName || View.name - static matches = View.matches - - static propTypes = { - ...View.propTypes, - content: PropTypes.string, // mark as non-required - flow: PropTypes.object.isRequired, - message: PropTypes.object.isRequired, - } - - constructor(props) { - super(props) - this.state = { - content: undefined, - request: undefined, + static propTypes = { + ...View.propTypes, + content: PropTypes.string, // mark as non-required + flow: PropTypes.object.isRequired, + message: PropTypes.object.isRequired, } - } - componentWillMount() { - this.updateContent(this.props) - } - - componentWillReceiveProps(nextProps) { - if ( - nextProps.message.content !== this.props.message.content || - nextProps.message.contentHash !== this.props.message.contentHash || - nextProps.contentView !== this.props.contentView - ) { - this.updateContent(nextProps) + constructor(props) { + super(props) + this.state = { + content: undefined, + request: undefined, + } } - } - componentWillUnmount() { - if (this.state.request) { - this.state.request.abort() + componentWillMount() { + this.updateContent(this.props) } - } - updateContent(props) { - if (this.state.request) { - this.state.request.abort() + componentWillReceiveProps(nextProps) { + if ( + nextProps.message.content !== this.props.message.content || + nextProps.message.contentHash !== this.props.message.contentHash || + nextProps.contentView !== this.props.contentView + ) { + this.updateContent(nextProps) + } } - // We have a few special cases where we do not need to make an HTTP request. - if(props.message.content !== undefined) { - return this.setState({request: undefined, content: props.message.content}) - } - if(props.message.contentLength === 0 || props.message.contentLength === null){ - return this.setState({request: undefined, content: ""}) + + componentWillUnmount() { + if (this.state.request) { + this.state.request.abort() + } } - let requestUrl = MessageUtils.getContentURL(props.flow, props.message, (View.name == 'ViewServer' ? props.contentView : undefined)) + updateContent(props) { + if (this.state.request) { + this.state.request.abort() + } + // We have a few special cases where we do not need to make an HTTP request. + if (props.message.content !== undefined) { + return this.setState({request: undefined, content: props.message.content}) + } + if (props.message.contentLength === 0 || props.message.contentLength === null) { + return this.setState({request: undefined, content: ""}) + } - // We use XMLHttpRequest instead of fetch() because fetch() is not (yet) abortable. - let request = new XMLHttpRequest(); - request.addEventListener("load", this.requestComplete.bind(this, request)); - request.addEventListener("error", this.requestFailed.bind(this, request)); - request.open("GET", requestUrl); - request.send(); - this.setState({ request, content: undefined }) - } + let requestUrl = MessageUtils.getContentURL(props.flow, props.message, props.contentView) - requestComplete(request, e) { - if (request !== this.state.request) { - return // Stale request + // We use XMLHttpRequest instead of fetch() because fetch() is not (yet) abortable. + let request = new XMLHttpRequest(); + request.addEventListener("load", this.requestComplete.bind(this, request)); + request.addEventListener("error", this.requestFailed.bind(this, request)); + request.open("GET", requestUrl); + request.send(); + this.setState({request, content: undefined}) } - this.setState({ - content: request.responseText, - request: undefined - }) - } - requestFailed(request, e) { - if (request !== this.state.request) { - return // Stale request + requestComplete(request, e) { + if (request !== this.state.request) { + return // Stale request + } + this.setState({ + content: request.responseText, + request: undefined + }) + } + + requestFailed(request, e) { + if (request !== this.state.request) { + return // Stale request + } + console.error(e) + // FIXME: Better error handling + this.setState({ + content: "Error getting content.", + request: undefined + }) } - console.error(e) - // FIXME: Better error handling - this.setState({ - content: "Error getting content.", - request: undefined - }) - } - render() { - return this.state.content !== undefined ? ( - <View content={this.state.content} {...this.props}/> - ) : ( - <div className="text-center"> - <i className="fa fa-spinner fa-spin"></i> - </div> - ) + render() { + return this.state.content !== undefined ? ( + <View content={this.state.content} {...this.props}/> + ) : ( + <div className="text-center"> + <i className="fa fa-spinner fa-spin"></i> + </div> + ) + } } }; diff --git a/web/src/js/components/ContentView/ContentViews.jsx b/web/src/js/components/ContentView/ContentViews.jsx index 136188d4..387c940a 100644 --- a/web/src/js/components/ContentView/ContentViews.jsx +++ b/web/src/js/components/ContentView/ContentViews.jsx @@ -2,7 +2,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import { setContentViewDescription, setContent } from '../../ducks/ui/flow' -import ContentLoader from './ContentLoader' +import withContentLoader from './ContentLoader' import { MessageUtils } from '../../flow/utils' import CodeEditor from './CodeEditor' @@ -28,9 +28,9 @@ Edit.propTypes = { function Edit({ content, onChange }) { return <CodeEditor content={content} onChange={onChange}/> } -Edit = ContentLoader(Edit) +Edit = withContentLoader(Edit) -class ViewServer extends Component { +export class PureViewServer extends Component { static propTypes = { showFullContent: PropTypes.bool.isRequired, maxLines: PropTypes.number.isRequired, @@ -85,7 +85,7 @@ class ViewServer extends Component { } -ViewServer = connect( +const ViewServer = connect( state => ({ showFullContent: state.ui.flow.showFullContent, maxLines: state.ui.flow.maxContentLines @@ -94,6 +94,6 @@ ViewServer = connect( setContentViewDescription, setContent } -)(ContentLoader(ViewServer)) +)(withContentLoader(PureViewServer)) export { Edit, ViewServer, ViewImage } diff --git a/web/src/js/components/ContentView/DownloadContentButton.jsx b/web/src/js/components/ContentView/DownloadContentButton.jsx index 447db211..f32a19ca 100644 --- a/web/src/js/components/ContentView/DownloadContentButton.jsx +++ b/web/src/js/components/ContentView/DownloadContentButton.jsx @@ -1,3 +1,4 @@ +import React from 'react' import { MessageUtils } from "../../flow/utils" import PropTypes from 'prop-types' diff --git a/web/src/js/components/ContentView/ShowFullContentButton.jsx b/web/src/js/components/ContentView/ShowFullContentButton.jsx index fd627ad9..c6d8c2f2 100644 --- a/web/src/js/components/ContentView/ShowFullContentButton.jsx +++ b/web/src/js/components/ContentView/ShowFullContentButton.jsx @@ -12,7 +12,7 @@ ShowFullContentButton.propTypes = { showFullContent: PropTypes.bool.isRequired } -function ShowFullContentButton ( {setShowFullContent, showFullContent, visibleLines, contentLines} ){ +export function ShowFullContentButton ( {setShowFullContent, showFullContent, visibleLines, contentLines} ){ return ( !showFullContent && diff --git a/web/src/js/components/ContentView/UploadContentButton.jsx b/web/src/js/components/ContentView/UploadContentButton.jsx index 0021593f..847d4eb0 100644 --- a/web/src/js/components/ContentView/UploadContentButton.jsx +++ b/web/src/js/components/ContentView/UploadContentButton.jsx @@ -1,3 +1,4 @@ +import React from 'react' import PropTypes from 'prop-types' import FileChooser from '../common/FileChooser' diff --git a/web/src/js/components/ContentView/ViewSelector.jsx b/web/src/js/components/ContentView/ViewSelector.jsx index 4c99d5ed..812d87e5 100644 --- a/web/src/js/components/ContentView/ViewSelector.jsx +++ b/web/src/js/components/ContentView/ViewSelector.jsx @@ -11,7 +11,7 @@ ViewSelector.propTypes = { setContentView: PropTypes.func.isRequired } -function ViewSelector ({contentViews, activeView, setContentView}){ +export function ViewSelector ({contentViews, activeView, setContentView}){ let inner = <span> <b>View:</b> {activeView.toLowerCase()} <span className="caret"></span> </span> return ( diff --git a/web/src/js/components/EventLog.jsx b/web/src/js/components/EventLog.jsx index a83cdb28..40fe900e 100644 --- a/web/src/js/components/EventLog.jsx +++ b/web/src/js/components/EventLog.jsx @@ -5,7 +5,7 @@ import { toggleFilter, toggleVisibility } from '../ducks/eventLog' import ToggleButton from './common/ToggleButton' import EventList from './EventLog/EventList' -class EventLog extends Component { +export class PureEventLog extends Component { static propTypes = { filters: PropTypes.object.isRequired, @@ -77,4 +77,4 @@ export default connect( close: toggleVisibility, toggleFilter: toggleFilter, } -)(EventLog) +)(PureEventLog) diff --git a/web/src/js/components/FlowTable.jsx b/web/src/js/components/FlowTable.jsx index 24c1f3a1..a6381d0d 100644 --- a/web/src/js/components/FlowTable.jsx +++ b/web/src/js/components/FlowTable.jsx @@ -1,17 +1,20 @@ import React from 'react' import PropTypes from 'prop-types' import ReactDOM from 'react-dom' +import { connect } from 'react-redux' import shallowEqual from 'shallowequal' import AutoScroll from './helpers/AutoScroll' import { calcVScroll } from './helpers/VirtualScroll' import FlowTableHead from './FlowTable/FlowTableHead' import FlowRow from './FlowTable/FlowRow' import Filt from "../filt/filt" +import * as flowsActions from '../ducks/flows' + class FlowTable extends React.Component { static propTypes = { - onSelect: PropTypes.func.isRequired, + selectFlow: PropTypes.func.isRequired, flows: PropTypes.array.isRequired, rowHeight: PropTypes.number, highlight: PropTypes.string, @@ -107,7 +110,7 @@ class FlowTable extends React.Component { flow={flow} selected={flow === selected} highlighted={isHighlighted(flow)} - onSelect={this.props.onSelect} + onSelect={this.props.selectFlow} /> ))} <tr style={{ height: vScroll.paddingBottom }}></tr> @@ -118,4 +121,15 @@ class FlowTable extends React.Component { } } -export default AutoScroll(FlowTable) +export const PureFlowTable = AutoScroll(FlowTable) + +export default connect( + state => ({ + flows: state.flows.view, + highlight: state.flows.highlight, + selected: state.flows.byId[state.flows.selected[0]], + }), + { + selectFlow: flowsActions.select, + } +)(PureFlowTable) diff --git a/web/src/js/components/FlowView.jsx b/web/src/js/components/FlowView.jsx index d03d681a..25e7bb9f 100644 --- a/web/src/js/components/FlowView.jsx +++ b/web/src/js/components/FlowView.jsx @@ -3,94 +3,47 @@ import { connect } from 'react-redux' import _ from 'lodash' import Nav from './FlowView/Nav' -import { Request, Response, ErrorView as Error } from './FlowView/Messages' +import { ErrorView as Error, Request, Response } from './FlowView/Messages' import Details from './FlowView/Details' -import Prompt from './Prompt' import { selectTab } from '../ducks/ui/flow' -class FlowView extends Component { +export const allTabs = { Request, Response, Error, Details } - static allTabs = { Request, Response, Error, Details } +function FlowView({ flow, tabName, selectTab }) { - constructor(props, context) { - super(props, context) - this.onPromptFinish = this.onPromptFinish.bind(this) - } + // only display available tab names + const tabs = ['request', 'response', 'error'].filter(k => flow[k]) + tabs.push("details") - onPromptFinish(edit) { - this.props.setPrompt(false) - if (edit && this.tabComponent) { - this.tabComponent.edit(edit) + if (tabs.indexOf(tabName) < 0) { + if (tabName === 'response' && flow.error) { + tabName = 'error' + } else if (tabName === 'error' && flow.response) { + tabName = 'response' + } else { + tabName = tabs[0] } } - getPromptOptions() { - switch (this.props.tab) { - - case 'request': - return [ - 'method', - 'url', - { text: 'http version', key: 'v' }, - 'header' - ] - break - - case 'response': - return [ - { text: 'http version', key: 'v' }, - 'code', - 'message', - 'header' - ] - break - - case 'details': - return - - default: - throw 'Unknown tab for edit: ' + this.props.tab - } - } - - render() { - let { flow, tab: active, updateFlow } = this.props - const tabs = ['request', 'response', 'error'].filter(k => flow[k]).concat(['details']) - - if (tabs.indexOf(active) < 0) { - if (active === 'response' && flow.error) { - active = 'error' - } else if (active === 'error' && flow.response) { - active = 'response' - } else { - active = tabs[0] - } - } - - const Tab = FlowView.allTabs[_.capitalize(active)] - - return ( - <div className="flow-detail"> - <Nav - flow={flow} - tabs={tabs} - active={active} - onSelectTab={this.props.selectTab} - /> - <Tab ref={ tab => this.tabComponent = tab } flow={flow} updateFlow={updateFlow} /> - {this.props.promptOpen && ( - <Prompt options={this.getPromptOptions()} done={this.onPromptFinish} /> - )} - </div> - ) - } + const Tab = allTabs[_.capitalize(tabName)] + + return ( + <div className="flow-detail"> + <Nav + tabs={tabs} + active={tabName} + onSelectTab={selectTab} + /> + <Tab flow={flow}/> + </div> + ) } export default connect( state => ({ - promptOpen: state.ui.promptOpen, - tab: state.ui.flow.tab + flow: state.flows.byId[state.flows.selected[0]], + tabName: state.ui.flow.tab, }), { selectTab, diff --git a/web/src/js/components/FlowView/Messages.jsx b/web/src/js/components/FlowView/Messages.jsx index 4a31faf4..c1af36c5 100644 --- a/web/src/js/components/FlowView/Messages.jsx +++ b/web/src/js/components/FlowView/Messages.jsx @@ -9,6 +9,7 @@ import ContentView from '../ContentView' import ContentViewOptions from '../ContentView/ContentViewOptions' import ValidateEditor from '../ValueEditor/ValidateEditor' import ValueEditor from '../ValueEditor/ValueEditor' +import HideInStatic from '../common/HideInStatic' import Headers from './Headers' import { startEdit, updateEdit } from '../../ducks/ui/flow' @@ -105,6 +106,7 @@ export class Request extends Component { onContentChange={content => updateFlow({ request: {content}})} message={flow.request}/> </article> + <HideInStatic> {!noContent && <footer> <ContentViewOptions @@ -114,33 +116,10 @@ export class Request extends Component { uploadContent={content => uploadContent(flow, content, "request")}/> </footer> } + </HideInStatic> </section> ) } - - - edit(k) { - throw "unimplemented" - /* - switch (k) { - case 'm': - this.refs.requestLine.refs.method.focus() - break - case 'u': - this.refs.requestLine.refs.url.focus() - break - case 'v': - this.refs.requestLine.refs.httpVersion.focus() - break - case 'h': - this.refs.headers.edit() - break - default: - throw new Error(`Unimplemented: ${k}`) - } - */ - } - } Request = Message(Request) @@ -172,6 +151,7 @@ export class Response extends Component { message={flow.response} /> </article> + <HideInStatic> {!noContent && <footer > <ContentViewOptions @@ -181,31 +161,10 @@ export class Response extends Component { readonly={!isEdit}/> </footer> } + </HideInStatic> </section> ) } - - edit(k) { - throw "unimplemented" - /* - switch (k) { - case 'c': - this.refs.responseLine.refs.status_code.focus() - break - case 'm': - this.refs.responseLine.refs.msg.focus() - break - case 'v': - this.refs.responseLine.refs.httpVersion.focus() - break - case 'h': - this.refs.headers.edit() - break - default: - throw new Error(`'Unimplemented: ${k}`) - } - */ - } } Response = Message(Response) diff --git a/web/src/js/components/Footer.jsx b/web/src/js/components/Footer.jsx index 08d15496..db9afe6f 100644 --- a/web/src/js/components/Footer.jsx +++ b/web/src/js/components/Footer.jsx @@ -2,6 +2,7 @@ import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import { formatSize } from '../utils.js' +import HideInStatic from '../components/common/HideInStatic' Footer.propTypes = { settings: PropTypes.object.isRequired, @@ -49,11 +50,14 @@ function Footer({ settings }) { <span className="label label-success">stream: {formatSize(stream_large_bodies)}</span> )} <div className="pull-right"> - {server && ( + <HideInStatic> + { + server && ( <span className="label label-primary" title="HTTP Proxy Server Address"> {listen_host||"*"}:{listen_port} - </span> - )} + </span>) + } + </HideInStatic> <span className="label label-info" title="Mitmproxy Version"> v{version} </span> diff --git a/web/src/js/components/Header.jsx b/web/src/js/components/Header.jsx index ebe7453c..9b7354eb 100644 --- a/web/src/js/components/Header.jsx +++ b/web/src/js/components/Header.jsx @@ -8,6 +8,7 @@ import FileMenu from './Header/FileMenu' import FlowMenu from './Header/FlowMenu' import {setActiveMenu} from '../ducks/ui/header' import ConnectionIndicator from "./Header/ConnectionIndicator" +import HideInStatic from './common/HideInStatic' class Header extends Component { static entries = [MainMenu, OptionMenu] @@ -40,7 +41,9 @@ class Header extends Component { {Entry.title} </a> ))} - <ConnectionIndicator/> + <HideInStatic> + <ConnectionIndicator/> + </HideInStatic> </nav> <div> <Active/> diff --git a/web/src/js/components/Header/FileMenu.jsx b/web/src/js/components/Header/FileMenu.jsx index 70fbb2c3..c88efcd8 100644 --- a/web/src/js/components/Header/FileMenu.jsx +++ b/web/src/js/components/Header/FileMenu.jsx @@ -4,11 +4,13 @@ import { connect } from 'react-redux' import FileChooser from '../common/FileChooser' import Dropdown, {Divider} from '../common/Dropdown' import * as flowsActions from '../../ducks/flows' +import * as modalActions from '../../ducks/ui/modal' +import HideInStatic from "../common/HideInStatic"; FileMenu.propTypes = { clearFlows: PropTypes.func.isRequired, loadFlows: PropTypes.func.isRequired, - saveFlows: PropTypes.func.isRequired + saveFlows: PropTypes.func.isRequired, } FileMenu.onNewClick = (e, clearFlows) => { @@ -34,12 +36,13 @@ export function FileMenu ({clearFlows, loadFlows, saveFlows}) { Save... </a> + <HideInStatic> <Divider/> - <a href="http://mitm.it/" target="_blank"> <i className="fa fa-fw fa-external-link"></i> Install Certificates... </a> + </HideInStatic> </Dropdown> ) } diff --git a/web/src/js/components/Header/FlowMenu.jsx b/web/src/js/components/Header/FlowMenu.jsx index 8f104213..70c8bfcf 100644 --- a/web/src/js/components/Header/FlowMenu.jsx +++ b/web/src/js/components/Header/FlowMenu.jsx @@ -4,6 +4,7 @@ import { connect } from "react-redux" import Button from "../common/Button" import { MessageUtils } from "../../flow/utils.js" import * as flowsActions from "../../ducks/flows" +import HideInStatic from "../common/HideInStatic"; FlowMenu.title = 'Flow' @@ -22,6 +23,7 @@ export function FlowMenu({ flow, resumeFlow, killFlow, replayFlow, duplicateFlow return <div/> return ( <div> + <HideInStatic> <div className="menu-group"> <div className="menu-content"> <Button title="[r]eplay flow" icon="fa-repeat text-primary" @@ -43,6 +45,8 @@ export function FlowMenu({ flow, resumeFlow, killFlow, replayFlow, duplicateFlow </div> <div className="menu-legend">Flow Modification</div> </div> + </HideInStatic> + <div className="menu-group"> <div className="menu-content"> <Button title="download" icon="fa-download" @@ -52,6 +56,8 @@ export function FlowMenu({ flow, resumeFlow, killFlow, replayFlow, duplicateFlow </div> <div className="menu-legend">Export</div> </div> + + <HideInStatic> <div className="menu-group"> <div className="menu-content"> <Button disabled={!flow || !flow.intercepted} title="[a]ccept intercepted flow" @@ -65,6 +71,7 @@ export function FlowMenu({ flow, resumeFlow, killFlow, replayFlow, duplicateFlow </div> <div className="menu-legend">Interception</div> </div> + </HideInStatic> </div> diff --git a/web/src/js/components/Header/OptionMenu.jsx b/web/src/js/components/Header/OptionMenu.jsx index b33d578d..765129ed 100644 --- a/web/src/js/components/Header/OptionMenu.jsx +++ b/web/src/js/components/Header/OptionMenu.jsx @@ -1,66 +1,56 @@ -import React from "react" -import PropTypes from 'prop-types' +import React from "react" import { connect } from "react-redux" -import { SettingsToggle, EventlogToggle } from "./MenuToggle" +import { EventlogToggle, SettingsToggle } from "./MenuToggle" +import Button from "../common/Button" import DocsLink from "../common/DocsLink" +import HideInStatic from "../common/HideInStatic"; +import * as modalActions from "../../ducks/ui/modal" OptionMenu.title = 'Options' -export default function OptionMenu() { +function OptionMenu({ openOptions }) { return ( <div> - <div className="menu-group"> - <div className="menu-content"> - <SettingsToggle setting="http2">HTTP/2.0</SettingsToggle> - <SettingsToggle setting="websocket">WebSockets</SettingsToggle> - <SettingsToggle setting="rawtcp">Raw TCP</SettingsToggle> + <HideInStatic> + <div className="menu-group"> + <div className="menu-content"> + <Button title="Open Options" icon="fa-cogs text-primary" + onClick={openOptions}> + Edit Options <sup>alpha</sup> + </Button> + </div> + <div className="menu-legend">Options Editor</div> </div> - <div className="menu-legend">Protocol Support</div> - </div> - <div className="menu-group"> - <div className="menu-content"> - <SettingsToggle setting="anticache"> - Disable Caching <DocsLink resource="features/anticache.html"/> - </SettingsToggle> - <SettingsToggle setting="anticomp"> - Disable Compression <i className="fa fa-question-circle" - title="Do not forward Accept-Encoding headers to the server to force an uncompressed response."></i> - </SettingsToggle> + + <div className="menu-group"> + <div className="menu-content"> + <SettingsToggle setting="anticache"> + Strip cache headers <DocsLink resource="features/anticache.html"/> + </SettingsToggle> + <SettingsToggle setting="showhost"> + Use host header for display + </SettingsToggle> + <SettingsToggle setting="ssl_insecure"> + Verify server certificates + </SettingsToggle> + </div> + <div className="menu-legend">Quick Options</div> </div> - <div className="menu-legend">HTTP Options</div> - </div> + </HideInStatic> + <div className="menu-group"> <div className="menu-content"> - <SettingsToggle setting="showhost"> - Use Host Header <i className="fa fa-question-circle" - title="Use the Host header to construct URLs for display."></i> - </SettingsToggle> <EventlogToggle/> </div> <div className="menu-legend">View Options</div> </div> - { /* - <ToggleButton text="no_upstream_cert" - checked={settings.no_upstream_cert} - onToggle={() => updateSettings({ no_upstream_cert: !settings.no_upstream_cert })} - /> - <ToggleInputButton name="stickyauth" placeholder="Sticky auth filter" - checked={!!settings.stickyauth} - txt={settings.stickyauth} - onToggleChanged={txt => updateSettings({ stickyauth: !settings.stickyauth ? txt : null })} - /> - <ToggleInputButton name="stickycookie" placeholder="Sticky cookie filter" - checked={!!settings.stickycookie} - txt={settings.stickycookie} - onToggleChanged={txt => updateSettings({ stickycookie: !settings.stickycookie ? txt : null })} - /> - <ToggleInputButton name="stream_large_bodies" placeholder="stream..." - checked={!!settings.stream_large_bodies} - txt={settings.stream_large_bodies} - inputType="number" - onToggleChanged={txt => updateSettings({ stream_large_bodies: !settings.stream_large_bodies ? txt : null })} - /> - */} </div> ) } + +export default connect( + null, + { + openOptions: () => modalActions.setActiveModal('OptionModal') + } +)(OptionMenu) diff --git a/web/src/js/components/MainView.jsx b/web/src/js/components/MainView.jsx index e2bedc88..03bfce7f 100644 --- a/web/src/js/components/MainView.jsx +++ b/web/src/js/components/MainView.jsx @@ -1,54 +1,27 @@ -import React, { Component } from 'react' +import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import Splitter from './common/Splitter' import FlowTable from './FlowTable' import FlowView from './FlowView' -import * as flowsActions from '../ducks/flows' -class MainView extends Component { - - static propTypes = { - highlight: PropTypes.string, - sort: PropTypes.object, - } +MainView.propTypes = { + hasSelection: PropTypes.bool.isRequired, +} - render() { - const { flows, selectedFlow, highlight } = this.props - return ( - <div className="main-view"> - <FlowTable - ref="flowTable" - flows={flows} - selected={selectedFlow} - highlight={highlight} - onSelect={this.props.selectFlow} - /> - {selectedFlow && [ - <Splitter key="splitter"/>, - <FlowView - key="flowDetails" - ref="flowDetails" - tab={this.props.tab} - updateFlow={data => this.props.updateFlow(selectedFlow, data)} - flow={selectedFlow} - /> - ]} - </div> - ) - } +function MainView({ hasSelection }) { + return ( + <div className="main-view"> + <FlowTable/> + {hasSelection && <Splitter key="splitter"/>} + {hasSelection && <FlowView key="flowDetails"/>} + </div> + ) } export default connect( state => ({ - flows: state.flows.view, - filter: state.flows.filter, - highlight: state.flows.highlight, - selectedFlow: state.flows.byId[state.flows.selected[0]], - tab: state.ui.flow.tab, + hasSelection: !!state.flows.byId[state.flows.selected[0]] }), - { - selectFlow: flowsActions.select, - updateFlow: flowsActions.update, - } + {} )(MainView) diff --git a/web/src/js/components/Modal/Modal.jsx b/web/src/js/components/Modal/Modal.jsx new file mode 100644 index 00000000..88e81156 --- /dev/null +++ b/web/src/js/components/Modal/Modal.jsx @@ -0,0 +1,24 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import ModalList from './ModalList' + +class PureModal extends Component { + + constructor(props, context) { + super(props, context) + } + + render() { + const { activeModal } = this.props + const ActiveModal = ModalList.find(m => m.name === activeModal ) + return( + activeModal ? <ActiveModal/> : <div/> + ) + } +} + +export default connect( + state => ({ + activeModal: state.ui.modal.activeModal + }) +)(PureModal) diff --git a/web/src/js/components/Modal/ModalLayout.jsx b/web/src/js/components/Modal/ModalLayout.jsx new file mode 100644 index 00000000..cf357b2b --- /dev/null +++ b/web/src/js/components/Modal/ModalLayout.jsx @@ -0,0 +1,16 @@ +import React from 'react' + +export default function ModalLayout ({ children }) { + return ( + <div> + <div className="modal-backdrop fade in"></div> + <div className="modal modal-visible" id="optionsModal" tabIndex="-1" role="dialog" aria-labelledby="options"> + <div className="modal-dialog modal-lg" role="document"> + <div className="modal-content"> + {children} + </div> + </div> + </div> + </div> + ) +} diff --git a/web/src/js/components/Modal/ModalList.jsx b/web/src/js/components/Modal/ModalList.jsx new file mode 100644 index 00000000..1175d5ea --- /dev/null +++ b/web/src/js/components/Modal/ModalList.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ModalLayout from './ModalLayout' +import OptionContent from './OptionModal' + +function OptionModal() { + return ( + <ModalLayout> + <OptionContent/> + </ModalLayout> + ) +} + +export default [ OptionModal ] diff --git a/web/src/js/components/Modal/Option.jsx b/web/src/js/components/Modal/Option.jsx new file mode 100644 index 00000000..38e2f239 --- /dev/null +++ b/web/src/js/components/Modal/Option.jsx @@ -0,0 +1,141 @@ +import React, { Component } from "react" +import PropTypes from "prop-types" +import { connect } from "react-redux" +import { update as updateOptions } from "../../ducks/options" +import { Key } from "../../utils" +import classnames from 'classnames' + +const stopPropagation = e => { + if (e.keyCode !== Key.ESC) { + e.stopPropagation() + } +} + +BooleanOption.PropTypes = { + value: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, +} +function BooleanOption({ value, onChange, ...props }) { + return ( + <div className="checkbox"> + <label> + <input type="checkbox" + checked={value} + onChange={e => onChange(e.target.checked)} + {...props} + /> + Enable + </label> + </div> + ) +} + +StringOption.PropTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +} +function StringOption({ value, onChange, ...props }) { + return ( + <input type="text" + value={value || ""} + onChange={e => onChange(e.target.value)} + {...props} + /> + ) +} +function Optional(Component) { + return function ({ onChange, ...props }) { + return <Component + onChange={x => onChange(x ? x : null)} + {...props} + /> + } +} + +NumberOption.PropTypes = { + value: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +} +function NumberOption({ value, onChange, ...props }) { + return ( + <input type="number" + value={value} + onChange={(e) => onChange(parseInt(e.target.value))} + {...props} + /> + ) +} + +ChoicesOption.PropTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +} +export function ChoicesOption({ value, onChange, choices, ...props }) { + return ( + <select + onChange={(e) => onChange(e.target.value)} + value={value} + {...props} + > + { choices.map( + choice => ( + <option key={choice} value={choice}>{choice}</option> + ) + )} + </select> + ) +} + +StringSequenceOption.PropTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +} +function StringSequenceOption({ value, onChange, ...props }) { + const height = Math.max(value.length, 1) + return <textarea + rows={height} + value={value.join('\n')} + onChange={e => onChange(e.target.value.split("\n"))} + {...props} + /> +} + +export const Options = { + "bool": BooleanOption, + "str": StringOption, + "int": NumberOption, + "optional str": Optional(StringOption), + "sequence of str": StringSequenceOption, +} + +function PureOption({ choices, type, value, onChange, name, error }) { + let Opt, props = {} + if (choices) { + Opt = ChoicesOption; + props.choices = choices + } else { + Opt = Options[type] + } + if (Opt !== BooleanOption) { + props.className = "form-control" + } + + return <div className={classnames({'has-error':error})}> + <Opt + name={name} + value={value} + onChange={onChange} + onKeyDown={stopPropagation} + {...props} + /> + </div> +} +export default connect( + (state, { name }) => ({ + ...state.options[name], + ...state.ui.optionsEditor[name] + }), + (dispatch, { name }) => ({ + onChange: value => dispatch(updateOptions(name, value)) + }) +)(PureOption) diff --git a/web/src/js/components/Modal/OptionModal.jsx b/web/src/js/components/Modal/OptionModal.jsx new file mode 100644 index 00000000..fed0048d --- /dev/null +++ b/web/src/js/components/Modal/OptionModal.jsx @@ -0,0 +1,110 @@ +import React, { Component } from "react" +import { connect } from "react-redux" +import * as modalAction from "../../ducks/ui/modal" +import * as optionAction from "../../ducks/options" +import Option from "./Option" +import _ from "lodash" + +function PureOptionHelp({help}){ + return <div className="help-block small">{help}</div>; +} +const OptionHelp = connect((state, {name}) => ({ + help: state.options[name].help, +}))(PureOptionHelp); + +function PureOptionError({error}){ + if(!error) return null; + return <div className="small text-danger">{error}</div>; +} +const OptionError = connect((state, {name}) => ({ + error: state.ui.optionsEditor[name] && state.ui.optionsEditor[name].error +}))(PureOptionError); + +export function PureOptionDefault({value, defaultVal}){ + if( value === defaultVal ) { + return null + } else { + if (typeof(defaultVal) === 'boolean') { + defaultVal = defaultVal ? 'true' : 'false' + } else if (Array.isArray(defaultVal)){ + if (_.isEmpty(_.compact(value)) && // filter the empty string in array + _.isEmpty(defaultVal)){ + return null + } + defaultVal = '[ ]' + } else if (defaultVal === ''){ + defaultVal = '\"\"' + } else if (defaultVal === null){ + defaultVal = 'null' + } + return <div className="small">Default: <strong> {defaultVal} </strong> </div> + } +} +const OptionDefault = connect((state, {name}) => ({ + value: state.options[name].value, + defaultVal: state.options[name].default +}))(PureOptionDefault) + +class PureOptionModal extends Component { + + constructor(props, context) { + super(props, context) + this.state = { title: 'Options' } + } + + componentWillUnmount(){ + // this.props.save() + } + + render() { + const { hideModal, options } = this.props + const { title } = this.state + return ( + <div> + <div className="modal-header"> + <button type="button" className="close" data-dismiss="modal" onClick={() => { + hideModal() + }}> + <i className="fa fa-fw fa-times"></i> + </button> + <div className="modal-title"> + <h4>{ title }</h4> + </div> + </div> + + <div className="modal-body"> + <div className="form-horizontal"> + { + options.map(name => + <div key={name} className="form-group"> + <div className="col-xs-6"> + <label htmlFor={name}>{name}</label> + <OptionHelp name={name}/> + </div> + <div className="col-xs-6"> + <Option name={name}/> + <OptionError name={name}/> + <OptionDefault name={name}/> + </div> + </div> + ) + } + </div> + </div> + + <div className="modal-footer"> + </div> + </div> + ) + } +} + +export default connect( + state => ({ + options: Object.keys(state.options).sort() + }), + { + hideModal: modalAction.hideModal, + save: optionAction.save, + } +)(PureOptionModal) diff --git a/web/src/js/components/Prompt.jsx b/web/src/js/components/Prompt.jsx deleted file mode 100755 index 77b07027..00000000 --- a/web/src/js/components/Prompt.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import ReactDOM from 'react-dom' -import _ from 'lodash' - -import {Key} from '../utils.js' - -Prompt.propTypes = { - options: PropTypes.array.isRequired, - done: PropTypes.func.isRequired, - prompt: PropTypes.string, -} - -export default function Prompt({ prompt, done, options }) { - const opts = [] - - for (let i = 0; i < options.length; i++) { - let opt = options[i] - if (_.isString(opt)) { - let str = opt - while (str.length > 0 && keyTaken(str[0])) { - str = str.substr(1) - } - opt = { text: opt, key: str[0] } - } - if (!opt.text || !opt.key || keyTaken(opt.key)) { - throw 'invalid options' - } - opts.push(opt) - } - - function keyTaken(k) { - return _.map(opts, 'key').includes(k) - } - - function onKeyDown(event) { - event.stopPropagation() - event.preventDefault() - const key = opts.find(opt => Key[opt.key.toUpperCase()] === event.keyCode) - if (!key && event.keyCode !== Key.ESC && event.keyCode !== Key.ENTER) { - return - } - done(key.key || false) - } - - return ( - <div tabIndex="0" onKeyDown={onKeyDown} className="prompt-dialog"> - <div className="prompt-content"> - {prompt || <strong>Select: </strong> } - {opts.map(opt => { - const idx = opt.text.indexOf(opt.key) - function onClick(event) { - done(opt.key) - event.stopPropagation() - } - return ( - <span key={opt.key} className="option" onClick={onClick}> - {idx !== -1 ? opt.text.substring(0, idx) : opt.text + '('} - <strong className="text-primary">{opt.key}</strong> - {idx !== -1 ? opt.text.substring(idx + 1) : ')'} - </span> - ) - })} - </div> - </div> - ) -} diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx index af5b3caa..15384e02 100644 --- a/web/src/js/components/ProxyApp.jsx +++ b/web/src/js/components/ProxyApp.jsx @@ -7,6 +7,7 @@ import MainView from './MainView' import Header from './Header' import EventLog from './EventLog' import Footer from './Footer' +import Modal from './Modal/Modal' class ProxyAppMain extends Component { @@ -19,7 +20,7 @@ class ProxyAppMain extends Component { } render() { - const { showEventLog, location, filter, highlight } = this.props + const { showEventLog } = this.props return ( <div id="container" tabIndex="0"> <Header/> @@ -28,6 +29,7 @@ class ProxyAppMain extends Component { <EventLog key="eventlog"/> )} <Footer /> + <Modal/> </div> ) } diff --git a/web/src/js/components/common/Button.jsx b/web/src/js/components/common/Button.jsx index e02ae010..02dab305 100644 --- a/web/src/js/components/common/Button.jsx +++ b/web/src/js/components/common/Button.jsx @@ -12,7 +12,7 @@ Button.propTypes = { export default function Button({ onClick, children, icon, disabled, className, title }) { return ( <div className={classnames(className, 'btn btn-default')} - onClick={!disabled && onClick} + onClick={disabled ? undefined : onClick} disabled={disabled} title={title}> {icon && (<i className={"fa fa-fw " + icon}/> )} diff --git a/web/src/js/components/common/HideInStatic.jsx b/web/src/js/components/common/HideInStatic.jsx new file mode 100644 index 00000000..c5f3bf47 --- /dev/null +++ b/web/src/js/components/common/HideInStatic.jsx @@ -0,0 +1,5 @@ +import React from 'react' + +export default function HideInStatic({ children }) { + return global.MITMWEB_STATIC ? null : [children] +} diff --git a/web/src/js/ducks/connection.js b/web/src/js/ducks/connection.js index ffa2c309..151277fb 100644 --- a/web/src/js/ducks/connection.js +++ b/web/src/js/ducks/connection.js @@ -1,6 +1,6 @@ export const ConnectionState = { INIT: Symbol("init"), - FETCHING: Symbol("fetching"), // WebSocket is established, but still startFetching resources. + FETCHING: Symbol("fetching"), // WebSocket is established, but still fetching resources. ESTABLISHED: Symbol("established"), ERROR: Symbol("error"), OFFLINE: Symbol("offline"), // indicates that there is no live (websocket) backend. diff --git a/web/src/js/ducks/index.js b/web/src/js/ducks/index.js index 0f2426ec..be2f2885 100644 --- a/web/src/js/ducks/index.js +++ b/web/src/js/ducks/index.js @@ -4,6 +4,7 @@ import flows from "./flows" import settings from "./settings" import ui from "./ui/index" import connection from "./connection" +import options from './options' export default combineReducers({ eventLog, @@ -11,4 +12,5 @@ export default combineReducers({ settings, connection, ui, + options, }) diff --git a/web/src/js/ducks/options.js b/web/src/js/ducks/options.js new file mode 100644 index 00000000..0da0fb8c --- /dev/null +++ b/web/src/js/ducks/options.js @@ -0,0 +1,50 @@ +import { fetchApi } from "../utils" +import * as optionsEditorActions from "./ui/optionsEditor" +import _ from "lodash" + +export const RECEIVE = 'OPTIONS_RECEIVE' +export const UPDATE = 'OPTIONS_UPDATE' +export const REQUEST_UPDATE = 'REQUEST_UPDATE' + +const defaultState = {} + +export default function reducer(state = defaultState, action) { + switch (action.type) { + + case RECEIVE: + return action.data + + case UPDATE: + return { + ...state, + ...action.data, + } + + default: + return state + } +} + +export function pureSendUpdate (option, value, dispatch) { + fetchApi.put('/options', { [option]: value }).then(response => { + if (response.status === 200) { + dispatch(optionsEditorActions.updateSuccess(option)) + } else { + response.text().then(error => { + dispatch(optionsEditorActions.updateError(option, error)) + }) + } + }) +} +let sendUpdate = _.throttle(pureSendUpdate, 700, { leading: true, trailing: true }) + +export function update(option, value) { + return dispatch => { + dispatch(optionsEditorActions.startUpdate(option, value)) + sendUpdate(option, value, dispatch); + } +} + +export function save() { + return dispatch => fetchApi('/options/save', { method: 'POST' }) +} diff --git a/web/src/js/ducks/settings.js b/web/src/js/ducks/settings.js index a2e360de..38c36842 100644 --- a/web/src/js/ducks/settings.js +++ b/web/src/js/ducks/settings.js @@ -3,7 +3,6 @@ import { fetchApi } from '../utils' export const RECEIVE = 'SETTINGS_RECEIVE' export const UPDATE = 'SETTINGS_UPDATE' export const REQUEST_UPDATE = 'REQUEST_UPDATE' -export const UNKNOWN_CMD = 'SETTINGS_UNKNOWN_CMD' const defaultState = { diff --git a/web/src/js/ducks/ui/flow.js b/web/src/js/ducks/ui/flow.js index 51ad4184..ea31db19 100644 --- a/web/src/js/ducks/ui/flow.js +++ b/web/src/js/ducks/ui/flow.js @@ -148,7 +148,6 @@ export function setContent(content){ return { type: SET_CONTENT, content } } -export function stopEdit(data, modifiedFlow) { - let diff = getDiff(data, modifiedFlow) - return {type: flowsActions.UPDATE, data, diff } +export function stopEdit(flow, modifiedFlow) { + return flowsActions.update(flow, getDiff(flow, modifiedFlow)) } diff --git a/web/src/js/ducks/ui/index.js b/web/src/js/ducks/ui/index.js index 1d989eb1..f5e6851f 100644 --- a/web/src/js/ducks/ui/index.js +++ b/web/src/js/ducks/ui/index.js @@ -1,9 +1,13 @@ import { combineReducers } from 'redux' import flow from './flow' import header from './header' +import modal from './modal' +import optionsEditor from './optionsEditor' // TODO: Just move ducks/ui/* into ducks/? export default combineReducers({ flow, header, + modal, + optionsEditor, }) diff --git a/web/src/js/ducks/ui/keyboard.js b/web/src/js/ducks/ui/keyboard.js index 0e3491fa..ed4dbba5 100644 --- a/web/src/js/ducks/ui/keyboard.js +++ b/web/src/js/ducks/ui/keyboard.js @@ -1,10 +1,11 @@ import { Key } from "../../utils" import { selectTab } from "./flow" import * as flowsActions from "../flows" +import * as modalActions from "./modal" export function onKeyDown(e) { - console.debug("onKeyDown", e) + //console.debug("onKeyDown", e) if (e.ctrlKey) { return () => { } @@ -46,7 +47,11 @@ export function onKeyDown(e) { break case Key.ESC: - dispatch(flowsActions.select(null)) + if(getState().ui.modal.activeModal){ + dispatch(modalActions.hideModal()) + } else { + dispatch(flowsActions.select(null)) + } break case Key.LEFT: { diff --git a/web/src/js/ducks/ui/modal.js b/web/src/js/ducks/ui/modal.js new file mode 100644 index 00000000..aafddaf7 --- /dev/null +++ b/web/src/js/ducks/ui/modal.js @@ -0,0 +1,33 @@ +export const HIDE_MODAL = 'UI_HIDE_MODAL' +export const SET_ACTIVE_MODAL = 'UI_SET_ACTIVE_MODAL' + +const defaultState = { + activeModal: undefined, +} + +export default function reducer(state = defaultState, action){ + switch (action.type){ + + case SET_ACTIVE_MODAL: + return { + ...state, + activeModal: action.activeModal, + } + + case HIDE_MODAL: + return { + ...state, + activeModal: undefined + } + default: + return state + } +} + +export function setActiveModal(activeModal) { + return { type: SET_ACTIVE_MODAL, activeModal } +} + +export function hideModal(){ + return { type: HIDE_MODAL } +} diff --git a/web/src/js/ducks/ui/optionsEditor.js b/web/src/js/ducks/ui/optionsEditor.js new file mode 100644 index 00000000..a8a8f69e --- /dev/null +++ b/web/src/js/ducks/ui/optionsEditor.js @@ -0,0 +1,73 @@ +import { HIDE_MODAL } from "./modal" + +export const OPTION_UPDATE_START = 'UI_OPTION_UPDATE_START' +export const OPTION_UPDATE_SUCCESS = 'UI_OPTION_UPDATE_SUCCESS' +export const OPTION_UPDATE_ERROR = 'UI_OPTION_UPDATE_ERROR' + +const defaultState = { + /* optionName -> {isUpdating, value (client-side), error} */ +} + +export default function reducer(state = defaultState, action) { + switch (action.type) { + case OPTION_UPDATE_START: + return { + ...state, + [action.option]: { + isUpdating: true, + value: action.value, + error: false, + } + } + + case OPTION_UPDATE_SUCCESS: + return { + ...state, + [action.option]: undefined + } + + case OPTION_UPDATE_ERROR: + let val = state[action.option].value; + if (typeof(val) === "boolean") { + // If a boolean option errs, reset it to its previous state to be less confusing. + // Example: Start mitmweb, check "add_upstream_certs_to_client_chain". + val = !val; + } + return { + ...state, + [action.option]: { + value: val, + isUpdating: false, + error: action.error + } + } + + case HIDE_MODAL: + return {} + + default: + return state + } +} + +export function startUpdate(option, value) { + return { + type: OPTION_UPDATE_START, + option, + value, + } +} +export function updateSuccess(option) { + return { + type: OPTION_UPDATE_SUCCESS, + option, + } +} + +export function updateError(option, error) { + return { + type: OPTION_UPDATE_ERROR, + option, + error, + } +} diff --git a/web/src/js/filt/filt.js b/web/src/js/filt/filt.js index 26058649..19a41af2 100644 --- a/web/src/js/filt/filt.js +++ b/web/src/js/filt/filt.js @@ -1929,7 +1929,7 @@ module.exports = (function() { function body(regex){ regex = new RegExp(regex, "i"); function bodyFilter(flow){ - return True; + return true; } bodyFilter.desc = "body filters are not implemented yet, see https://github.com/mitmproxy/mitmweb/issues/10"; return bodyFilter; @@ -1937,7 +1937,7 @@ module.exports = (function() { function requestBody(regex){ regex = new RegExp(regex, "i"); function requestBodyFilter(flow){ - return True; + return true; } requestBodyFilter.desc = "body filters are not implemented yet, see https://github.com/mitmproxy/mitmweb/issues/10"; return requestBodyFilter; @@ -1945,7 +1945,7 @@ module.exports = (function() { function responseBody(regex){ regex = new RegExp(regex, "i"); function responseBodyFilter(flow){ - return True; + return true; } responseBodyFilter.desc = "body filters are not implemented yet, see https://github.com/mitmproxy/mitmweb/issues/10"; return responseBodyFilter; @@ -2104,4 +2104,4 @@ module.exports = (function() { SyntaxError: peg$SyntaxError, parse: peg$parse }; -})();
\ No newline at end of file +})(); diff --git a/web/src/js/filt/filt.peg b/web/src/js/filt/filt.peg index 12959474..e4b151ad 100644 --- a/web/src/js/filt/filt.peg +++ b/web/src/js/filt/filt.peg @@ -1,4 +1,4 @@ -// PEG.js filter rules - see http://pegjs.majda.cz/online +// PEG.js filter rules - see https://pegjs.org/ { var flowutils = require("../flow/utils.js"); @@ -72,7 +72,7 @@ function responseCode(code){ function body(regex){ regex = new RegExp(regex, "i"); function bodyFilter(flow){ - return True; + return true; } bodyFilter.desc = "body filters are not implemented yet, see https://github.com/mitmproxy/mitmweb/issues/10"; return bodyFilter; @@ -80,7 +80,7 @@ function body(regex){ function requestBody(regex){ regex = new RegExp(regex, "i"); function requestBodyFilter(flow){ - return True; + return true; } requestBodyFilter.desc = "body filters are not implemented yet, see https://github.com/mitmproxy/mitmweb/issues/10"; return requestBodyFilter; @@ -88,7 +88,7 @@ function requestBody(regex){ function responseBody(regex){ regex = new RegExp(regex, "i"); function responseBodyFilter(flow){ - return True; + return true; } responseBodyFilter.desc = "body filters are not implemented yet, see https://github.com/mitmproxy/mitmweb/issues/10"; return responseBodyFilter; diff --git a/web/src/js/flow/utils.js b/web/src/js/flow/utils.js index 3c38058e..d38f4a86 100644 --- a/web/src/js/flow/utils.js +++ b/web/src/js/flow/utils.js @@ -49,7 +49,7 @@ export var MessageUtils = { } else if (message === flow.response) { message = "response"; } - return `/flows/${flow.id}/${message}/content` + (view ? `/${view}` : ''); + return `./flows/${flow.id}/${message}/` + (view ? `content/${view}.json` : 'content.data'); } }; diff --git a/web/src/js/urlState.js b/web/src/js/urlState.js index 7802bdb8..d9c50c03 100644 --- a/web/src/js/urlState.js +++ b/web/src/js/urlState.js @@ -72,8 +72,12 @@ export function updateUrlFromStore(store) { if (queryStr) { url += "?" + queryStr } + let pathname = window.location.pathname + if(pathname === "blank") { + pathname = "/" // this happens in tests... + } if (window.location.hash.substr(1) !== url) { - history.replaceState(undefined, "", `/#${url}`) + history.replaceState(undefined, "", `${pathname}#${url}`) } } diff --git a/web/src/js/utils.js b/web/src/js/utils.js index e8470cec..80f0c42a 100644 --- a/web/src/js/utils.js +++ b/web/src/js/utils.js @@ -1,6 +1,5 @@ import _ from 'lodash' import React from 'react' -import shallowEqual from 'shallowequal' window._ = _; window.React = React; @@ -88,6 +87,11 @@ export function fetchApi(url, options={}) { } else { url += "&" + xsrf; } + } else { + url += '.json' + } + if (url.startsWith("/")) { + url = "." + url; } return fetch(url, { @@ -121,13 +125,9 @@ export function getDiff(obj1, obj2) { return result } -export const pure = renderFn => class extends React.Component { +export const pure = renderFn => class extends React.PureComponent { static displayName = renderFn.name - shouldComponentUpdate(nextProps) { - return !shallowEqual(this.props, nextProps) - } - render() { return renderFn(this.props) } diff --git a/web/src/templates/index.html b/web/src/templates/index.html index db9d2ecb..d2d01776 100644 --- a/web/src/templates/index.html +++ b/web/src/templates/index.html @@ -7,6 +7,7 @@ <link rel="stylesheet" href="/static/vendor.css"/> <link rel="stylesheet" href="/static/app.css"/> <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon"/> + <script src="/static/static.js"></script> <script src="/static/vendor.js"></script> <script src="/static/app.js"></script> </head> |