From e4612735c0dd0086f84c18eded1d0a89155aaec1 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Fri, 23 Jun 2023 19:03:01 +0200 Subject: [PATCH] Add AI test generation (#6053) * savez * ai animation * save * fixes * improvements to ux and loading states * ux improvements * delete unused file * undo vscode settings changes * add prod api url * remove console * fixes * rename loading to generating * remove extra case in ai settings * generate a new folder for the generated requests * fix icon * fix unused import --- packages/insomnia/package-lock.json | 4 +- packages/insomnia/package.json | 4 +- packages/insomnia/src/common/api-specs.ts | 36 +++ packages/insomnia/src/common/import.ts | 17 +- packages/insomnia/src/network/network.ts | 4 + .../insomnia/src/ui/components/app-header.tsx | 5 +- .../components/base/dropdown/item-content.tsx | 4 +- .../dropdowns/workspace-dropdown.tsx | 44 +++ .../src/ui/components/insomnia-ai-icon.tsx | 37 +++ .../src/ui/components/insomnia-icon.tsx | 173 ++++++++++++ .../ui/components/modals/settings-modal.tsx | 8 + .../src/ui/components/settings/ai.tsx | 104 +++++++ .../spec-editor/spec-editor-sidebar.tsx | 34 ++- .../insomnia/src/ui/components/statusbar.tsx | 12 +- .../src/ui/context/app/ai-context.tsx | 119 ++++++++ packages/insomnia/src/ui/css/layout/base.less | 30 ++ packages/insomnia/src/ui/index.tsx | 23 ++ packages/insomnia/src/ui/routes/actions.tsx | 265 +++++++++++++++++- packages/insomnia/src/ui/routes/root.tsx | 65 +++-- 19 files changed, 938 insertions(+), 50 deletions(-) create mode 100644 packages/insomnia/src/ui/components/insomnia-ai-icon.tsx create mode 100644 packages/insomnia/src/ui/components/insomnia-icon.tsx create mode 100644 packages/insomnia/src/ui/components/settings/ai.tsx create mode 100644 packages/insomnia/src/ui/context/app/ai-context.tsx diff --git a/packages/insomnia/package-lock.json b/packages/insomnia/package-lock.json index 80779dfdb..4e4bc1b7e 100644 --- a/packages/insomnia/package-lock.json +++ b/packages/insomnia/package-lock.json @@ -137,13 +137,13 @@ "objectpath": "^2.0.0", "openapi-types": "^7.0.1", "react": "^18.2.0", - "react-aria": "^3.23.1", + "react-aria": "3.23.1", "react-dnd": "^7.4.5", "react-dnd-html5-backend": "^7.4.4", "react-dom": "^18.2.0", "react-redux": "^7.2.6", "react-router-dom": "^6.4.2", - "react-stately": "^3.21.0", + "react-stately": "3.21.0", "react-use": "^17.4.0", "redux": "^4.1.2", "redux-mock-store": "^1.5.4", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index e82ec9c61..ee9981b0f 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -189,13 +189,13 @@ "objectpath": "^2.0.0", "openapi-types": "^7.0.1", "react": "^18.2.0", - "react-aria": "^3.23.1", + "react-aria": "3.23.1", "react-dnd": "^7.4.5", "react-dnd-html5-backend": "^7.4.4", "react-dom": "^18.2.0", "react-redux": "^7.2.6", "react-router-dom": "^6.4.2", - "react-stately": "^3.21.0", + "react-stately": "3.21.0", "react-use": "^17.4.0", "redux": "^4.1.2", "redux-mock-store": "^1.5.4", diff --git a/packages/insomnia/src/common/api-specs.ts b/packages/insomnia/src/common/api-specs.ts index 97efc58ad..5471c04b1 100644 --- a/packages/insomnia/src/common/api-specs.ts +++ b/packages/insomnia/src/common/api-specs.ts @@ -40,3 +40,39 @@ export function parseApiSpec( return result; } + +export function resolveComponentSchemaRefs( + spec: ParsedApiSpec, + methodInfo: Record, +) { + const schemas = spec.contents?.components?.schemas; + if (!schemas) { + return; + } + + const resolveRefs = (obj: Record): Record => { + if (typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(resolveRefs); + } + + if (obj.$ref) { + const ref = obj.$ref.replace('#/components/schemas/', ''); + return resolveRefs(schemas[ref]); + } + + const resolved: Record = {}; + for (const [key, value] of Object.entries(obj)) { + resolved[key] = resolveRefs(value); + } + + return resolved; + }; + + const resolved = resolveRefs(methodInfo); + + return resolved; +} diff --git a/packages/insomnia/src/common/import.ts b/packages/insomnia/src/common/import.ts index 1ae791d52..7926fd927 100644 --- a/packages/insomnia/src/common/import.ts +++ b/packages/insomnia/src/common/import.ts @@ -225,9 +225,14 @@ export async function importResources({ } await db.flushChanges(bufferId); + const resourcesWithIds = resources.map(r => ({ + ...r, + _id: ResourceIdMap.get(r._id), + parentId: ResourceIdMap.get(r.parentId), + })); return { - resources, + resources: resourcesWithIds, workspace: existingWorkspace, }; } else { @@ -318,8 +323,6 @@ export async function importResources({ const subEnvironments = resources.filter(isEnvironment).filter(env => env.parentId.startsWith(models.environment.prefix)) || []; - console.log({ subEnvironments }); - if (subEnvironments.length > 0) { const firstSubEnvironment = subEnvironments[0]; @@ -336,8 +339,14 @@ export async function importResources({ await db.flushChanges(bufferId); + const resourcesWithIds = resources.map(r => ({ + ...r, + _id: ResourceIdMap.get(r._id), + parentId: ResourceIdMap.get(r.parentId), + })); + return { - resources, + resources: resourcesWithIds, workspace: newWorkspace, }; } diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index f2aa781ff..32feacd7b 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -139,6 +139,10 @@ export const tryToInterpolateRequest = async (request: Request, environmentId: s extraInfo, }); } catch (err) { + // @TODO Find a better way to detect missing environment variables in requests and show a more helpful error + if ('type' in err && err.type === 'render') { + throw new Error('Failed to run the request. This is likely due to missing environment variables that are referenced in the request.'); + } throw new Error(`Failed to render request: ${request._id}`); } }; diff --git a/packages/insomnia/src/ui/components/app-header.tsx b/packages/insomnia/src/ui/components/app-header.tsx index 2d8d81a33..5b6a05233 100644 --- a/packages/insomnia/src/ui/components/app-header.tsx +++ b/packages/insomnia/src/ui/components/app-header.tsx @@ -3,13 +3,12 @@ import React, { FC, Fragment, ReactNode } from 'react'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; -import coreLogo from '../images/insomnia-logo.svg'; import { selectIsLoggedIn } from '../redux/selectors'; import { GitHubStarsButton } from './github-stars-button'; +import { InsomniaAILogo } from './insomnia-icon'; const LogoWrapper = styled.div({ display: 'flex', - width: '50px', justifyContent: 'center', }); @@ -83,7 +82,7 @@ export const AppHeader: FC = ({ gridLeft={( - Insomnia + {!isLoggedIn ? : null} diff --git a/packages/insomnia/src/ui/components/base/dropdown/item-content.tsx b/packages/insomnia/src/ui/components/base/dropdown/item-content.tsx index 0b9a15100..5347a459b 100644 --- a/packages/insomnia/src/ui/components/base/dropdown/item-content.tsx +++ b/packages/insomnia/src/ui/components/base/dropdown/item-content.tsx @@ -63,7 +63,7 @@ const Checkmark = styled(SvgIcon)({ }); type ItemContentProps = PropsWithChildren<{ - icon?: string; + icon?: string | ReactNode; label?: string | ReactNode; hint?: PlatformKeyCombinations; className?: string; @@ -82,7 +82,7 @@ export const ItemContent: FC = (props: ItemContentProps) => { const content = ( <> - {icon && } + {icon && typeof icon === 'string' ? : icon} {children || label} {hint && } diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx index 7673dad6c..2712a0a54 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx @@ -1,6 +1,7 @@ import React, { FC, useCallback, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; +import { isLoggedIn } from '../../../account/session'; import { database as db } from '../../../common/database'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render'; @@ -10,8 +11,10 @@ import { isDesign, Workspace } from '../../../models/workspace'; import type { WorkspaceAction } from '../../../plugins'; import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../../../plugins'; import * as pluginContexts from '../../../plugins/context'; +import { useAIContext } from '../../context/app/ai-context'; import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors'; import { type DropdownHandle, Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown'; +import { InsomniaAI } from '../insomnia-ai-icon'; import { showError, showModal } from '../modals'; import { showGenerateConfigModal } from '../modals/generate-config-modal'; import { SettingsModal, TAB_INDEX_EXPORT } from '../modals/settings-modal'; @@ -30,6 +33,12 @@ export const WorkspaceDropdown: FC = () => { const [loadingActions, setLoadingActions] = useState>({}); const dropdownRef = useRef(null); + const { + generating: loading, + access, + generateTests, + } = useAIContext(); + const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => { setLoadingActions({ ...loadingActions, [label]: true }); try { @@ -96,6 +105,7 @@ export const WorkspaceDropdown: FC = () => { { } + + + {item => + + + + } + isDisabled={loading} + label={item.label} + onClick={item.action} + /> + + } + ); }; diff --git a/packages/insomnia/src/ui/components/insomnia-ai-icon.tsx b/packages/insomnia/src/ui/components/insomnia-ai-icon.tsx new file mode 100644 index 000000000..be0793fd2 --- /dev/null +++ b/packages/insomnia/src/ui/components/insomnia-ai-icon.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { useAIContext } from '../context/app/ai-context'; + +export const InsomniaAI = ({ + ...props +}: React.SVGProps) => { + const { generating: loading } = useAIContext(); + + return ( + + + {loading && ( + + )} + + + ); +}; diff --git a/packages/insomnia/src/ui/components/insomnia-icon.tsx b/packages/insomnia/src/ui/components/insomnia-icon.tsx new file mode 100644 index 000000000..2fc1c5277 --- /dev/null +++ b/packages/insomnia/src/ui/components/insomnia-icon.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; + +import { useAIContext } from '../context/app/ai-context'; + +const SlideInLeftKeyframes = keyframes` + 0% { + opacity: 0; + transform: translateX(-100%); + } + + 100% { + opacity: 1; + transform: translateX(0); + } +`; + +const FadeInKeyframes = keyframes` + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +`; + +const Layout = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 'var(--padding-xs)', + paddingLeft: '11px', + position: 'relative', +}); + +const RelativeFrame = styled.div({ + position: 'relative', + display: 'flex', + alignItems: 'center', + gap: 'var(--padding-xs)', +}); + +const AILoadingText = styled.div` + display: flex; + z-index: 1; + align-items: center; + height: 100%; + font-size: var(--font-size-small); + color: var(--color-font); + padding-right: var(--padding-sm); + opacity: 0; + animation: ${FadeInKeyframes} 0.1s 0.3s ease-out forwards; +`; + +const LoadingBoundary = styled.div({ + display: 'flex', + width: 'calc(100% + 4px)', + height: 'calc(100% + 2px)', + position: 'absolute', + overflow: 'hidden', + borderRadius: '60px', +}); + +const LoadingBar = styled.div` + display: flex; + width: 100%; + height: 100%; + position: absolute; + inset: 0; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 60px; + opacity: 0; + animation: ${SlideInLeftKeyframes} 0.4s ease-out forwards; +`; + +const LoadingBarIndicator = styled.div` + display: flex; + width: 100%; + height: 100%; + position: absolute; + inset: 0; + background-color: #7400e1; + border-radius: 60px; + opacity: 1; + transform: translateX(-100%); +`; + +export const InsomniaAILogo = ({ + ...props +}: React.SVGProps) => { + const { + generating: loading, + progress, + } = useAIContext(); + + const loadingProgress = 100 - (progress.progress / progress.total) * 100; + + return ( + + + + + + + {loading && ( + + )} + + + + + + + + + {loading && + + + + } + {loading && ( + + {'AI is thinking...'} + + )} + + + ); +}; diff --git a/packages/insomnia/src/ui/components/modals/settings-modal.tsx b/packages/insomnia/src/ui/components/modals/settings-modal.tsx index 8e9d87b56..179f5a8cb 100644 --- a/packages/insomnia/src/ui/components/modals/settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/settings-modal.tsx @@ -7,6 +7,7 @@ import { ModalBody } from '../base/modal-body'; import { ModalHeader } from '../base/modal-header'; import { PanelContainer, TabItem, Tabs } from '../base/tabs'; import { Account } from '../settings/account'; +import { AI } from '../settings/ai'; import { General } from '../settings/general'; import { ImportExport } from '../settings/import-export'; import { Plugins } from '../settings/plugins'; @@ -23,6 +24,8 @@ export const TAB_INDEX_EXPORT = 'data'; export const TAB_INDEX_SHORTCUTS = 'keyboard'; export const TAB_INDEX_THEMES = 'themes'; export const TAB_INDEX_PLUGINS = 'plugins'; +export const TAB_INDEX_AI = 'ai'; + export const SettingsModal = forwardRef((props, ref) => { const [defaultTabKey, setDefaultTabKey] = useState('general'); const modalRef = useRef(null); @@ -79,6 +82,11 @@ export const SettingsModal = forwardRef((props, + + + + + diff --git a/packages/insomnia/src/ui/components/settings/ai.tsx b/packages/insomnia/src/ui/components/settings/ai.tsx new file mode 100644 index 000000000..670c30f0c --- /dev/null +++ b/packages/insomnia/src/ui/components/settings/ai.tsx @@ -0,0 +1,104 @@ +import React, { Fragment, useCallback } from 'react'; + +import { isLoggedIn } from '../../../account/session'; +import { useAIContext } from '../../context/app/ai-context'; +import { Link } from '../base/link'; +import { InsomniaAI } from '../insomnia-ai-icon'; +import { hideAllModals, showModal } from '../modals'; +import { LoginModal } from '../modals/login-modal'; + +export const AI = () => { + const loggedIn = isLoggedIn(); + + const handleLogin = useCallback((event: React.SyntheticEvent) => { + event.preventDefault(); + hideAllModals(); + showModal(LoginModal); + }, []); + + const { + access: { + enabled, + loading, + }, + } = useAIContext(); + + if (loading) { + return
Loading...
; + } + + if (loggedIn && enabled) { + return +
+

Insomnia AI is enabled +

+

+ The Insomnia AI add-on is currently available on your account. The pay as-you-go consumption of this capability will be automatically added to your account and invoiced accordingly. +

+
+
+ Beware that too many requests of Insomnia AI could generate an unpredictable spend. +
+
+
; + } + + return ( + +
+

Try Insomnia AI

+

+ Improve your productivity with Insomnia AI and perform complex operations in 1-click, like auto-generating API tests for your documents and collections. +
+
+ This capability is an add-on to Enterprise customers only. +

+
+
+ + Enable Insomnia AI + +
+
+ {!loggedIn ?

+ Or{' '} + Log In + +

: null} +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx b/packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx index b842d8872..a8184aad2 100644 --- a/packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx +++ b/packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx @@ -4,6 +4,9 @@ import YAML from 'yaml'; import YAMLSourceMap from 'yaml-source-map'; import type { ApiSpec } from '../../../models/api-spec'; +import { useAIContext } from '../../context/app/ai-context'; +import { InsomniaAI } from '../insomnia-ai-icon'; +import { Button } from '../themed-button'; import { Sidebar } from './sidebar'; interface Props { @@ -17,6 +20,11 @@ const StyledSpecEditorSidebar = styled.div` `; export const SpecEditorSidebar: FC = ({ apiSpec, handleSetSelection }) => { + const { + generating: loading, + generateTestsFromSpec, + access, + } = useAIContext(); const onClick = (...itemPath: any[]): void => { const scrollPosition = { start: { line: 0, col: 0 }, end: { line: 0, col: 200 } }; @@ -24,7 +32,7 @@ export const SpecEditorSidebar: FC = ({ apiSpec, handleSetSelection }) => JSON.parse(apiSpec.contents); // Account for JSON (as string) line number shift scrollPosition.start.line = 1; - } catch {} + } catch { } const sourceMap = new YAMLSourceMap(); const specMap = sourceMap.index( @@ -48,8 +56,32 @@ export const SpecEditorSidebar: FC = ({ apiSpec, handleSetSelection }) => }; const specJSON = YAML.parse(apiSpec.contents); + return ( +
+ {access.enabled && ( + + )} +
); diff --git a/packages/insomnia/src/ui/components/statusbar.tsx b/packages/insomnia/src/ui/components/statusbar.tsx index 49202bd24..f1c63cef4 100644 --- a/packages/insomnia/src/ui/components/statusbar.tsx +++ b/packages/insomnia/src/ui/components/statusbar.tsx @@ -28,7 +28,17 @@ const KongLink = styled.a({ export const StatusBar: FC = () => { return - +
+ +
Made with   by Kong diff --git a/packages/insomnia/src/ui/context/app/ai-context.tsx b/packages/insomnia/src/ui/context/app/ai-context.tsx new file mode 100644 index 000000000..27faccb14 --- /dev/null +++ b/packages/insomnia/src/ui/context/app/ai-context.tsx @@ -0,0 +1,119 @@ +import React, { createContext, FC, PropsWithChildren, useContext, useEffect } from 'react'; +import { useFetcher, useFetchers, useParams } from 'react-router-dom'; +import { usePrevious } from 'react-use'; + +import { isLoggedIn } from '../../../account/session'; + +const AIContext = createContext({ + generating: false, + generateTests: () => { }, + generateTestsFromSpec: () => { }, + access: { + enabled: false, + loading: false, + }, + progress: { + total: 0, + progress: 0, + }, +}); + +export const AIProvider: FC = ({ children }) => { + const { + organizationId, + projectId, + workspaceId, + } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + }; + + const [progress, setProgress] = React.useState({ + total: 0, + progress: 0, + }); + const aiAccessFetcher = useFetcher(); + const aiGenerateTestsFetcher = useFetcher(); + const aiGenerateTestsFromSpecFetcher = useFetcher(); + const loading = useFetchers().filter(loader => loader.formAction?.includes('/ai/generate/')).some(loader => loader.state !== 'idle'); + + const loggedIn = isLoggedIn(); + + const prevProjectId = usePrevious(projectId); + + useEffect(() => { + if (!loggedIn) { + return; + } + + const fetcherHasNotRun = aiAccessFetcher.state === 'idle' && !aiAccessFetcher.data; + const projectIdHasChanged = prevProjectId !== projectId; + + if (fetcherHasNotRun || projectIdHasChanged) { + aiAccessFetcher.submit({}, { + method: 'post', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/ai/access`, + }); + } + }, [aiAccessFetcher, organizationId, projectId, workspaceId, loggedIn, prevProjectId]); + + const isAIEnabled = aiAccessFetcher.data?.enabled ?? false; + + const aiGenerateTestsProgressStream = aiGenerateTestsFetcher.data as TransformStream; + + useEffect(() => { + if (aiGenerateTestsProgressStream) { + const progress = aiGenerateTestsProgressStream.readable; + + progress.pipeTo(new WritableStream({ + write: (chunk: any) => { + setProgress(chunk); + }, + })); + } + }, [aiGenerateTestsProgressStream]); + + const aiGenerateTestsFromSpecProgressStream = aiGenerateTestsFromSpecFetcher.data as TransformStream; + + useEffect(() => { + if (aiGenerateTestsFromSpecProgressStream) { + const progress = aiGenerateTestsFromSpecProgressStream.readable; + + progress.pipeTo(new WritableStream({ + write: (chunk: any) => { + setProgress(chunk); + }, + })); + } + }, [aiGenerateTestsFromSpecProgressStream]); + + return ( + 0 && progress.progress < progress.total), + progress, + generateTests: () => { + aiGenerateTestsFetcher.submit({}, { + method: 'post', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/ai/generate/tests`, + }); + }, + generateTestsFromSpec: () => { + aiGenerateTestsFromSpecFetcher.submit({}, { + method: 'post', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/ai/generate/collection-and-tests`, + }); + }, + access: { + enabled: isAIEnabled, + loading: aiAccessFetcher.state !== 'idle', + }, + }} + > + {children} + + ); +}; + +export const useAIContext = () => useContext(AIContext); diff --git a/packages/insomnia/src/ui/css/layout/base.less b/packages/insomnia/src/ui/css/layout/base.less index d1e57dabc..d0e94d53c 100644 --- a/packages/insomnia/src/ui/css/layout/base.less +++ b/packages/insomnia/src/ui/css/layout/base.less @@ -833,3 +833,33 @@ strong { text-decoration: underline; } +@keyframes AIBlockLoading { + 0% { + opacity: 0; + transform: translateX(-100%); + } + + 100% { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes AIfadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.ai-block { + animation: AIBlockLoading 1s ease-in-out; +} + +.ai-text { + opacity: 0; + animation: AIfadeIn 0.5s 0.5s ease-in-out forwards; +} diff --git a/packages/insomnia/src/ui/index.tsx b/packages/insomnia/src/ui/index.tsx index c72d0e7e0..87ab28310 100644 --- a/packages/insomnia/src/ui/index.tsx +++ b/packages/insomnia/src/ui/index.tsx @@ -254,6 +254,29 @@ const router = createMemoryRouter( }, ], }, + { + path: 'ai', + children: [ + { + path: 'generate', + children: [ + + { + path: 'collection-and-tests', + action: async (...args) => (await import('./routes/actions')).generateCollectionAndTestsAction(...args), + }, + { + path: 'tests', + action: async (...args) => (await import('./routes/actions')).generateTestsAction(...args), + }, + ], + }, + { + path: 'access', + action: async (...args) => (await import('./routes/actions')).accessAIApiAction(...args), + }, + ], + }, { path: 'duplicate', action: async (...args) => diff --git a/packages/insomnia/src/ui/routes/actions.tsx b/packages/insomnia/src/ui/routes/actions.tsx index 2f6b6eb39..3957e6e37 100644 --- a/packages/insomnia/src/ui/routes/actions.tsx +++ b/packages/insomnia/src/ui/routes/actions.tsx @@ -4,15 +4,19 @@ import path from 'path'; import { ActionFunction, redirect } from 'react-router-dom'; import * as session from '../../account/session'; +import { parseApiSpec, resolveComponentSchemaRefs } from '../../common/api-specs'; import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants'; import { database } from '../../common/database'; import { importResources, scanResources } from '../../common/import'; +import { generateId } from '../../common/misc'; import * as models from '../../models'; import * as workspaceOperations from '../../models/helpers/workspace-operations'; import { DEFAULT_ORGANIZATION_ID } from '../../models/organization'; import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project'; +import { isRequest, Request } from '../../models/request'; import { UnitTest } from '../../models/unit-test'; import { isCollection } from '../../models/workspace'; +import { axiosRequest } from '../../network/axios-request'; import { getSendRequestCallback } from '../../network/unit-test-feature'; import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project'; import { getVCS } from '../../sync/vcs/vcs'; @@ -124,8 +128,7 @@ export const createNewWorkspaceAction: ActionFunction = async ({ ); return redirect( - `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${ - workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC + `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC }` ); }; @@ -209,8 +212,7 @@ export const duplicateWorkspaceAction: ActionFunction = async ({ request, params } return redirect( - `/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${ - newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC + `/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC }` ); }; @@ -517,3 +519,258 @@ export const generateCollectionFromApiSpecAction: ActionFunction = async ({ return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_DEBUG}`); }; + +export const generateCollectionAndTestsAction: ActionFunction = async ({ params }) => { + const { organizationId, projectId, workspaceId } = params; + + invariant(typeof organizationId === 'string', 'Organization ID is required'); + invariant(typeof projectId === 'string', 'Project ID is required'); + invariant(typeof workspaceId === 'string', 'Workspace ID is required'); + + const apiSpec = await models.apiSpec.getByParentId(workspaceId); + + invariant(apiSpec, 'API Spec not found'); + + const workspace = await models.workspace.getById(workspaceId); + + invariant(workspace, 'Workspace not found'); + + const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspaceId); + + const isLintError = (result: IRuleResult) => result.severity === 0; + const rulesetPath = path.join( + process.env['INSOMNIA_DATA_PATH'] || window.app.getPath('userData'), + `version-control/git/${workspaceMeta?.gitRepositoryId}/other/.spectral.yaml`, + ); + + const results = (await window.main.spectralRun({ contents: apiSpec.contents, rulesetPath })).filter(isLintError); + if (apiSpec.contents && results && results.length) { + throw new Error('Error Generating Configuration'); + } + + const resources = await scanResources({ + content: apiSpec.contents, + }); + + const aiGeneratedRequestGroup = await models.requestGroup.create({ + name: 'AI Generated Requests', + parentId: workspaceId, + }); + + const requests = resources.requests?.filter(isRequest).map(request => { + return { + ...request, + _id: generateId(models.request.prefix), + parentId: aiGeneratedRequestGroup._id, + }; + }) || []; + + await Promise.all(requests.map(request => models.request.create(request))); + + const aiTestSuite = await models.unitTestSuite.create({ + name: 'AI Generated Tests', + parentId: workspaceId, + }); + + const spec = parseApiSpec(apiSpec.contents); + + const getMethodInfo = (request: Request) => { + try { + const specPaths = Object.keys(spec.contents?.paths) || []; + + const pathMatches = specPaths.filter(path => request.url.endsWith(path)); + + const closestPath = pathMatches.sort((a, b) => { + return a.length - b.length; + })[0]; + + const methodInfo = spec.contents?.paths[closestPath][request.method.toLowerCase()]; + + return methodInfo; + } catch (error) { + console.log(error); + return undefined; + } + }; + + const tests: Partial[] = requests.map(request => { + return { + name: `Test: ${request.name}`, + code: '', + parentId: aiTestSuite._id, + requestId: request._id, + }; + }); + + const total = tests.length; + let progress = 0; + + // @TODO Investigate the defer API for streaming results. + const progressStream = new TransformStream(); + const writer = progressStream.writable.getWriter(); + + writer.write({ + progress, + total, + }); + + for (const test of tests) { + async function generateTest() { + try { + const request = requests.find(r => r._id === test.requestId); + if (!request) { + throw new Error('Request not found'); + } + + const methodInfo = resolveComponentSchemaRefs(spec, getMethodInfo(request)); + + const response = await axiosRequest({ + method: 'POST', + url: 'https://ai.insomnia.rest/v1/generate-test', + headers: { + 'Content-Type': 'application/json', + 'X-Session-Id': session.getCurrentSessionId(), + }, + data: { + teamId: organizationId, + request: requests.find(r => r._id === test.requestId), + methodInfo, + }, + }); + + const aiTest = response.data.test; + + await models.unitTest.create({ ...aiTest, parentId: aiTestSuite._id, requestId: test.requestId }); + writer.write({ + progress: ++progress, + total, + }); + + } catch (err) { + console.log(err); + writer.write({ + progress: ++progress, + total, + }); + } + } + generateTest(); + } + + return progressStream; +}; + +export const generateTestsAction: ActionFunction = async ({ params }) => { + const { organizationId, projectId, workspaceId } = params; + + invariant(typeof organizationId === 'string', 'Organization ID is required'); + invariant(typeof projectId === 'string', 'Project ID is required'); + invariant(typeof workspaceId === 'string', 'Workspace ID is required'); + + const apiSpec = await models.apiSpec.getByParentId(workspaceId); + + invariant(apiSpec, 'API Spec not found'); + + const workspace = await models.workspace.getById(workspaceId); + + invariant(workspace, 'Workspace not found'); + + const workspaceDescendants = await database.withDescendants(workspace); + + const requests = workspaceDescendants.filter(isRequest); + + const aiTestSuite = await models.unitTestSuite.create({ + name: 'AI Generated Tests', + parentId: workspaceId, + }); + + const tests: Partial[] = requests.map(request => { + return { + name: `Test: ${request.name}`, + code: '', + parentId: aiTestSuite._id, + requestId: request._id, + }; + }); + + const total = tests.length; + let progress = 0; + // @TODO Investigate the defer API for streaming results. + const progressStream = new TransformStream(); + const writer = progressStream.writable.getWriter(); + + writer.write({ + progress, + total, + }); + + for (const test of tests) { + async function generateTest() { + try { + const response = await axiosRequest({ + method: 'POST', + url: 'https://ai.insomnia.rest/v1/generate-test', + headers: { + 'Content-Type': 'application/json', + 'X-Session-Id': session.getCurrentSessionId(), + }, + data: { + teamId: organizationId, + request: requests.find(r => r._id === test.requestId), + }, + }); + + const aiTest = response.data.test; + + await models.unitTest.create({ ...aiTest, parentId: aiTestSuite._id, requestId: test.requestId }); + + writer.write({ + progress: ++progress, + total, + }); + } catch (err) { + console.log(err); + writer.write({ + progress: ++progress, + total, + }); + } + } + + generateTest(); + } + + return progressStream; +}; + +export const accessAIApiAction: ActionFunction = async ({ params }) => { + console.log('AI'); + const { organizationId, projectId, workspaceId } = params; + + invariant(typeof organizationId === 'string', 'Organization ID is required'); + invariant(typeof projectId === 'string', 'Project ID is required'); + invariant(typeof workspaceId === 'string', 'Workspace ID is required'); + + try { + const response = await axiosRequest({ + method: 'POST', + url: 'https://ai.insomnia.rest/v1/access', + headers: { + 'Content-Type': 'application/json', + 'X-Session-Id': session.getCurrentSessionId(), + }, + data: { + teamId: organizationId, + }, + }); + + const enabled = response.data.enabled; + + return { + enabled, + }; + } catch (err) { + console.log(err); + return { enabled: false }; + } +}; diff --git a/packages/insomnia/src/ui/routes/root.tsx b/packages/insomnia/src/ui/routes/root.tsx index c7dace064..e265fb937 100644 --- a/packages/insomnia/src/ui/routes/root.tsx +++ b/packages/insomnia/src/ui/routes/root.tsx @@ -41,6 +41,7 @@ import { StatusBar } from '../components/statusbar'; import { Toast } from '../components/toast'; import { WorkspaceHeader } from '../components/workspace-header'; import { AppHooks } from '../containers/app-hooks'; +import { AIProvider } from '../context/app/ai-context'; import withDragDropContext from '../context/app/drag-drop-context'; import { NunjucksEnabledProvider } from '../context/nunjucks/nunjucks-enabled-context'; import Modals from './modals'; @@ -248,38 +249,40 @@ const Root = () => { }; return ( - - -
- - - {importUri && ( - setImportUri('')} - organizationId={organizationId} - defaultProjectId={projectId || ''} - defaultWorkspaceId={workspaceId} - from={{ type: 'uri', defaultValue: importUri }} - /> - )} - - - : null - } - gridRight={} - /> - - - - + + + +
+ + + {importUri && ( + setImportUri('')} + organizationId={organizationId} + defaultProjectId={projectId || ''} + defaultWorkspaceId={workspaceId} + from={{ type: 'uri', defaultValue: importUri }} + /> + )} + + + : null + } + gridRight={} + /> + + + + - - - -
-
+ + + +
+
+ ); };