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
This commit is contained in:
James Gatz 2023-06-23 19:03:01 +02:00 committed by GitHub
parent b4857c7a13
commit e4612735c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 938 additions and 50 deletions

View File

@ -137,13 +137,13 @@
"objectpath": "^2.0.0", "objectpath": "^2.0.0",
"openapi-types": "^7.0.1", "openapi-types": "^7.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-aria": "^3.23.1", "react-aria": "3.23.1",
"react-dnd": "^7.4.5", "react-dnd": "^7.4.5",
"react-dnd-html5-backend": "^7.4.4", "react-dnd-html5-backend": "^7.4.4",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^7.2.6", "react-redux": "^7.2.6",
"react-router-dom": "^6.4.2", "react-router-dom": "^6.4.2",
"react-stately": "^3.21.0", "react-stately": "3.21.0",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"redux": "^4.1.2", "redux": "^4.1.2",
"redux-mock-store": "^1.5.4", "redux-mock-store": "^1.5.4",

View File

@ -189,13 +189,13 @@
"objectpath": "^2.0.0", "objectpath": "^2.0.0",
"openapi-types": "^7.0.1", "openapi-types": "^7.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-aria": "^3.23.1", "react-aria": "3.23.1",
"react-dnd": "^7.4.5", "react-dnd": "^7.4.5",
"react-dnd-html5-backend": "^7.4.4", "react-dnd-html5-backend": "^7.4.4",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^7.2.6", "react-redux": "^7.2.6",
"react-router-dom": "^6.4.2", "react-router-dom": "^6.4.2",
"react-stately": "^3.21.0", "react-stately": "3.21.0",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"redux": "^4.1.2", "redux": "^4.1.2",
"redux-mock-store": "^1.5.4", "redux-mock-store": "^1.5.4",

View File

@ -40,3 +40,39 @@ export function parseApiSpec(
return result; return result;
} }
export function resolveComponentSchemaRefs(
spec: ParsedApiSpec,
methodInfo: Record<string, any>,
) {
const schemas = spec.contents?.components?.schemas;
if (!schemas) {
return;
}
const resolveRefs = (obj: Record<string, any>): Record<string, any> => {
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<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
resolved[key] = resolveRefs(value);
}
return resolved;
};
const resolved = resolveRefs(methodInfo);
return resolved;
}

View File

@ -225,9 +225,14 @@ export async function importResources({
} }
await db.flushChanges(bufferId); await db.flushChanges(bufferId);
const resourcesWithIds = resources.map(r => ({
...r,
_id: ResourceIdMap.get(r._id),
parentId: ResourceIdMap.get(r.parentId),
}));
return { return {
resources, resources: resourcesWithIds,
workspace: existingWorkspace, workspace: existingWorkspace,
}; };
} else { } else {
@ -318,8 +323,6 @@ export async function importResources({
const subEnvironments = const subEnvironments =
resources.filter(isEnvironment).filter(env => env.parentId.startsWith(models.environment.prefix)) || []; resources.filter(isEnvironment).filter(env => env.parentId.startsWith(models.environment.prefix)) || [];
console.log({ subEnvironments });
if (subEnvironments.length > 0) { if (subEnvironments.length > 0) {
const firstSubEnvironment = subEnvironments[0]; const firstSubEnvironment = subEnvironments[0];
@ -336,8 +339,14 @@ export async function importResources({
await db.flushChanges(bufferId); await db.flushChanges(bufferId);
const resourcesWithIds = resources.map(r => ({
...r,
_id: ResourceIdMap.get(r._id),
parentId: ResourceIdMap.get(r.parentId),
}));
return { return {
resources, resources: resourcesWithIds,
workspace: newWorkspace, workspace: newWorkspace,
}; };
} }

View File

@ -139,6 +139,10 @@ export const tryToInterpolateRequest = async (request: Request, environmentId: s
extraInfo, extraInfo,
}); });
} catch (err) { } 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}`); throw new Error(`Failed to render request: ${request._id}`);
} }
}; };

View File

@ -3,13 +3,12 @@ import React, { FC, Fragment, ReactNode } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import coreLogo from '../images/insomnia-logo.svg';
import { selectIsLoggedIn } from '../redux/selectors'; import { selectIsLoggedIn } from '../redux/selectors';
import { GitHubStarsButton } from './github-stars-button'; import { GitHubStarsButton } from './github-stars-button';
import { InsomniaAILogo } from './insomnia-icon';
const LogoWrapper = styled.div({ const LogoWrapper = styled.div({
display: 'flex', display: 'flex',
width: '50px',
justifyContent: 'center', justifyContent: 'center',
}); });
@ -83,7 +82,7 @@ export const AppHeader: FC<AppHeaderProps> = ({
gridLeft={( gridLeft={(
<Fragment> <Fragment>
<LogoWrapper> <LogoWrapper>
<img style={{ zIndex: 1 }} src={coreLogo} alt="Insomnia" width="28" height="28" /> <InsomniaAILogo />
</LogoWrapper> </LogoWrapper>
{!isLoggedIn ? <GitHubStarsButton /> : null} {!isLoggedIn ? <GitHubStarsButton /> : null}
</Fragment> </Fragment>

View File

@ -63,7 +63,7 @@ const Checkmark = styled(SvgIcon)({
}); });
type ItemContentProps = PropsWithChildren<{ type ItemContentProps = PropsWithChildren<{
icon?: string; icon?: string | ReactNode;
label?: string | ReactNode; label?: string | ReactNode;
hint?: PlatformKeyCombinations; hint?: PlatformKeyCombinations;
className?: string; className?: string;
@ -82,7 +82,7 @@ export const ItemContent: FC<ItemContentProps> = (props: ItemContentProps) => {
const content = ( const content = (
<> <>
<StyledItemContent> <StyledItemContent>
{icon && <StyledIcon icon={icon} style={iconStyle} />} {icon && typeof icon === 'string' ? <StyledIcon icon={icon} style={iconStyle} /> : icon}
{children || label} {children || label}
</StyledItemContent> </StyledItemContent>
{hint && <DropdownHint keyBindings={hint} />} {hint && <DropdownHint keyBindings={hint} />}

View File

@ -1,6 +1,7 @@
import React, { FC, useCallback, useRef, useState } from 'react'; import React, { FC, useCallback, useRef, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { isLoggedIn } from '../../../account/session';
import { database as db } from '../../../common/database'; import { database as db } from '../../../common/database';
import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render'; import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render';
@ -10,8 +11,10 @@ import { isDesign, Workspace } from '../../../models/workspace';
import type { WorkspaceAction } from '../../../plugins'; import type { WorkspaceAction } from '../../../plugins';
import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../../../plugins'; import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../../../plugins';
import * as pluginContexts from '../../../plugins/context'; import * as pluginContexts from '../../../plugins/context';
import { useAIContext } from '../../context/app/ai-context';
import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors'; import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors';
import { type DropdownHandle, Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown'; import { type DropdownHandle, Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
import { InsomniaAI } from '../insomnia-ai-icon';
import { showError, showModal } from '../modals'; import { showError, showModal } from '../modals';
import { showGenerateConfigModal } from '../modals/generate-config-modal'; import { showGenerateConfigModal } from '../modals/generate-config-modal';
import { SettingsModal, TAB_INDEX_EXPORT } from '../modals/settings-modal'; import { SettingsModal, TAB_INDEX_EXPORT } from '../modals/settings-modal';
@ -30,6 +33,12 @@ export const WorkspaceDropdown: FC = () => {
const [loadingActions, setLoadingActions] = useState<Record<string, boolean>>({}); const [loadingActions, setLoadingActions] = useState<Record<string, boolean>>({});
const dropdownRef = useRef<DropdownHandle>(null); const dropdownRef = useRef<DropdownHandle>(null);
const {
generating: loading,
access,
generateTests,
} = useAIContext();
const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => { const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => {
setLoadingActions({ ...loadingActions, [label]: true }); setLoadingActions({ ...loadingActions, [label]: true });
try { try {
@ -96,6 +105,7 @@ export const WorkspaceDropdown: FC = () => {
<Dropdown <Dropdown
aria-label="Workspace Dropdown" aria-label="Workspace Dropdown"
ref={dropdownRef} ref={dropdownRef}
closeOnSelect={false}
className="wide workspace-dropdown" className="wide workspace-dropdown"
onOpen={handleDropdownOpen} onOpen={handleDropdownOpen}
triggerButton={ triggerButton={
@ -167,6 +177,40 @@ export const WorkspaceDropdown: FC = () => {
</DropdownItem> </DropdownItem>
} }
</DropdownSection> </DropdownSection>
<DropdownSection
aria-label='AI'
title="Insomnia AI"
items={isLoggedIn() && access.enabled && activeWorkspace.scope === 'design' ? [{
label: 'Auto-generate Tests For Collection',
key: 'insomnia-ai/generate-test-suite',
action: generateTests,
}] : []}
>
{item =>
<DropdownItem
key={`generateConfig-${item.label}`}
aria-label={item.label}
>
<ItemContent
icon={
<span
style={{
display: 'flex',
alignItems: 'center',
padding: '0 var(--padding-xs)',
width: 'unset',
}}
>
<InsomniaAI />
</span>}
isDisabled={loading}
label={item.label}
onClick={item.action}
/>
</DropdownItem>
}
</DropdownSection>
</Dropdown> </Dropdown>
); );
}; };

View File

@ -0,0 +1,37 @@
import React from 'react';
import { useAIContext } from '../context/app/ai-context';
export const InsomniaAI = ({
...props
}: React.SVGProps<SVGSVGElement>) => {
const { generating: loading } = useAIContext();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
width="16"
height="16"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zm8.446-7.189L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zm-1.365 11.852L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
>
{loading && (
<animate
attributeName="opacity"
values="0.4;1;0.4"
dur="2s"
repeatCount="indefinite"
/>
)}
</path>
</svg>
);
};

View File

@ -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<SVGSVGElement>) => {
const {
generating: loading,
progress,
} = useAIContext();
const loadingProgress = 100 - (progress.progress / progress.total) * 100;
return (
<Layout>
<RelativeFrame>
<svg
viewBox="0 0 128 128"
width="28px"
xmlns="http://www.w3.org/2000/svg"
fillRule="evenodd"
clipRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit={2}
style={{
zIndex: 1,
}}
{...props}
>
<path
d="M16 32.187c8.387 0 15.186-6.8 15.186-15.187S24.387 1.814 16 1.814.813 8.613.813 17 7.613 32.187 16 32.187z"
fill="#fff"
fillRule="nonzero"
transform="translate(-448 -236) translate(448.128 232.136) scale(3.99198)"
/>
<path
d="M16 1C7.163 1 0 8.163 0 17s7.163 16 16 16 16-7.163 16-16S24.837 1 16 1zm0 1.627c7.938 0 14.373 6.435 14.373 14.373S23.938 31.373 16 31.373 1.627 24.938 1.627 17 8.062 2.627 16 2.627z"
fill="#4000bf"
fillRule="nonzero"
transform="translate(-448 -236) translate(448.128 232.136) scale(3.99198)"
/>
<path
d="M16.181 5.61c6.29 0 11.39 5.1 11.39 11.39 0 6.291-5.1 11.39-11.39 11.39-6.291 0-11.39-5.099-11.39-11.39 0-1.537.305-3.004.857-4.341a4.43 4.43 0 106.191-6.192 11.362 11.362 0 014.342-.857z"
fill="url(#_Linear1)"
transform="translate(-448 -236) translate(448.128 232.136) scale(3.99198)"
>
{loading && (
<animate
attributeName="opacity"
values="0.4;1;0.4"
dur="4s"
repeatCount="indefinite"
/>
)}
</path>
<defs>
<linearGradient
id="_Linear1"
x1={0}
y1={0}
x2={1}
y2={0}
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(-90 22.285 6.105) scale(22.7797)"
>
<stop offset={0} stopColor="#7400e1" />
<stop offset={1} stopColor="#4000bf" />
</linearGradient>
</defs>
</svg>
{loading && <LoadingBoundary>
<LoadingBar />
<LoadingBarIndicator
style={{
opacity: progress.progress === 0 || progress.total === progress.progress ? 0 : 1,
transform: `translateX(-${loadingProgress}%)`,
}}
/>
</LoadingBoundary>
}
{loading && (
<AILoadingText>
<span>{'AI is thinking...'}</span>
</AILoadingText>
)}
</RelativeFrame>
</Layout>
);
};

View File

@ -7,6 +7,7 @@ import { ModalBody } from '../base/modal-body';
import { ModalHeader } from '../base/modal-header'; import { ModalHeader } from '../base/modal-header';
import { PanelContainer, TabItem, Tabs } from '../base/tabs'; import { PanelContainer, TabItem, Tabs } from '../base/tabs';
import { Account } from '../settings/account'; import { Account } from '../settings/account';
import { AI } from '../settings/ai';
import { General } from '../settings/general'; import { General } from '../settings/general';
import { ImportExport } from '../settings/import-export'; import { ImportExport } from '../settings/import-export';
import { Plugins } from '../settings/plugins'; 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_SHORTCUTS = 'keyboard';
export const TAB_INDEX_THEMES = 'themes'; export const TAB_INDEX_THEMES = 'themes';
export const TAB_INDEX_PLUGINS = 'plugins'; export const TAB_INDEX_PLUGINS = 'plugins';
export const TAB_INDEX_AI = 'ai';
export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => { export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => {
const [defaultTabKey, setDefaultTabKey] = useState('general'); const [defaultTabKey, setDefaultTabKey] = useState('general');
const modalRef = useRef<ModalHandle>(null); const modalRef = useRef<ModalHandle>(null);
@ -79,6 +82,11 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
<Plugins /> <Plugins />
</PanelContainer> </PanelContainer>
</TabItem> </TabItem>
<TabItem key="ai" title="AI">
<PanelContainer className="pad">
<AI />
</PanelContainer>
</TabItem>
</Tabs> </Tabs>
</ModalBody> </ModalBody>
</Modal> </Modal>

View File

@ -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<HTMLAnchorElement>) => {
event.preventDefault();
hideAllModals();
showModal(LoginModal);
}, []);
const {
access: {
enabled,
loading,
},
} = useAIContext();
if (loading) {
return <div>Loading...</div>;
}
if (loggedIn && enabled) {
return <Fragment>
<div
className="notice pad success"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<h1
className="no-margin-top"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-xs)',
}}
>Insomnia AI is enabled
<InsomniaAI /> </h1>
<p
style={{
textAlign: 'center',
width: '100%',
maxWidth: '66ch',
}}
>
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.
</p>
<br />
<div
className="pad"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-xs)',
}}
>
<i className='fa fa-info-circle' /> Beware that too many requests of Insomnia AI could generate an unpredictable spend.
</div>
</div>
</Fragment >;
}
return (
<Fragment>
<div
className="notice pad surprise"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<h1 className="no-margin-top">Try Insomnia AI <InsomniaAI /></h1>
<p>
Improve your productivity with Insomnia AI and perform complex operations in 1-click, like auto-generating API tests for your documents and collections.
<br />
<br />
This capability is an add-on to Enterprise customers only.
</p>
<br />
<div className="pad">
<Link button className="btn btn--clicky" href="https://insomnia.rest/pricing/contact">
Enable Insomnia AI <i className="fa fa-external-link" />
</Link>
</div>
</div>
{!loggedIn ? <p>
Or{' '}<a href="#" onClick={handleLogin} className="theme--link">
Log In
</a>
</p> : null}
</Fragment >
);
};

View File

@ -4,6 +4,9 @@ import YAML from 'yaml';
import YAMLSourceMap from 'yaml-source-map'; import YAMLSourceMap from 'yaml-source-map';
import type { ApiSpec } from '../../../models/api-spec'; 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'; import { Sidebar } from './sidebar';
interface Props { interface Props {
@ -17,6 +20,11 @@ const StyledSpecEditorSidebar = styled.div`
`; `;
export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) => { export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) => {
const {
generating: loading,
generateTestsFromSpec,
access,
} = useAIContext();
const onClick = (...itemPath: any[]): void => { const onClick = (...itemPath: any[]): void => {
const scrollPosition = { start: { line: 0, col: 0 }, end: { line: 0, col: 200 } }; const scrollPosition = { start: { line: 0, col: 0 }, end: { line: 0, col: 200 } };
@ -24,7 +32,7 @@ export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) =>
JSON.parse(apiSpec.contents); JSON.parse(apiSpec.contents);
// Account for JSON (as string) line number shift // Account for JSON (as string) line number shift
scrollPosition.start.line = 1; scrollPosition.start.line = 1;
} catch {} } catch { }
const sourceMap = new YAMLSourceMap(); const sourceMap = new YAMLSourceMap();
const specMap = sourceMap.index( const specMap = sourceMap.index(
@ -48,8 +56,32 @@ export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) =>
}; };
const specJSON = YAML.parse(apiSpec.contents); const specJSON = YAML.parse(apiSpec.contents);
return ( return (
<StyledSpecEditorSidebar> <StyledSpecEditorSidebar>
<div>
{access.enabled && (
<Button
variant="text"
disabled={loading}
style={{
width: '100%',
justifyContent: 'flex-start!important',
gap: 'var(--padding-xs)',
}}
onClick={generateTestsFromSpec}
>
<InsomniaAI
style={{
flex: '0 0 20px',
}}
/>
<span>
Auto-generate Tests For Collection
</span>
</Button>
)}
</div>
<Sidebar jsonData={specJSON} onClick={onClick} /> <Sidebar jsonData={specJSON} onClick={onClick} />
</StyledSpecEditorSidebar> </StyledSpecEditorSidebar>
); );

View File

@ -28,7 +28,17 @@ const KongLink = styled.a({
export const StatusBar: FC = () => { export const StatusBar: FC = () => {
return <Bar> return <Bar>
<SettingsButton /> <div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-xs)',
color: 'var(--color-font)',
fontSize: 'var(--font-size-xs)',
}}
>
<SettingsButton />
</div>
<KongLink className="made-with-love" href="https://konghq.com/"> <KongLink className="made-with-love" href="https://konghq.com/">
Made with&nbsp; <SvgIcon icon="heart" /> &nbsp;by Kong Made with&nbsp; <SvgIcon icon="heart" /> &nbsp;by Kong
</KongLink> </KongLink>

View File

@ -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<PropsWithChildren> = ({ 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 (
<AIContext.Provider
value={{
generating: loading || (progress.total > 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}
</AIContext.Provider>
);
};
export const useAIContext = () => useContext(AIContext);

View File

@ -833,3 +833,33 @@ strong {
text-decoration: underline; 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;
}

View File

@ -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', path: 'duplicate',
action: async (...args) => action: async (...args) =>

View File

@ -4,15 +4,19 @@ import path from 'path';
import { ActionFunction, redirect } from 'react-router-dom'; import { ActionFunction, redirect } from 'react-router-dom';
import * as session from '../../account/session'; import * as session from '../../account/session';
import { parseApiSpec, resolveComponentSchemaRefs } from '../../common/api-specs';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants'; import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
import { database } from '../../common/database'; import { database } from '../../common/database';
import { importResources, scanResources } from '../../common/import'; import { importResources, scanResources } from '../../common/import';
import { generateId } from '../../common/misc';
import * as models from '../../models'; import * as models from '../../models';
import * as workspaceOperations from '../../models/helpers/workspace-operations'; import * as workspaceOperations from '../../models/helpers/workspace-operations';
import { DEFAULT_ORGANIZATION_ID } from '../../models/organization'; import { DEFAULT_ORGANIZATION_ID } from '../../models/organization';
import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project'; import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project';
import { isRequest, Request } from '../../models/request';
import { UnitTest } from '../../models/unit-test'; import { UnitTest } from '../../models/unit-test';
import { isCollection } from '../../models/workspace'; import { isCollection } from '../../models/workspace';
import { axiosRequest } from '../../network/axios-request';
import { getSendRequestCallback } from '../../network/unit-test-feature'; import { getSendRequestCallback } from '../../network/unit-test-feature';
import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project'; import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project';
import { getVCS } from '../../sync/vcs/vcs'; import { getVCS } from '../../sync/vcs/vcs';
@ -124,8 +128,7 @@ export const createNewWorkspaceAction: ActionFunction = async ({
); );
return redirect( return redirect(
`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${ `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
}` }`
); );
}; };
@ -209,8 +212,7 @@ export const duplicateWorkspaceAction: ActionFunction = async ({ request, params
} }
return redirect( return redirect(
`/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${ `/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
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}`); 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<UnitTest>[] = 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<UnitTest>[] = 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 };
}
};

View File

@ -41,6 +41,7 @@ import { StatusBar } from '../components/statusbar';
import { Toast } from '../components/toast'; import { Toast } from '../components/toast';
import { WorkspaceHeader } from '../components/workspace-header'; import { WorkspaceHeader } from '../components/workspace-header';
import { AppHooks } from '../containers/app-hooks'; import { AppHooks } from '../containers/app-hooks';
import { AIProvider } from '../context/app/ai-context';
import withDragDropContext from '../context/app/drag-drop-context'; import withDragDropContext from '../context/app/drag-drop-context';
import { NunjucksEnabledProvider } from '../context/nunjucks/nunjucks-enabled-context'; import { NunjucksEnabledProvider } from '../context/nunjucks/nunjucks-enabled-context';
import Modals from './modals'; import Modals from './modals';
@ -248,38 +249,40 @@ const Root = () => {
}; };
return ( return (
<NunjucksEnabledProvider> <AIProvider>
<AppHooks /> <NunjucksEnabledProvider>
<div className="app"> <AppHooks />
<ErrorBoundary showAlert> <div className="app">
<Modals /> <ErrorBoundary showAlert>
{importUri && ( <Modals />
<ImportModal {importUri && (
onHide={() => setImportUri('')} <ImportModal
organizationId={organizationId} onHide={() => setImportUri('')}
defaultProjectId={projectId || ''} organizationId={organizationId}
defaultWorkspaceId={workspaceId} defaultProjectId={projectId || ''}
from={{ type: 'uri', defaultValue: importUri }} defaultWorkspaceId={workspaceId}
/> from={{ type: 'uri', defaultValue: importUri }}
)} />
<Layout> )}
<OrganizationsNav /> <Layout>
<AppHeader <OrganizationsNav />
gridCenter={ <AppHeader
workspaceData ? <WorkspaceHeader {...workspaceData} /> : null gridCenter={
} workspaceData ? <WorkspaceHeader {...workspaceData} /> : null
gridRight={<AccountToolbar />} }
/> gridRight={<AccountToolbar />}
<Outlet /> />
<StatusBar /> <Outlet />
</Layout> <StatusBar />
</ErrorBoundary> </Layout>
</ErrorBoundary>
<ErrorBoundary showAlert> <ErrorBoundary showAlert>
<Toast /> <Toast />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</NunjucksEnabledProvider> </NunjucksEnabledProvider>
</AIProvider>
); );
}; };