From 3883dc6feb7d2cf225065ebe9fd635b15e2171c2 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 1 Jul 2020 14:17:14 -0700 Subject: [PATCH] POC for Unit Test UI (#2315) * Start working on insomnia-testing package to generate Mocha code * Moved some things around * Integration tests and ability to run multiple files * Fix integration tests * Rename runSuite to runMochaTests * Add types for test results * Fix lint errors * Play with Chia assertions * insomnia and chai globals, plus a bunch of improvements * Stub chai and axios Flow types * Ability to reference requests by ID * Fix chai flow-typed location * Address PR comments (small tweaks) * Basic UI going * Lots and lots and lots * Pretty-much done with the unit test UI * Minor CSS tweak * Activity bar triple toggle and more * Minor tweak * Unit Test and Suite deletion * Bump Designer version * Fix eslint stuff * Fix insomnia-testing tests * Fix tests * lib@2.2.9 * Remove tests tab from response pane * Hook up Insomnia networking * Fix linting * Bump version for alpha * Remove extra ActivityToggleSwitch * Remove unused import * Add test:pre-release script that excludes CLI tests * Less repetition * Clean some things up * Tweaks after self-cr * Undo request ID tag * Swap out switch for new activity toggle component * Extra check * Remove dead code * Delete dead test * Oops, revert example code * PR feedback * More PR comment addresses --- packages/insomnia-app/app/models/index.js | 9 + packages/insomnia-app/app/models/settings.js | 2 + .../app/models/unit-test-result.js | 45 ++ .../app/models/unit-test-suite.js | 52 ++ packages/insomnia-app/app/models/unit-test.js | 53 ++ .../insomnia-app/app/models/workspace-meta.js | 48 +- .../insomnia-app/app/sync-legacy/index.js | 2 + .../sync/git/__tests__/ne-db-plugin.test.js | 2 + .../insomnia-app/app/sync/git/ne-db-plugin.js | 2 + .../ui/components/codemirror/base-imports.js | 3 +- .../codemirror/lint/javascript-async-lint.js | 72 +++ .../app/ui/components/page-layout.js | 1 - .../app/ui/components/request-pane.js | 1 - .../app/ui/components/response-pane.js | 2 + .../app/ui/components/settings/general.js | 30 +- .../app/ui/components/sidebar/sidebar.js | 1 - .../app/ui/components/wrapper-debug.js | 4 + .../app/ui/components/wrapper-design.js | 29 +- .../app/ui/components/wrapper-unit-test.js | 482 ++++++++++++++++++ .../insomnia-app/app/ui/components/wrapper.js | 31 +- .../insomnia-app/app/ui/containers/app.js | 30 +- .../app/ui/css/components/unit-tests.less | 153 ++++++ packages/insomnia-app/app/ui/css/index.less | 1 + .../app/ui/redux/modules/entities.js | 3 + .../insomnia-app/app/ui/redux/selectors.js | 57 +++ .../insomnia-app/config/config.designer.json | 2 +- .../insomnia-components/components/button.js | 5 - .../src/__tests__/integration.test.js | 33 ++ .../themes/studio-light.js | 2 +- .../__tests__/index.test.js | 17 + 30 files changed, 1086 insertions(+), 88 deletions(-) create mode 100644 packages/insomnia-app/app/models/unit-test-result.js create mode 100644 packages/insomnia-app/app/models/unit-test-suite.js create mode 100644 packages/insomnia-app/app/models/unit-test.js create mode 100644 packages/insomnia-app/app/ui/components/codemirror/lint/javascript-async-lint.js create mode 100644 packages/insomnia-app/app/ui/components/wrapper-unit-test.js create mode 100644 packages/insomnia-app/app/ui/css/components/unit-tests.less diff --git a/packages/insomnia-app/app/models/index.js b/packages/insomnia-app/app/models/index.js index 7b9474a6f2..26d1129a75 100644 --- a/packages/insomnia-app/app/models/index.js +++ b/packages/insomnia-app/app/models/index.js @@ -14,6 +14,9 @@ import * as _requestVersion from './request-version'; import * as _response from './response'; import * as _settings from './settings'; import * as _stats from './stats'; +import * as _unitTest from './unit-test'; +import * as _unitTestResult from './unit-test-result'; +import * as _unitTestSuite from './unit-test-suite'; import * as _workspace from './workspace'; import * as _workspaceMeta from './workspace-meta'; import { generateId } from '../common/misc'; @@ -42,6 +45,9 @@ export const requestVersion = _requestVersion; export const response = _response; export const settings = _settings; export const stats = _stats; +export const unitTest = _unitTest; +export const unitTestSuite = _unitTestSuite; +export const unitTestResult = _unitTestResult; export const workspace = _workspace; export const workspaceMeta = _workspaceMeta; @@ -64,6 +70,9 @@ export function all() { oAuth2Token, clientCertificate, pluginData, + unitTestSuite, + unitTestResult, + unitTest, ]; } diff --git a/packages/insomnia-app/app/models/settings.js b/packages/insomnia-app/app/models/settings.js index 52380488a2..3d5e51ff70 100644 --- a/packages/insomnia-app/app/models/settings.js +++ b/packages/insomnia-app/app/models/settings.js @@ -57,6 +57,7 @@ type BaseSettings = { // Feature flags enableSyncBeta: boolean, + enableUnitTestingBeta: boolean, }; export type Settings = BaseModel & BaseSettings; @@ -112,6 +113,7 @@ export function init(): BaseSettings { // Feature flags enableSyncBeta: false, + enableUnitTestingBeta: false, }; } diff --git a/packages/insomnia-app/app/models/unit-test-result.js b/packages/insomnia-app/app/models/unit-test-result.js new file mode 100644 index 0000000000..9cfbd235fe --- /dev/null +++ b/packages/insomnia-app/app/models/unit-test-result.js @@ -0,0 +1,45 @@ +// @flow +import * as db from '../common/database'; +import type { BaseModel } from './index'; + +export const name = 'Unit Test Result'; +export const type = 'UnitTestResult'; +export const prefix = 'utr'; +export const canDuplicate = false; +export const canSync = false; + +type BaseUnitTestResult = { + results: Object, +}; + +export type UnitTestResult = BaseModel & BaseUnitTestResult; + +export function init() { + return { + results: null, + }; +} + +export function migrate(doc: UnitTestResult): UnitTestResult { + return doc; +} + +export function create(patch: $Shape = {}) { + if (!patch.parentId) { + throw new Error('New UnitTestResult missing `parentId` ' + JSON.stringify(patch)); + } + + return db.docCreate(type, patch); +} + +export function update(unitTest: UnitTestResult, patch: $Shape) { + return db.docUpdate(unitTest, patch); +} + +export function getByParentId(parentId: string) { + return db.getWhere(type, { parentId }); +} + +export function all(): Promise> { + return db.all(type); +} diff --git a/packages/insomnia-app/app/models/unit-test-suite.js b/packages/insomnia-app/app/models/unit-test-suite.js new file mode 100644 index 0000000000..c0186c66f9 --- /dev/null +++ b/packages/insomnia-app/app/models/unit-test-suite.js @@ -0,0 +1,52 @@ +// @flow +import * as db from '../common/database'; +import type { BaseModel } from './index'; + +export const name = 'Unit Test Suite'; +export const type = 'UnitTestSuite'; +export const prefix = 'uts'; +export const canDuplicate = true; +export const canSync = true; + +type BaseUnitTestSuite = { + name: string, +}; + +export type UnitTestSuite = BaseModel & BaseUnitTestSuite; + +export function init() { + return { + name: 'My Test', + }; +} + +export function migrate(doc: UnitTestSuite): UnitTestSuite { + return doc; +} + +export function create(patch: $Shape = {}) { + if (!patch.parentId) { + throw new Error('New UnitTestSuite missing `parentId` ' + JSON.stringify(patch)); + } + + return db.docCreate(type, patch); +} + +export function update( + unitTestSuite: UnitTestSuite, + patch: $Shape = {}, +): Promise { + return db.docUpdate(unitTestSuite, patch); +} + +export function remove(unitTestSuite: UnitTestSuite): Promise { + return db.remove(unitTestSuite); +} + +export function getByParentId(parentId: string): Promise { + return db.getWhere(type, { parentId }); +} + +export function all(): Promise> { + return db.all(type); +} diff --git a/packages/insomnia-app/app/models/unit-test.js b/packages/insomnia-app/app/models/unit-test.js new file mode 100644 index 0000000000..d27bc6c68b --- /dev/null +++ b/packages/insomnia-app/app/models/unit-test.js @@ -0,0 +1,53 @@ +// @flow +import * as db from '../common/database'; +import type { BaseModel } from './index'; + +export const name = 'Unit Test'; +export const type = 'UnitTest'; +export const prefix = 'ut'; +export const canDuplicate = true; +export const canSync = true; + +type BaseUnitTest = { + name: string, + code: string, + requestId: string | null, +}; + +export type UnitTest = BaseModel & BaseUnitTest; + +export function init() { + return { + requestId: null, + name: 'My Test', + code: '', + }; +} + +export function migrate(doc: UnitTest): UnitTest { + return doc; +} + +export function create(patch: $Shape = {}): Promise { + if (!patch.parentId) { + throw new Error('New UnitTest missing `parentId` ' + JSON.stringify(patch)); + } + + return db.docCreate(type, patch); +} + +export function remove(unitTest: UnitTest): Promise { + return db.remove(unitTest); +} + +export function update(unitTest: UnitTest, patch: $Shape = {}): Promise { + return db.docUpdate(unitTest, patch); +} + +export function getByParentId(parentId: string): Promise { + return db.getWhere(type, { parentId }); +} + +export function all(): Promise> { + return db.all(type); +} diff --git a/packages/insomnia-app/app/models/workspace-meta.js b/packages/insomnia-app/app/models/workspace-meta.js index 04e2012391..f3e9a9eaf4 100644 --- a/packages/insomnia-app/app/models/workspace-meta.js +++ b/packages/insomnia-app/app/models/workspace-meta.js @@ -14,41 +14,43 @@ export const canDuplicate = false; export const canSync = false; type BaseWorkspaceMeta = { - activeRequestId: string | null, - activeEnvironmentId: string | null, activeActivity: string | null, - sidebarFilter: string, - sidebarHidden: boolean, - previewHidden: boolean, - sidebarWidth: number, - paneWidth: number, - paneHeight: number, - hasSeen: boolean, - gitRepositoryId: string | null, - cachedGitRepositoryBranch: string | null, + activeEnvironmentId: string | null, + activeRequestId: string | null, + activeUnitTestSuiteId: string | null, cachedGitLastAuthor: string | null, cachedGitLastCommitTime: number | null, + cachedGitRepositoryBranch: string | null, + gitRepositoryId: string | null, + hasSeen: boolean, + paneHeight: number, + paneWidth: number, + previewHidden: boolean, + sidebarFilter: string, + sidebarHidden: boolean, + sidebarWidth: number, }; export type WorkspaceMeta = BaseWorkspaceMeta & BaseModel; export function init(): BaseWorkspaceMeta { return { - parentId: null, - activeRequestId: null, - activeEnvironmentId: null, activeActivity: null, - sidebarFilter: '', - sidebarHidden: false, - previewHidden: false, - sidebarWidth: DEFAULT_SIDEBAR_WIDTH, - paneWidth: DEFAULT_PANE_WIDTH, - paneHeight: DEFAULT_PANE_HEIGHT, - hasSeen: true, - gitRepositoryId: null, - cachedGitRepositoryBranch: null, + activeEnvironmentId: null, + activeRequestId: null, + activeUnitTestSuiteId: null, cachedGitLastAuthor: null, cachedGitLastCommitTime: null, + cachedGitRepositoryBranch: null, + gitRepositoryId: null, + hasSeen: true, + paneHeight: DEFAULT_PANE_HEIGHT, + paneWidth: DEFAULT_PANE_WIDTH, + parentId: null, + previewHidden: false, + sidebarFilter: '', + sidebarHidden: false, + sidebarWidth: DEFAULT_SIDEBAR_WIDTH, }; } diff --git a/packages/insomnia-app/app/sync-legacy/index.js b/packages/insomnia-app/app/sync-legacy/index.js index 050e81b41e..844be3ce29 100644 --- a/packages/insomnia-app/app/sync-legacy/index.js +++ b/packages/insomnia-app/app/sync-legacy/index.js @@ -24,6 +24,8 @@ const WHITE_LIST = { [models.request.type]: true, [models.requestGroup.type]: true, [models.environment.type]: true, + [models.unitTest.type]: true, + [models.unitTestSuite.type]: true, // These can be overridden in sync config [models.cookieJar.type]: true, diff --git a/packages/insomnia-app/app/sync/git/__tests__/ne-db-plugin.test.js b/packages/insomnia-app/app/sync/git/__tests__/ne-db-plugin.test.js index 829b3a1944..73be68902a 100644 --- a/packages/insomnia-app/app/sync/git/__tests__/ne-db-plugin.test.js +++ b/packages/insomnia-app/app/sync/git/__tests__/ne-db-plugin.test.js @@ -39,6 +39,8 @@ describe.each(['win32', 'posix'])('NeDBPlugin using path.%s', type => { models.environment.type, models.request.type, models.requestGroup.type, + models.unitTest.type, + models.unitTestSuite.type, models.workspace.type, ]); diff --git a/packages/insomnia-app/app/sync/git/ne-db-plugin.js b/packages/insomnia-app/app/sync/git/ne-db-plugin.js index d143d2d360..a8ab4effc3 100644 --- a/packages/insomnia-app/app/sync/git/ne-db-plugin.js +++ b/packages/insomnia-app/app/sync/git/ne-db-plugin.js @@ -125,6 +125,8 @@ export default class NeDBPlugin { models.requestGroup.type, models.request.type, models.apiSpec.type, + models.unitTestSuite.type, + models.unitTest.type, ]; } else if (type !== null && id === null) { const workspace = await db.get(models.workspace.type, this._workspaceId); diff --git a/packages/insomnia-app/app/ui/components/codemirror/base-imports.js b/packages/insomnia-app/app/ui/components/codemirror/base-imports.js index f12192f3fd..7de597ca12 100644 --- a/packages/insomnia-app/app/ui/components/codemirror/base-imports.js +++ b/packages/insomnia-app/app/ui/components/codemirror/base-imports.js @@ -54,6 +54,7 @@ import './modes/nunjucks'; import './modes/curl'; import './modes/openapi'; import './lint/openapi'; +import './lint/javascript-async-lint'; import './extensions/autocomplete'; import './extensions/clickable'; import './extensions/nunjucks-tags'; @@ -62,6 +63,6 @@ import './extensions/nunjucks-tags'; import 'codemirror/lib/codemirror.css'; import '../../css/editor/index.less'; -// Make jsonlint available to the jsonlint plugin +// Make jsonlint available to the jsonlint addon import { parser as jsonlint } from 'jsonlint'; global.jsonlint = jsonlint; diff --git a/packages/insomnia-app/app/ui/components/codemirror/lint/javascript-async-lint.js b/packages/insomnia-app/app/ui/components/codemirror/lint/javascript-async-lint.js new file mode 100644 index 0000000000..f741e9f033 --- /dev/null +++ b/packages/insomnia-app/app/ui/components/codemirror/lint/javascript-async-lint.js @@ -0,0 +1,72 @@ +// This linter is a fork of Codemirror's built-in javascript linter that enables top-level +// await usage. It does this by wrapping the text in an async function, then offsetting any +// lint errors to account for the wrapper. +// +// The major modifications are marked in comments starting with "CHANGED ---" + +import CodeMirror from 'codemirror'; +import { JSHINT } from 'jshint'; + +CodeMirror.registerHelper('lint', 'javascript', validator); + +function validator(text, options) { + if (!options.indent) { + // JSHint error.character actually is a column index, this fixes underlining on lines using tabs for indentation + options.indent = 1; + } // JSHint default value is 4 + + // CHANGED ----------------------------------------------------------- // + // Wrap text with async function to "enable" top-level await + const textWithWrapper = `async function asyncWrapper() {\n${text}\n}`; + + JSHINT(textWithWrapper, options, options.globals); + const errors = JSHINT.data().errors; + const result = []; + + if (errors) { + parseErrors(errors, result); + } + + return result; +} + +function parseErrors(errors, output) { + for (let i = 0; i < errors.length; i++) { + const error = errors[i]; + if (error) { + if (error.line <= 0) { + if (window.console) { + window.console.warn( + 'Cannot display JSHint error (invalid line ' + error.line + ')', + error, + ); + } + continue; + } + + const start = error.character - 1; + let end = start + 1; + if (error.evidence) { + const index = error.evidence.substring(start).search(/.\b/); + if (index > -1) { + end += index; + } + } + + // Convert to format expected by validation service + const hint = { + message: error.reason, + severity: error.code ? (error.code.startsWith('W') ? 'warning' : 'error') : 'error', + from: CodeMirror.Pos(error.line - 1, start), + to: CodeMirror.Pos(error.line - 1, end), + }; + + // CHANGED ----------------------------------------------------------- // + // Offset an extra line to account for async wrapper function + hint.from.line -= 1; + hint.to.line -= 1; + + output.push(hint); + } + } +} diff --git a/packages/insomnia-app/app/ui/components/page-layout.js b/packages/insomnia-app/app/ui/components/page-layout.js index 61f187e03e..78c0776138 100644 --- a/packages/insomnia-app/app/ui/components/page-layout.js +++ b/packages/insomnia-app/app/ui/components/page-layout.js @@ -107,7 +107,6 @@ class PageLayout extends React.PureComponent { enableSyncBeta={settings.enableSyncBeta} environmentHighlightColorStyle={settings.environmentHighlightColorStyle} handleInitializeEntities={handleInitializeEntities} - handleNavigateHome={this._handleSetHomeActivity} handleSetActiveEnvironment={handleSetActiveEnvironment} handleSetActiveWorkspace={handleSetActiveWorkspace} hidden={sidebarHidden || false} diff --git a/packages/insomnia-app/app/ui/components/request-pane.js b/packages/insomnia-app/app/ui/components/request-pane.js index eb42849a62..c678c136af 100644 --- a/packages/insomnia-app/app/ui/components/request-pane.js +++ b/packages/insomnia-app/app/ui/components/request-pane.js @@ -8,7 +8,6 @@ import type { } from '../../models/request'; import type { Workspace } from '../../models/workspace'; import type { OAuth2Token } from '../../models/o-auth-2-token'; - import autobind from 'autobind-decorator'; import { deconstructQueryStringToParams, extractQueryStringFromUrl } from 'insomnia-url'; import * as React from 'react'; diff --git a/packages/insomnia-app/app/ui/components/response-pane.js b/packages/insomnia-app/app/ui/components/response-pane.js index ccd20f8797..848b9b3a74 100644 --- a/packages/insomnia-app/app/ui/components/response-pane.js +++ b/packages/insomnia-app/app/ui/components/response-pane.js @@ -31,6 +31,7 @@ import type { RequestVersion } from '../../models/request-version'; import { showError } from '../components/modals/index'; import { json as jsonPrettify } from 'insomnia-prettify'; import type { Environment } from '../../models/environment'; +import type { UnitTestResult } from '../../models/unit-test-result'; type Props = { // Functions @@ -61,6 +62,7 @@ type Props = { request: ?Request, response: ?Response, environment: ?Environment, + unitTestResult: ?UnitTestResult, }; @autobind diff --git a/packages/insomnia-app/app/ui/components/settings/general.js b/packages/insomnia-app/app/ui/components/settings/general.js index a52e47d625..78d4839392 100644 --- a/packages/insomnia-app/app/ui/components/settings/general.js +++ b/packages/insomnia-app/app/ui/components/settings/general.js @@ -4,11 +4,13 @@ import * as fontScanner from 'font-manager'; import * as electron from 'electron'; import autobind from 'autobind-decorator'; import HelpTooltip from '../help-tooltip'; +import type { HttpVersion } from '../../../common/constants'; import { EDITOR_KEY_MAP_DEFAULT, EDITOR_KEY_MAP_EMACS, EDITOR_KEY_MAP_SUBLIME, EDITOR_KEY_MAP_VIM, + getAppId, HttpVersions, isLinux, isMac, @@ -21,7 +23,7 @@ import { setFont } from '../../../plugins/misc'; import * as session from '../../../account/session'; import Tooltip from '../tooltip'; import CheckForUpdatesButton from '../check-for-updates-button'; -import type { HttpVersion } from '../../../common/constants'; +import { APP_ID_DESIGNER } from '../../../../config'; // Font family regex to match certain monospace fonts that don't get // recognized as monospace @@ -479,13 +481,6 @@ class General extends React.PureComponent {
- {session.isLoggedIn() && ( - -
- {this.renderBooleanSetting('Enable version control beta', 'enableSyncBeta', '', true)} -
- )} -

Data Sharing

@@ -508,6 +503,25 @@ class General extends React.PureComponent { as request data, names, etc.

+ + {session.isLoggedIn() && ( + +
+ {this.renderBooleanSetting('Enable version control beta', 'enableSyncBeta', '', true)} +
+ )} + + {getAppId() === APP_ID_DESIGNER && ( + <> +
+ {this.renderBooleanSetting( + 'Enable unit testing beta', + 'enableUnitTestingBeta', + '', + false, + )} + + )} ); } diff --git a/packages/insomnia-app/app/ui/components/sidebar/sidebar.js b/packages/insomnia-app/app/ui/components/sidebar/sidebar.js index a14cc7649c..1495bb2d63 100644 --- a/packages/insomnia-app/app/ui/components/sidebar/sidebar.js +++ b/packages/insomnia-app/app/ui/components/sidebar/sidebar.js @@ -16,7 +16,6 @@ type Props = {| children: React.Node, enableSyncBeta: boolean, environmentHighlightColorStyle: string, - handleNavigateHome: () => any, handleSetActiveEnvironment: Function, handleSetActiveWorkspace: Function, hidden: boolean, diff --git a/packages/insomnia-app/app/ui/components/wrapper-debug.js b/packages/insomnia-app/app/ui/components/wrapper-debug.js index 68e560a950..029cf1d07a 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-debug.js +++ b/packages/insomnia-app/app/ui/components/wrapper-debug.js @@ -120,6 +120,8 @@ class WrapperDebug extends React.PureComponent { activeRequest, activeRequestResponses, activeResponse, + activeUnitTest, + activeUnitTestResult, activeWorkspace, environments, enableSyncBeta, @@ -265,6 +267,7 @@ class WrapperDebug extends React.PureComponent { nunjucksPowerUserMode={settings.nunjucksPowerUserMode} oAuth2Token={oAuth2Token} request={activeRequest} + unitTest={activeUnitTest} settings={settings} updateRequestAuthentication={handleUpdateRequestAuthentication} updateRequestBody={handleUpdateRequestBody} @@ -320,6 +323,7 @@ class WrapperDebug extends React.PureComponent { requestVersions={requestVersions} response={activeResponse} responses={activeRequestResponses} + unitTestResult={activeUnitTestResult} /> diff --git a/packages/insomnia-app/app/ui/components/wrapper-design.js b/packages/insomnia-app/app/ui/components/wrapper-design.js index d681e9ed4a..cf376458e7 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-design.js +++ b/packages/insomnia-app/app/ui/components/wrapper-design.js @@ -19,9 +19,8 @@ import generateConfigIcon from '../images/icn-gear.svg'; import * as models from '../../models/index'; import { parseApiSpec } from '../../common/api-specs'; import { getConfigGenerators } from '../../plugins'; -import AlertModal from './modals/alert-modal'; -import { ACTIVITY_HOME } from '../../common/constants'; import type { GlobalActivity } from '../../common/constants'; +import { ACTIVITY_HOME } from '../../common/constants'; import ActivityToggle from './activity-toggle'; const spectral = new Spectral(); @@ -73,32 +72,6 @@ class WrapperDesign extends React.PureComponent { showModal(GenerateConfigModal, { apiSpec: activeApiSpec }); } - async _handleDebugSpec(errors, e): Pomise { - e.preventDefault(); - if (errors) { - showModal(AlertModal, { - title: 'Error Generating Configuration', - message: - 'Some requests may not be available due to errors found in the specification. We recommend fixing errors before proceeding. 🤗', - okLabel: 'Proceed', - addCancel: true, - onConfirm: async () => { - const { - handleSetDebugActivity, - wrapperProps: { activeApiSpec }, - } = this.props; - await handleSetDebugActivity(activeApiSpec); - }, - }); - } else { - const { - handleSetDebugActivity, - wrapperProps: { activeApiSpec }, - } = this.props; - await handleSetDebugActivity(activeApiSpec); - } - } - async _handleTogglePreview() { await this.setState( prevState => ({ previewHidden: !prevState.previewHidden }), diff --git a/packages/insomnia-app/app/ui/components/wrapper-unit-test.js b/packages/insomnia-app/app/ui/components/wrapper-unit-test.js new file mode 100644 index 0000000000..d5e8d0115d --- /dev/null +++ b/packages/insomnia-app/app/ui/components/wrapper-unit-test.js @@ -0,0 +1,482 @@ +// @flow +import * as React from 'react'; +import autobind from 'autobind-decorator'; +import classnames from 'classnames'; +import PageLayout from './page-layout'; +import { Breadcrumb, Button, Dropdown, DropdownItem, Header, SvgIcon } from 'insomnia-components'; +import ErrorBoundary from './error-boundary'; +import CodeEditor from './codemirror/code-editor'; +import designerLogo from '../images/insomnia-designer-logo.svg'; +import type { WrapperProps } from './wrapper'; +import * as models from '../../models'; +import type { UnitTest } from '../../models/unit-test'; +import { generate, runTests } from 'insomnia-testing'; +import { showAlert, showModal, showPrompt } from './modals'; +import Editable from './base/editable'; +import type { SidebarChildObjects } from './sidebar/sidebar-children'; +import SelectModal from './modals/select-modal'; +import type { UnitTestSuite } from '../../models/unit-test-suite'; +import ActivityToggle from './activity-toggle'; +import { getSendRequestCallback } from '../../common/send-request'; +import type { GlobalActivity } from '../../common/constants'; +import { ACTIVITY_HOME } from '../../common/constants'; + +type Props = {| + children: SidebarChildObjects, + gitSyncDropdown: React.Node, + handleActivityChange: (workspaceId: string, activity: GlobalActivity) => Promise, + wrapperProps: WrapperProps, +|}; + +type State = {| + testsRunning: Array | null, +|}; + +@autobind +class WrapperUnitTest extends React.PureComponent { + state = { + testsRunning: null, + }; + + // Defining it here instead of in render() so it won't act as a changed prop + // when being passed to 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): 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): Array<{ name: string, value: () => Promise }> { + 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: '', + value: this.generateSendReqSnippet(unitTest.code, `'${request._id}'`), + })), + ], + onDone: v => resolve(v), + }); + }); + }, + }, + ]; + } + + async _handleCreateTestSuite(): Promise { + const { activeWorkspace } = this.props.wrapperProps; + 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); + }, + }); + } + + async _handleCreateTest(): Promise { + const { activeUnitTestSuite } = this.props.wrapperProps; + showPrompt({ + title: 'New Test', + defaultValue: 'Returns 200', + submitName: 'Create Test', + label: 'Test Name', + selectText: true, + onComplete: async name => { + await models.unitTest.create({ + parentId: activeUnitTestSuite._id, + code: this.generateSendReqSnippet('', ''), + name, + }); + }, + }); + } + + async _handleUnitTestCodeChange(unitTest: UnitTest, v: string): Promise { + await models.unitTest.update(unitTest, { code: v }); + } + + async _handleBreadcrumb(): void { + const { + handleActivityChange, + wrapperProps: { activeWorkspace }, + } = this.props; + await handleActivityChange(activeWorkspace._id, ACTIVITY_HOME); + } + + async _handleRunTests(): Promise { + const { activeUnitTests } = this.props.wrapperProps; + await this._runTests(activeUnitTests); + } + + async _handleRunTest(unitTest: UnitTest): Promise { + await this._runTests([unitTest]); + } + + async _handleDeleteTest(unitTest: UnitTest): Promise { + await models.unitTest.remove(unitTest); + } + + async _handleSetActiveRequest( + unitTest: UnitTest, + e: SyntheticEvent, + ): Promise { + const requestId = e.currentTarget.value === '__NULL__' ? null : e.currentTarget.value; + await models.unitTest.update(unitTest, { requestId }); + } + + async _handleDeleteUnitTestSuite(unitTestSuite: UnitTestSuite): Promise { + showAlert({ + title: `Delete ${unitTestSuite.name}`, + message: ( + + Really delete {unitTestSuite.name}? + + ), + addCancel: true, + onConfirm: async () => { + await models.unitTestSuite.remove(unitTestSuite); + }, + }); + } + + async _handleSetActiveUnitTestSuite(unitTestSuite: UnitTestSuite): Promise { + const { activeWorkspace } = this.props.wrapperProps; + await models.workspaceMeta.updateByParentId(activeWorkspace._id, { + activeUnitTestSuiteId: unitTestSuite._id, + }); + } + + async _handleChangeTestName(unitTest: UnitTest, name: string): Promise { + await models.unitTest.update(unitTest, { name }); + } + + async _runTests(unitTests: Array): Promise { + const { requests, activeWorkspace, activeEnvironment } = this.props.wrapperProps; + + this.setState({ testsRunning: unitTests }); + + const tests = []; + 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 results = await runTests(src, { + requests, + sendRequest: getSendRequestCallback(activeEnvironment ? activeEnvironment._id : null), + }); + + await models.unitTestResult.create({ results, parentId: activeWorkspace._id }); + + this.setState({ testsRunning: null }); + } + + buildSelectableRequests(): Array<{ name: string, request: Request }> { + const { children } = this.props; + const selectableRequests: Array<{ name: string, request: Request }> = []; + + const next = (p, children) => { + for (const c of children) { + if (c.doc.type === models.request.type) { + selectableRequests.push({ + name: `${p} [${c.doc.method}] ${c.doc.name}`, + request: c.doc, + }); + } else if (c.doc.type === models.requestGroup.type) { + next(c.doc.name + ' / ', c.children); + } + } + }; + + next('', children.all); + + return selectableRequests; + } + + renderResults(): React.Node { + const { activeUnitTestResult } = this.props.wrapperProps; + const { testsRunning } = this.state; + + if (testsRunning) { + return ( +
+

Running {testsRunning.length} Tests...

+
+ ); + } + + if (!activeUnitTestResult) { + return null; + } + + const { stats, tests } = activeUnitTestResult.results; + + return ( +
+ {activeUnitTestResult && ( +
+ {stats.failures ? ( +

+ Tests Failed {stats.failures}/{stats.tests} +

+ ) : ( +

+ Tests Passed {stats.passes}/{stats.tests} +

+ )} +
    + {tests.map((t, i) => ( +
  • + {t.fullTitle} ({t.duration}{' '} + ms) + {t.err.message && ( + <> +
    + {t.err.message} + + )} +
  • + ))} +
+
+ )} +
+ ); + } + + renderUnitTest(unitTest: UnitTest): React.Node { + const { settings } = this.props.wrapperProps; + const { testsRunning } = this.state; + + const selectableRequests = this.buildSelectableRequests(); + + return ( +
+
+

+ +

+
+ +
+ ( + + )}> + t._id === unitTest._id)} + onClick={() => this._handleRunTest(unitTest)}> + Run Test + + this._handleDeleteTest(unitTest)}>Delete + +
+ this.autocompleteSnippets(unitTest)} + lintOptions={WrapperUnitTest.lintOptions} + onChange={this._handleUnitTestCodeChange.bind(this, unitTest)} + nunjucksPowerUserMode={settings.nunjucksPowerUserMode} + isVariableUncovered={settings.isVariableUncovered} + mode="javascript" + lineWrapping={settings.editorLineWrapping} + placeholder="" + /> +
+ ); + } + + renderPageBody(): React.Node { + const { activeUnitTests, activeUnitTestSuite } = this.props.wrapperProps; + + if (!activeUnitTestSuite) { + return ( +
+
No test suite selected
+
+ ); + } + + return ( +
+
+ {activeUnitTests.map(this.renderUnitTest)} +
+ +
+
+ {this.renderResults()} +
+ ); + } + + renderPageSidebar(): React.Node { + const { activeUnitTestSuites, activeUnitTestSuite } = this.props.wrapperProps; + const activeId = activeUnitTestSuite ? activeUnitTestSuite._id : 'n/a'; + + return ( + +
+
+ +
+
    + {activeUnitTestSuites.map(s => ( +
  • + + ( + + )}> + + Delete Suite + + +
  • + ))} +
+
+
+ ); + } + + render() { + const { handleActivityChange, gitSyncDropdown } = this.props; + const { activeWorkspace, activity, settings, activeApiSpec } = this.props.wrapperProps; + const { testsRunning } = this.state; + return ( + ( +
+ Insomnia + + + } + gridCenter={ + + } + gridRight={ + <> + + {gitSyncDropdown} + + } + /> + )} + /> + ); + } +} + +export default WrapperUnitTest; diff --git a/packages/insomnia-app/app/ui/components/wrapper.js b/packages/insomnia-app/app/ui/components/wrapper.js index 04583d1d85..af5c6e9e14 100644 --- a/packages/insomnia-app/app/ui/components/wrapper.js +++ b/packages/insomnia-app/app/ui/components/wrapper.js @@ -12,7 +12,6 @@ import type { RequestParameter, } from '../../models/request'; import type { SidebarChildObjects } from './sidebar/sidebar-children'; - import * as React from 'react'; import autobind from 'autobind-decorator'; import { registerModal, showModal } from './modals/index'; @@ -71,21 +70,26 @@ import { trackPageView } from '../../common/analytics'; import type { GitRepository } from '../../models/git-repository'; import WrapperHome from './wrapper-home'; import WrapperDesign from './wrapper-design'; +import WrapperUnitTest from './wrapper-unit-test'; import WrapperOnboarding from './wrapper-onboarding'; import WrapperDebug from './wrapper-debug'; import { importRaw } from '../../common/import'; import GitSyncDropdown from './dropdowns/git-sync-dropdown'; import { DropdownButton } from './base/dropdown'; import type { ForceToWorkspace } from '../redux/modules/helpers'; +import type { UnitTest } from '../../models/unit-test'; +import type { UnitTestResult } from '../../models/unit-test-result'; +import type { UnitTestSuite } from '../../models/unit-test-suite'; +import type { GlobalActivity } from '../../common/constants'; import { ACTIVITY_DEBUG, ACTIVITY_HOME, ACTIVITY_INSOMNIA, ACTIVITY_SPEC, + ACTIVITY_UNIT_TEST, getAppName, } from '../../common/constants'; import { Spectral } from '@stoplight/spectral'; -import type { GlobalActivity } from '../../common/constants'; const spectral = new Spectral(); @@ -177,9 +181,12 @@ export type WrapperProps = { activeCookieJar: CookieJar, activeEnvironment: Environment | null, activeGitRepository: GitRepository | null, + activeUnitTestResult: UnitTestResult | null, + activeUnitTestSuites: Array, + activeUnitTests: Array, activeWorkspaceClientCertificates: Array, - isVariableUncovered: boolean, headerEditorKey: string, + isVariableUncovered: boolean, vcs: VCS | null, gitVCS: GitVCS | null, gitRepositories: Array, @@ -305,7 +312,8 @@ class Wrapper extends React.PureComponent { await models.workspaceMeta.updateByParentId(workspaceId, { activeActivity: nextActivity }); } - if (activity !== ACTIVITY_SPEC) { + const notEditingASpec = activity !== ACTIVITY_SPEC; + if (notEditingASpec) { handleSetActiveActivity(nextActivity); return; } @@ -350,12 +358,6 @@ class Wrapper extends React.PureComponent { this.props.handleImportFileToWorkspace(this.props.activeWorkspace._id, forceToWorkspace); } - _handleUpdateSettingsUseBulkParametersEditor( - useBulkParametersEditor: boolean, - ): Promise { - return sUpdate(this.props.settings, { useBulkParametersEditor }); - } - _handleImportUri(uri: string, forceToWorkspace?: ForceToWorkspace): void { this.props.handleImportUriToWorkspace(this.props.activeWorkspace._id, uri, forceToWorkspace); } @@ -783,6 +785,15 @@ class Wrapper extends React.PureComponent { /> )} + {activity === ACTIVITY_UNIT_TEST && ( + + )} + {(activity === ACTIVITY_DEBUG || activity === ACTIVITY_INSOMNIA) && ( s.parentId === activeWorkspace._id); + // Test stuff + const activeUnitTests = selectActiveUnitTests(state, props); + const activeUnitTestSuite = selectActiveUnitTestSuite(state, props); + const activeUnitTestSuites = selectActiveUnitTestSuites(state, props); + const activeUnitTestResult = selectActiveUnitTestResult(state, props); + return Object.assign({}, state, { activity: activeActivity, activeApiSpec, @@ -1432,6 +1442,10 @@ function mapStateToProps(state, props) { activeRequest, activeRequestResponses, activeResponse, + activeUnitTestResult, + activeUnitTestSuite, + activeUnitTestSuites, + activeUnitTests, activeWorkspace, activeWorkspaceClientCertificates, activeWorkspaceMeta, diff --git a/packages/insomnia-app/app/ui/css/components/unit-tests.less b/packages/insomnia-app/app/ui/css/components/unit-tests.less new file mode 100644 index 0000000000..536004a002 --- /dev/null +++ b/packages/insomnia-app/app/ui/css/components/unit-tests.less @@ -0,0 +1,153 @@ +.unit-tests { + display: grid; + width: 100%; + height: 100%; + + grid-template-areas: 'appheader appheader' 'body-left body-right'; + grid-template-rows: auto 1fr; + grid-template-columns: 2fr 1fr; + + border-left: 1px solid var(--hl-md); + + &.preview-hidden { + grid-template-columns: 1fr 0; + } + + &__header { + grid-area: header; + background: var(--color-bg); + color: var(--color-font); + display: flex; + flex-direction: row; + align-items: center; + border-bottom: 1px solid var(--hl-md); + + &__buttons { + margin-left: auto; + + & > button { + align-self: stretch; + height: 100%; + background: none; + border: none !important; + } + } + + h1 { + padding: 0 var(--padding-md); + margin: 0; + } + } + + &__tests { + grid-area: body-left; + background: var(--color-bg); + color: var(--color-font); + display: flex; + flex-direction: column; + overflow-y: auto; + + &__block { + height: auto; + width: 100%; + border-bottom: 1px dashed var(--hl-md); + padding-bottom: var(--padding-md); + + .editor { + overflow-y: hidden !important; + } + + &__header { + display: flex; + flex-direction: row; + align-items: center; + + h2 { + font-size: var(--font-size-lg); + white-space: nowrap; + } + + & > * { + margin-top: var(--padding-md); + margin-bottom: var(--padding-md); + margin-right: var(--padding-md); + } + + & > *:first-child { + margin-right: auto; + margin-left: var(--padding-md); + } + + .form-control { + width: 100%; + max-width: 18rem; + + * { + margin: 0; + } + } + } + } + } + + &__results { + padding-left: var(--padding-md); + border-left: 1px solid var(--hl-md); + grid-area: body-right; + background: var(--color-bg); + color: var(--color-font); + display: grid; + grid-template-columns: 100%; + grid-template-rows: 1fr auto; + + li { + display: block; + padding: var(--padding-xs) 0; + } + } + + &__sidebar { + display: flex; + flex-direction: column; + + li { + display: flex; + color: var(--hl); + + &:first-child { + width: 100%; + align-items: center; + } + + .unit-tests__sidebar__action { + display: none; + color: var(--hl); + + &:hover { + color: var(--color-font); + } + } + + &:hover .unit-tests__sidebar__action { + display: block; + } + + &:hover { + background-color: var(--hl-sm); + color: var(--color-font); + } + + &.active { + background-color: var(--hl-xs); + color: var(--color-font); + } + + button { + padding: 0 var(--padding-sm); + height: var(--line-height-xs); + width: 100%; + box-sizing: border-box; + } + } + } +} diff --git a/packages/insomnia-app/app/ui/css/index.less b/packages/insomnia-app/app/ui/css/index.less index 901aea6f4a..0604d9afb2 100644 --- a/packages/insomnia-app/app/ui/css/index.less +++ b/packages/insomnia-app/app/ui/css/index.less @@ -52,6 +52,7 @@ @import 'components/shortcuts'; @import 'components/sidebar'; @import 'components/spec-editor'; +@import 'components/unit-tests'; @import 'components/spec-editor-sidebar'; @import 'components/tabs'; @import 'components/tag'; diff --git a/packages/insomnia-app/app/ui/redux/modules/entities.js b/packages/insomnia-app/app/ui/redux/modules/entities.js index b3473c211c..56029775e5 100644 --- a/packages/insomnia-app/app/ui/redux/modules/entities.js +++ b/packages/insomnia-app/app/ui/redux/modules/entities.js @@ -115,5 +115,8 @@ export async function allDocs() { ...(await models.oAuth2Token.all()), ...(await models.clientCertificate.all()), ...(await models.apiSpec.all()), + ...(await models.unitTestSuite.all()), + ...(await models.unitTest.all()), + ...(await models.unitTestResult.all()), ]; } diff --git a/packages/insomnia-app/app/ui/redux/selectors.js b/packages/insomnia-app/app/ui/redux/selectors.js index 62fa707426..a041e4e524 100644 --- a/packages/insomnia-app/app/ui/redux/selectors.js +++ b/packages/insomnia-app/app/ui/redux/selectors.js @@ -324,6 +324,63 @@ export const selectActiveResponse = createSelector( }, ); +export const selectActiveUnitTestResult = createSelector( + selectEntitiesLists, + selectActiveWorkspace, + (entities, activeWorkspace) => { + let recentResult = null; + for (const r of entities.unitTestResults) { + if (r.parentId !== activeWorkspace._id) { + continue; + } + + if (!recentResult) { + recentResult = r; + continue; + } + + if (r.created > recentResult.created) { + recentResult = r; + } + } + + return recentResult; + }, +); + +export const selectActiveUnitTestSuite = createSelector( + selectEntitiesLists, + selectActiveWorkspaceMeta, + (entities, activeWorkspaceMeta) => { + if (!activeWorkspaceMeta) { + return null; + } + + const id = activeWorkspaceMeta.activeUnitTestSuiteId; + return entities.unitTestSuites.find(s => s._id === id) || null; + }, +); + +export const selectActiveUnitTests = createSelector( + selectEntitiesLists, + selectActiveUnitTestSuite, + (entities, activeUnitTestSuite) => { + if (!activeUnitTestSuite) { + return []; + } + + return entities.unitTests.filter(s => s.parentId === activeUnitTestSuite._id); + }, +); + +export const selectActiveUnitTestSuites = createSelector( + selectEntitiesLists, + selectActiveWorkspace, + (entities, activeWorkspace) => { + return entities.unitTestSuites.filter(s => s.parentId === activeWorkspace._id); + }, +); + export const selectSyncItems = createSelector(selectActiveWorkspaceEntities, workspaceEntities => workspaceEntities.filter(models.canSync).map(doc => ({ key: doc._id, diff --git a/packages/insomnia-app/config/config.designer.json b/packages/insomnia-app/config/config.designer.json index ceae060035..0e6ae36663 100644 --- a/packages/insomnia-app/config/config.designer.json +++ b/packages/insomnia-app/config/config.designer.json @@ -1,5 +1,5 @@ { - "version": "2020.2.2", + "version": "2020.3.0-alpha.2", "name": "insomnia-designer", "executableName": "insomnia-designer", "appId": "com.insomnia.designer", diff --git a/packages/insomnia-components/components/button.js b/packages/insomnia-components/components/button.js index 757910ac25..0c5776c3b0 100644 --- a/packages/insomnia-components/components/button.js +++ b/packages/insomnia-components/components/button.js @@ -98,11 +98,6 @@ const StyledButton: React.ComponentType = styled.button` &:disabled { opacity: 60%; } - - svg { - display: inline-block !important; - margin-left: 0.4em; - } `; const Button = ({ variant, bg, size, ...props }: Props) => ( diff --git a/packages/insomnia-testing/src/__tests__/integration.test.js b/packages/insomnia-testing/src/__tests__/integration.test.js index a0fa3c94d9..ca5c8c47a3 100644 --- a/packages/insomnia-testing/src/__tests__/integration.test.js +++ b/packages/insomnia-testing/src/__tests__/integration.test.js @@ -66,6 +66,39 @@ describe('integration', () => { expect(stats2.passes).toBe(2); }); + it('generates and runs more than once', async () => { + const testSrc = await generate([ + { + name: 'Example TestSuite', + suites: [], + tests: [ + { + name: 'should return -1 when the value is not present', + code: 'expect([1, 2, 3].indexOf(4)).to.equal(-1);\nexpect(true).to.be.true;', + defaultRequestId: null, + }, + { + name: 'is an empty test', + code: '', + defaultRequestId: null, + }, + ], + }, + ]); + + const { stats, failures } = await runTests(testSrc); + expect(failures).toEqual([]); + expect(stats.tests).toBe(2); + expect(stats.failures).toBe(0); + expect(stats.passes).toBe(2); + + const { stats: stats2, failures: failures2 } = await runTests(testSrc); + expect(failures2).toEqual([]); + expect(stats2.tests).toBe(2); + expect(stats2.failures).toBe(0); + expect(stats2.passes).toBe(2); + }); + it('sends an HTTP request', async () => { axios.__setResponse('GET', '200.insomnia.rest', { status: 200, diff --git a/plugins/insomnia-plugin-core-themes/themes/studio-light.js b/plugins/insomnia-plugin-core-themes/themes/studio-light.js index e70831ecb4..f86276adfa 100644 --- a/plugins/insomnia-plugin-core-themes/themes/studio-light.js +++ b/plugins/insomnia-plugin-core-themes/themes/studio-light.js @@ -37,7 +37,7 @@ module.exports = { notice: '#c18401', warning: '#c18401', danger: '#e45649', - surprise: '#a626a4', + surprise: '#6030BF', info: '#0184bc', }, foreground: { diff --git a/plugins/insomnia-plugin-request/__tests__/index.test.js b/plugins/insomnia-plugin-request/__tests__/index.test.js index cde7b312df..70abfe3295 100644 --- a/plugins/insomnia-plugin-request/__tests__/index.test.js +++ b/plugins/insomnia-plugin-request/__tests__/index.test.js @@ -86,6 +86,23 @@ describe('plugin', () => { expect(result).toBe('bar'); }); }); + + describe('RequestExtension name', () => { + it('should get name', async () => { + const requests = [ + { + _id: 'req_1', + name: 'Foo', + parameters: [{ name: 'foo', value: '{{ foo }}' }], + url: 'https://insomnia.rest/foo/bar', + }, + ]; + const context = _getTestContext([{ _id: 'wrk_1' }], requests); + const result = await tag.run(context, 'name'); + + expect(result).toBe('Foo'); + }); + }); }); function _getTestContext(workspaces, requests, jars) {