insomnia/packages/insomnia-app/app/ui/components/wrapper-unit-test.tsx

586 lines
17 KiB
TypeScript
Raw Normal View History

import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames';
import {
Button,
Dropdown,
DropdownItem,
ListGroup,
2022-01-18 22:21:07 +00:00
SvgIcon,
UnitTestItem,
UnitTestResultItem,
} from 'insomnia-components';
2021-07-22 23:04:56 +00:00
import { generate, runTests, Test } from 'insomnia-testing';
2022-01-18 22:21:07 +00:00
import { isEmpty } from 'ramda';
2021-07-22 23:04:56 +00:00
import React, { PureComponent, ReactNode } from 'react';
import { connect } from 'react-redux';
2021-07-22 23:04:56 +00:00
import { SegmentEvent, trackSegmentEvent } from '../../common/analytics';
2021-07-22 23:04:56 +00:00
import { AUTOBIND_CFG } from '../../common/constants';
2022-01-18 22:21:07 +00:00
import { documentationLinks } from '../../common/documentation';
2021-07-22 23:04:56 +00:00
import { getSendRequestCallback } from '../../common/send-request';
import * as models from '../../models';
2021-07-22 23:04:56 +00:00
import { isRequest } from '../../models/request';
import { isRequestGroup } from '../../models/request-group';
import type { UnitTest } from '../../models/unit-test';
2021-07-22 23:04:56 +00:00
import type { UnitTestSuite } from '../../models/unit-test-suite';
import { RootState } from '../redux/modules';
import { selectActiveEnvironment, selectActiveUnitTestResult, selectActiveUnitTests, selectActiveUnitTestSuite, selectActiveUnitTestSuites, selectActiveWorkspace } from '../redux/selectors';
import { Editable } from './base/editable';
import { CodeEditor } from './codemirror/code-editor';
import { ErrorBoundary } from './error-boundary';
2021-07-22 23:04:56 +00:00
import { showAlert, showModal, showPrompt } from './modals';
import { SelectModal } from './modals/select-modal';
import { PageLayout } from './page-layout';
2022-01-18 22:21:07 +00:00
import { EmptyStatePane } from './panes/empty-state-pane';
2021-07-22 23:04:56 +00:00
import type { SidebarChildObjects } from './sidebar/sidebar-children';
import { UnitTestEditable } from './unit-test-editable';
import { WorkspacePageHeader } from './workspace-page-header';
import type { HandleActivityChange, WrapperProps } from './wrapper';
interface Props extends ReturnType<typeof mapStateToProps> {
children: SidebarChildObjects;
gitSyncDropdown: ReactNode;
handleActivityChange: HandleActivityChange;
wrapperProps: WrapperProps;
}
interface State {
testsRunning: UnitTest[] | null;
resultsError: string | null;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
class UnconnectedWrapperUnitTest extends PureComponent<Props, State> {
state: State = {
testsRunning: null,
resultsError: null,
};
// Defining it here instead of in render() so it won't act as a changed prop
// when being passed to <CodeEditor> again
static lintOptions = {
globals: {
// https://jshint.com/docs/options/
insomnia: true,
expect: true,
chai: true,
debugger: true,
},
asi: true,
// Don't require semicolons
undef: true,
// Prevent undefined usages
node: true,
// Enable NodeJS globals
esversion: 8, // ES8 syntax (async/await, etc)
};
generateSendReqSnippet(existingCode: string, requestId: string) {
let variableName = 'response';
for (let i = 1; i < 100; i++) {
variableName = `response${i}`;
// Try next one if code already contains this variable
if (existingCode.includes(`const ${variableName} =`)) {
continue;
}
// Found variable that doesn't exist in code yet
break;
}
return (
`const ${variableName} = await insomnia.send(${requestId});\n` +
`expect(${variableName}.status).to.equal(200);`
);
}
autocompleteSnippets(unitTest: UnitTest) {
return [
{
name: 'Send Current Request',
displayValue: '',
value: this.generateSendReqSnippet(unitTest.code, ''),
},
{
name: 'Send Request By ID',
displayValue: '',
value: async () => {
return new Promise(resolve => {
showModal(SelectModal, {
title: 'Select Request',
message: 'Select a request to fill',
value: '__NULL__',
options: [
{
name: '-- Select Request --',
value: '__NULL__',
},
...this.buildSelectableRequests().map(({ name, request }) => ({
name: name,
displayValue: '',
// @ts-expect-error -- TSCONVERSION
value: this.generateSendReqSnippet(unitTest.code, `'${request._id}'`),
})),
],
onDone: value => resolve(value),
});
});
},
},
];
}
async _handleCreateTestSuite() {
const { activeWorkspace } = this.props;
if (!activeWorkspace) {
return;
}
showPrompt({
title: 'New Test Suite',
defaultValue: 'New Suite',
submitName: 'Create Suite',
label: 'Test Suite Name',
selectText: true,
onComplete: async name => {
const unitTestSuite = await models.unitTestSuite.create({
parentId: activeWorkspace._id,
name,
});
await this._handleSetActiveUnitTestSuite(unitTestSuite);
trackSegmentEvent(SegmentEvent.testSuiteCreate);
},
});
}
async _handleCreateTest() {
const { activeUnitTestSuite } = this.props;
showPrompt({
title: 'New Test',
defaultValue: 'Returns 200',
submitName: 'New Test',
label: 'Test Name',
selectText: true,
onComplete: async name => {
await models.unitTest.create({
parentId: activeUnitTestSuite?._id,
code: this.generateSendReqSnippet('', ''),
name,
});
trackSegmentEvent(SegmentEvent.unitTestCreate);
},
});
}
async _handleUnitTestCodeChange(unitTest: UnitTest, v: string) {
await models.unitTest.update(unitTest, {
code: v,
});
}
async _handleRunTests() {
const { activeUnitTests } = this.props;
await this._runTests(activeUnitTests);
trackSegmentEvent(SegmentEvent.unitTestRunAll);
}
async _handleRunTest(unitTest: UnitTest) {
await this._runTests([unitTest]);
trackSegmentEvent(SegmentEvent.unitTestRun);
}
async _handleDeleteTest(unitTest: UnitTest) {
showAlert({
title: `Delete ${unitTest.name}`,
message: (
<span>
Really delete <strong>{unitTest.name}</strong>?
</span>
),
addCancel: true,
onConfirm: async () => {
await models.unitTest.remove(unitTest);
trackSegmentEvent(SegmentEvent.unitTestDelete);
},
});
}
async _handleSetActiveRequest(
unitTest: UnitTest,
e: React.SyntheticEvent<HTMLSelectElement>,
) {
const requestId = e.currentTarget.value === '__NULL__' ? null : e.currentTarget.value;
await models.unitTest.update(unitTest, {
requestId,
});
}
async _handleDeleteUnitTestSuite(unitTestSuite: UnitTestSuite) {
showAlert({
title: `Delete ${unitTestSuite.name}`,
message: (
<span>
Really delete <strong>{unitTestSuite.name}</strong>?
</span>
),
addCancel: true,
onConfirm: async () => {
await models.unitTestSuite.remove(unitTestSuite);
trackSegmentEvent(SegmentEvent.testSuiteDelete);
},
});
}
async _handleSetActiveUnitTestSuite(unitTestSuite: UnitTestSuite) {
const { activeWorkspace } = this.props;
if (!activeWorkspace) {
return;
}
await models.workspaceMeta.updateByParentId(activeWorkspace._id, {
activeUnitTestSuiteId: unitTestSuite._id,
});
}
async _handleChangeTestName(unitTest: UnitTest, name: string) {
await models.unitTest.update(unitTest, {
name,
});
}
async _handleChangeActiveSuiteName(name: string) {
const { activeUnitTestSuite } = this.props;
if (!activeUnitTestSuite) {
return;
}
await models.unitTestSuite.update(activeUnitTestSuite, {
name,
});
}
async _runTests(unitTests: UnitTest[]) {
const { activeWorkspace, activeEnvironment } = this.props;
if (!activeWorkspace) {
return;
}
this.setState({
testsRunning: unitTests,
resultsError: null,
});
const tests: Test[] = [];
for (const t of unitTests) {
tests.push({
name: t.name,
code: t.code,
defaultRequestId: t.requestId,
});
}
const src = await generate([
{
name: 'My Suite',
suites: [],
tests,
},
]);
const sendRequest = getSendRequestCallback(activeEnvironment?._id);
try {
const results = await runTests(src, { sendRequest });
await models.unitTestResult.create({
results,
parentId: activeWorkspace._id,
});
this.setState({ testsRunning: null });
} catch (err) {
// Set the state after a timeout so the user still sees the loading state
setTimeout(() => {
this.setState({
resultsError: err.message,
testsRunning: null,
});
}, 400);
return;
}
}
buildSelectableRequests(): {
name: string;
request: Request;
}[] {
const { children } = this.props;
const selectableRequests: {
name: string;
request: Request;
}[] = [];
const next = (p, children) => {
for (const c of children) {
if (isRequest(c.doc)) {
selectableRequests.push({
name: `${p} [${c.doc.method}] ${c.doc.name}`,
request: c.doc,
});
} else if (isRequestGroup(c.doc)) {
next(c.doc.name + ' / ', c.children);
}
}
};
next('', children.all);
return selectableRequests;
}
_renderResults() {
const { activeUnitTestResult } = this.props;
const { testsRunning, resultsError } = this.state;
if (resultsError) {
return (
<div className="unit-tests__results">
<div className="unit-tests__top-header">
<h2>Run Failed</h2>
</div>
<div className="danger pad">{resultsError}</div>
</div>
);
}
if (testsRunning) {
return (
<div className="unit-tests__results">
<div className="unit-tests__top-header">
<h2>Running {testsRunning.length} Tests...</h2>
</div>
</div>
);
}
if (!activeUnitTestResult) {
return (
<div className="unit-tests__results">
<div className="unit-tests__top-header">
<h2>No Results</h2>
</div>
</div>
);
}
if (activeUnitTestResult.results) {
const { stats, tests } = activeUnitTestResult.results;
return (
<div className="unit-tests__results">
{activeUnitTestResult && (
<div key={activeUnitTestResult._id}>
<div className="unit-tests__top-header">
{stats.failures ? (
<h2 className="warning">
Tests Failed {stats.failures}/{stats.tests}
</h2>
) : (
<h2 className="success">
Tests Passed {stats.passes}/{stats.tests}
</h2>
)}
</div>
<ListGroup>
{tests.map((t, i) => (
<UnitTestResultItem key={i} item={t} />
))}
</ListGroup>
</div>
)}
</div>
);
}
return (
<div className="unit-tests">
<div className="unit-tests__top-header">
<h2 className="success">Awaiting Test Execution</h2>
</div>
</div>
);
}
renderUnitTest(unitTest: UnitTest) {
const { testsRunning } = this.state;
const selectableRequests = this.buildSelectableRequests();
return (
<UnitTestItem
item={unitTest}
key={unitTest._id}
onSetActiveRequest={this._handleSetActiveRequest.bind(this, unitTest)}
onDeleteTest={this._handleDeleteTest.bind(this, unitTest)}
onRunTest={this._handleRunTest.bind(this, unitTest)}
testsRunning={testsRunning}
selectedRequestId={unitTest.requestId}
// @ts-expect-error -- TSCONVERSION
selectableRequests={selectableRequests}
testNameEditable={
<UnitTestEditable
onSubmit={this._handleChangeTestName.bind(this, unitTest)}
value={unitTest.name}
/>
2021-08-07 08:03:56 +00:00
}
>
<CodeEditor
dynamicHeight
manualPrettify
defaultValue={unitTest ? unitTest.code : ''}
getAutocompleteSnippets={() => this.autocompleteSnippets(unitTest)}
lintOptions={WrapperUnitTest.lintOptions}
onChange={this._handleUnitTestCodeChange.bind(this, unitTest)}
mode="javascript"
placeholder=""
/>
</UnitTestItem>
);
}
_renderTestSuite() {
const { activeUnitTests, activeUnitTestSuite } = this.props;
const { testsRunning } = this.state;
2022-01-18 22:21:07 +00:00
const emptyStatePane = (
<div style={{ height: '100%' }}>
<EmptyStatePane
icon={<SvgIcon icon="vial" />}
documentationLinks={[
documentationLinks.unitTesting,
documentationLinks.introductionToInsoCLI,
]}
title="Add unit tests to verify your API"
secondaryAction="You can run these tests in CI with Inso CLI"
/>
</div>
);
if (!activeUnitTestSuite) {
return <div className="unit-tests pad theme--pane__body">No test suite selected</div>;
}
return (
<div className="unit-tests theme--pane__body">
<div className="unit-tests__top-header">
<h2>
<Editable
singleClick
onSubmit={this._handleChangeActiveSuiteName}
value={activeUnitTestSuite.name}
/>
</h2>
<Button variant="outlined" onClick={this._handleCreateTest}>
New Test
</Button>
<Button
variant="contained"
bg="surprise"
onClick={this._handleRunTests}
size="default"
disabled={Boolean(testsRunning)}
>
{testsRunning ? 'Running... ' : 'Run Tests'}
2021-08-07 08:03:56 +00:00
<i className="fa fa-play space-left" />
</Button>
</div>
2022-01-18 22:21:07 +00:00
{isEmpty(activeUnitTests) ? emptyStatePane : null}
<ListGroup>{activeUnitTests.map(this.renderUnitTest)}</ListGroup>
</div>
);
}
_renderPageSidebar() {
const { activeUnitTestSuites, activeUnitTestSuite } = this.props;
const { testsRunning } = this.state;
const activeId = activeUnitTestSuite ? activeUnitTestSuite._id : 'n/a';
return (
<ErrorBoundary showAlert>
<div className="unit-tests__sidebar">
<div className="pad-sm">
<Button variant="outlined" onClick={this._handleCreateTestSuite}>
New Test Suite
</Button>
</div>
<ul>
{activeUnitTestSuites.map(s => (
<li
key={s._id}
className={classnames({
active: s._id === activeId,
2021-08-07 08:03:56 +00:00
})}
>
<button key={s._id} onClick={this._handleSetActiveUnitTestSuite.bind(this, s)}>
{s.name}
</button>
<Dropdown
right
renderButton={() => (
<button className="unit-tests__sidebar__action">
<i className="fa fa-caret-down" />
</button>
2021-08-07 08:03:56 +00:00
)}
>
<DropdownItem
stayOpenAfterClick
onClick={this._handleRunTests}
disabled={Boolean(testsRunning)}
2021-08-07 08:03:56 +00:00
>
{testsRunning ? 'Running... ' : 'Run Tests'}
</DropdownItem>
<DropdownItem onClick={this._handleDeleteUnitTestSuite.bind(this, s)}>
Delete Suite
</DropdownItem>
</Dropdown>
</li>
))}
</ul>
</div>
</ErrorBoundary>
);
}
_renderPageHeader() {
const { gitSyncDropdown, handleActivityChange } = this.props;
return (
<WorkspacePageHeader
handleActivityChange={handleActivityChange}
gridRight={gitSyncDropdown}
/>
);
}
render() {
return (
<PageLayout
wrapperProps={this.props.wrapperProps}
renderPageSidebar={this._renderPageSidebar}
renderPaneOne={this._renderTestSuite}
renderPaneTwo={this._renderResults}
renderPageHeader={this._renderPageHeader}
/>
);
}
}
const mapStateToProps = (state: RootState) => ({
activeWorkspace: selectActiveWorkspace(state),
activeUnitTestSuite: selectActiveUnitTestSuite(state),
activeUnitTestSuites: selectActiveUnitTestSuites(state),
activeUnitTests: selectActiveUnitTests(state),
activeEnvironment: selectActiveEnvironment(state),
activeUnitTestResult: selectActiveUnitTestResult(state),
});
export const WrapperUnitTest = connect(mapStateToProps)(UnconnectedWrapperUnitTest);