diff --git a/packages/insomnia-inso/tsconfig.json b/packages/insomnia-inso/tsconfig.json index 73b98caaf00..5c1b3820903 100644 --- a/packages/insomnia-inso/tsconfig.json +++ b/packages/insomnia-inso/tsconfig.json @@ -21,7 +21,7 @@ "sourceMap": true, /* Runs in the DOM NOTE: this is inconsistent with reality */ "lib": [ - "ES2020", + "ES2023", "DOM", "DOM.Iterable" ], diff --git a/packages/insomnia-sdk/tsconfig.json b/packages/insomnia-sdk/tsconfig.json index 1d7f225d8c6..92caa82ffaa 100644 --- a/packages/insomnia-sdk/tsconfig.json +++ b/packages/insomnia-sdk/tsconfig.json @@ -20,7 +20,7 @@ "jsx": "react", /* If your code runs in the DOM: */ "lib": [ - "es2022", + "es2023", "dom", "dom.iterable" ], diff --git a/packages/insomnia/src/ui/components/document-tab.tsx b/packages/insomnia/src/ui/components/document-tab.tsx index ee2d7220744..d77e1be7a94 100644 --- a/packages/insomnia/src/ui/components/document-tab.tsx +++ b/packages/insomnia/src/ui/components/document-tab.tsx @@ -21,7 +21,7 @@ export const DocumentTab = ({ organizationId, projectId, workspaceId, className key={item.id} to={`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${item.id}`} className={({ isActive, isPending }) => classnames('text-center rounded-full px-2', { - 'text-[--color-font] bg-[--color-surprise]': isActive, + 'text-[--color-font-surprise] bg-[--color-surprise]': isActive, 'animate-pulse': isPending, })} data-testid={`workspace-${item.id}`} diff --git a/packages/insomnia/src/ui/components/tabs/tabList.tsx b/packages/insomnia/src/ui/components/tabs/tabList.tsx index 6c1885e0152..2d9c2219c97 100644 --- a/packages/insomnia/src/ui/components/tabs/tabList.tsx +++ b/packages/insomnia/src/ui/components/tabs/tabList.tsx @@ -56,6 +56,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' closeTabById, closeAllTabsUnderWorkspace, closeAllTabsUnderProject, + batchCloseTabs, updateTabById, updateProjectName, updateWorkspaceName, @@ -98,11 +99,14 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' if (docType === models.workspace.type) { // delete all tabs of this workspace closeAllTabsUnderWorkspace?.(docId); + } else if (docType === models.requestGroup.type) { + // when delete a folder, we need also delete the corresponding folder runner tab(if exists) + batchCloseTabs?.([docId, `runner_${docId}`]); } else { // delete tab by id closeTabById(docId); } - }, [closeAllTabsUnderProject, closeAllTabsUnderWorkspace, closeTabById]); + }, [batchCloseTabs, closeAllTabsUnderProject, closeAllTabsUnderWorkspace, closeTabById]); const handleUpdate = useCallback(async (doc: models.BaseModel, patches: Partial[] = []) => { const patchObj: Record = {}; diff --git a/packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx b/packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx index 2a1f99baf69..68b634407f7 100644 --- a/packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx +++ b/packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx @@ -4,6 +4,7 @@ import { useLocalStorage } from 'react-use'; import type { BaseTab } from '../../components/tabs/tab'; import type { OrganizationTabs } from '../../components/tabs/tabList'; +import uiEventBus, { UIEventType } from '../../eventBus'; interface UpdateInsomniaTabParams { organizationId: string; @@ -19,6 +20,7 @@ interface ContextProps { changeActiveTab: (id: string) => void; closeAllTabsUnderWorkspace?: (workspaceId: string) => void; closeAllTabsUnderProject?: (projectId: string) => void; + batchCloseTabs?: (ids: string[]) => void; updateProjectName?: (projectId: string, name: string) => void; updateWorkspaceName?: (projectId: string, name: string) => void; updateTabById?: (tabId: string, patches: Partial) => void; @@ -97,6 +99,7 @@ export const InsomniaTabProvider: FC = ({ children }) => { tabList: [], activeTabId: '', }); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, [id]); return; } @@ -114,6 +117,39 @@ export const InsomniaTabProvider: FC = ({ children }) => { tabList: newTabList, activeTabId: currentTabs.activeTabId === id ? newTabList[Math.max(index - 1, 0)]?.id : currentTabs.activeTabId as string, }); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, [id]); + }, [navigate, organizationId, projectId, updateInsomniaTabs]); + + const batchCloseTabs = useCallback((deleteIds: string[]) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + + if (currentTabs.tabList.every(tab => deleteIds.includes(tab.id))) { + navigate(`/organization/${organizationId}/project/${projectId}`); + updateInsomniaTabs({ + organizationId, + tabList: [], + activeTabId: '', + }); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, 'all'); + return; + } + + const index = currentTabs.tabList.findIndex(tab => deleteIds.includes(tab.id)); + const newTabList = currentTabs.tabList.filter(tab => !deleteIds.includes(tab.id)); + if (deleteIds.includes(currentTabs.activeTabId || '')) { + const url = newTabList[Math.max(index - 1, 0)]?.url; + navigate(url); + } + + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: deleteIds.includes(currentTabs.activeTabId || '') ? newTabList[Math.max(index - 1, 0)]?.id : currentTabs.activeTabId as string, + }); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, deleteIds); }, [navigate, organizationId, projectId, updateInsomniaTabs]); const closeAllTabsUnderWorkspace = useCallback((workspaceId: string) => { @@ -121,6 +157,7 @@ export const InsomniaTabProvider: FC = ({ children }) => { if (!currentTabs) { return; } + const closeIds = currentTabs.tabList.filter(tab => tab.workspaceId === workspaceId).map(tab => tab.id); const newTabList = currentTabs.tabList.filter(tab => tab.workspaceId !== workspaceId); updateInsomniaTabs({ @@ -128,6 +165,7 @@ export const InsomniaTabProvider: FC = ({ children }) => { tabList: newTabList, activeTabId: '', }); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, closeIds); }, [organizationId, updateInsomniaTabs]); const closeAllTabsUnderProject = useCallback((projectId: string) => { @@ -135,6 +173,7 @@ export const InsomniaTabProvider: FC = ({ children }) => { if (!currentTabs) { return; } + const closeIds = currentTabs.tabList.filter(tab => tab.projectId === projectId).map(tab => tab.id); const newTabList = currentTabs.tabList.filter(tab => tab.projectId !== projectId); updateInsomniaTabs({ @@ -142,6 +181,7 @@ export const InsomniaTabProvider: FC = ({ children }) => { tabList: newTabList, activeTabId: '', }); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, closeIds); }, [organizationId, updateInsomniaTabs]); const closeAllTabs = useCallback(() => { @@ -151,6 +191,7 @@ export const InsomniaTabProvider: FC = ({ children }) => { tabList: [], activeTabId: '', }); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, 'all'); }, [navigate, organizationId, projectId, updateInsomniaTabs]); const closeOtherTabs = useCallback((id: string) => { @@ -171,6 +212,8 @@ export const InsomniaTabProvider: FC = ({ children }) => { tabList: [reservedTab], activeTabId: id, }); + const closeIds = currentTabs.tabList.filter(tab => tab.id !== id).map(tab => tab.id); + uiEventBus.emit(UIEventType.CLOSE_TAB, organizationId, closeIds); }, [navigate, organizationId, updateInsomniaTabs]); const updateTabById = useCallback((tabId: string, patches: Partial) => { @@ -324,6 +367,7 @@ export const InsomniaTabProvider: FC = ({ children }) => { closeAllTabsUnderProject, closeAllTabs, closeOtherTabs, + batchCloseTabs, addTab, updateTabById, changeActiveTab, diff --git a/packages/insomnia/src/ui/context/app/runner-context.tsx b/packages/insomnia/src/ui/context/app/runner-context.tsx new file mode 100644 index 00000000000..16bd2afb250 --- /dev/null +++ b/packages/insomnia/src/ui/context/app/runner-context.tsx @@ -0,0 +1,99 @@ +import React, { createContext, type FC, type PropsWithChildren, useCallback, useContext, useEffect } from 'react'; +import type { Selection } from 'react-aria-components'; + +import type { UploadDataType } from '../../components/modals/upload-runner-data-modal'; +import uiEventBus, { UIEventType } from '../../eventBus'; +import useStateRef from '../../hooks/use-state-ref'; +import type { RequestRow } from '../../routes/runner'; + +interface RunnerState { + selectedKeys: Selection; + iterationCount: number; + delay: number; + uploadData: UploadDataType[]; + advancedConfig: Record; + file: File | null; + reqList: RequestRow[]; +} + +interface OrgRunnerStateMap { + [runnerId: string]: Partial; +} + +interface RunnerStateMap { + [orgId: string]: OrgRunnerStateMap; +}; +interface ContextProps { + runnerStateMap: RunnerStateMap; + runnerStateRef?: React.MutableRefObject; + updateRunnerState: (organizationId: string, runnerId: string, patch: Partial) => void; +} +const RunnerContext = createContext({ + runnerStateMap: {}, + updateRunnerState: () => { }, +}); + +export const RunnerProvider: FC = ({ children }) => { + + const [runnerState, setRunnerState, runnerStateRef] = useStateRef({}); + + const updateRunnerState = useCallback((organizationId: string, runnerId: string, patch: Partial) => { + setRunnerState(prevState => { + const newState = { + ...prevState, + [organizationId]: { + ...prevState[organizationId], + [runnerId]: { ...prevState[organizationId]?.[runnerId], ...patch }, + }, + }; + return newState; + }); + }, [setRunnerState]); + + const handleTabClose = useCallback((organizationId: string, ids: 'all' | string[]) => { + if (ids === 'all') { + setRunnerState(prevState => { + const newState = { ...prevState }; + delete newState[organizationId]; + return newState; + }); + return; + } + + setRunnerState(prevState => { + const newOrgState = { ...prevState?.[organizationId] }; + ids.forEach(id => { + // runner tab id starts with 'runner' prefix, but the runnerId in this context doesn't have the prefix, so we need to remove it + if (id.startsWith('runner')) { + const runnerId = id.replace('runner_', ''); + delete newOrgState[runnerId]; + } + }); + return { + ...prevState, + [organizationId]: newOrgState, + }; + }); + }, [setRunnerState]); + + useEffect(() => { + uiEventBus.on(UIEventType.CLOSE_TAB, handleTabClose); + return () => { + uiEventBus.off(UIEventType.CLOSE_TAB, handleTabClose); + }; + }, [handleTabClose]); + + return ( + + {children} + + ); +}; + +export const useRunnerContext = () => useContext(RunnerContext); diff --git a/packages/insomnia/src/ui/eventBus.ts b/packages/insomnia/src/ui/eventBus.ts new file mode 100644 index 00000000000..90b354e126c --- /dev/null +++ b/packages/insomnia/src/ui/eventBus.ts @@ -0,0 +1,37 @@ +type EventHandler = (...args: any[]) => void; + +export enum UIEventType { + CLOSE_TAB = 'closeTab', +} +class EventBus { + private events: Record = { + [UIEventType.CLOSE_TAB]: [], + }; + + // Subscribe to event + on(event: UIEventType, handler: EventHandler): void { + if (!this.events[event]) { + this.events[event] = []; + } + this.events[event].push(handler); + } + + // Unsubscribe from event + off(event: UIEventType, handler: EventHandler): void { + if (!this.events[event]) { + return; + } + this.events[event] = this.events[event].filter(h => h !== handler); + } + + // emit event + emit(event: UIEventType, ...args: any[]): void { + if (!this.events[event]) { + return; + } + this.events[event].forEach(handler => handler(...args)); + } +} + +const uiEventBus = new EventBus(); +export default uiEventBus; diff --git a/packages/insomnia/src/ui/hooks/use-runner-request-list.tsx b/packages/insomnia/src/ui/hooks/use-runner-request-list.tsx index 785bf68b422..64a05997593 100644 --- a/packages/insomnia/src/ui/hooks/use-runner-request-list.tsx +++ b/packages/insomnia/src/ui/hooks/use-runner-request-list.tsx @@ -1,15 +1,14 @@ import { useEffect, useMemo, useRef } from 'react'; import { useRouteLoaderData } from 'react-router-dom'; -import { useListData } from 'react-stately'; -import { usePrevious } from 'react-use'; import { isRequest, type Request } from '../../models/request'; import { isRequestGroup } from '../../models/request-group'; import { invariant } from '../../utils/invariant'; +import { useRunnerContext } from '../context/app/runner-context'; import type { RequestRow } from '../routes/runner'; import type { Child, WorkspaceLoaderData } from '../routes/workspace'; -export const useRunnerRequestList = (workspaceId: string, targetFolderId: string) => { +export const useRunnerRequestList = (organizationId: string, targetFolderId: string, runnerId: string) => { const { collection } = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData; const entityMapRef = useRef(new Map()); @@ -52,24 +51,18 @@ export const useRunnerRequestList = (workspaceId: string, targetFolderId: string }); }, [collection, targetFolderId]); - const reqList = useListData({ - initialItems: requestRows, - }); - - const previousWorkspaceId = usePrevious(workspaceId); - const previousTargetFolderId = usePrevious(targetFolderId); + const { runnerStateMap, runnerStateRef, updateRunnerState } = useRunnerContext(); useEffect(() => { - if ((previousWorkspaceId && previousWorkspaceId !== workspaceId) || (previousTargetFolderId !== undefined && previousTargetFolderId !== targetFolderId)) { - // reset the list when workspace changes - const keys = reqList.items.map(item => item.id); - reqList.remove(...keys); - reqList.append(...requestRows); + if (!runnerStateRef?.current?.[organizationId]?.[runnerId]) { + updateRunnerState(organizationId, runnerId, { + reqList: requestRows, + }); } - }, [reqList, requestRows, workspaceId, targetFolderId, previousWorkspaceId, previousTargetFolderId]); + }, [organizationId, requestRows, runnerId, runnerStateRef, updateRunnerState]); return { - reqList, + reqList: runnerStateMap[organizationId]?.[runnerId]?.reqList || [], requestRows, entityMap: entityMapRef.current, }; diff --git a/packages/insomnia/src/ui/routes/organization.tsx b/packages/insomnia/src/ui/routes/organization.tsx index 40e413e1c14..78eb964b1ca 100644 --- a/packages/insomnia/src/ui/routes/organization.tsx +++ b/packages/insomnia/src/ui/routes/organization.tsx @@ -58,6 +58,7 @@ import { Toast } from '../components/toast'; import { useAIContext } from '../context/app/ai-context'; import { InsomniaEventStreamProvider } from '../context/app/insomnia-event-stream-context'; import { InsomniaTabProvider } from '../context/app/insomnia-tab-context'; +import { RunnerProvider } from '../context/app/runner-context'; import { useOrganizationPermissions } from '../hooks/use-organization-features'; import { syncProjects } from './project'; import { useRootLoaderData } from './root'; @@ -879,7 +880,9 @@ const OrganizationRoute = () => { }
- + + +
diff --git a/packages/insomnia/src/ui/routes/runner.tsx b/packages/insomnia/src/ui/routes/runner.tsx index c87679692ff..4c119f83ccf 100644 --- a/packages/insomnia/src/ui/routes/runner.tsx +++ b/packages/insomnia/src/ui/routes/runner.tsx @@ -1,9 +1,9 @@ import { type RequestContext } from 'insomnia-sdk'; import porderedJSON from 'json-order'; -import React, { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { type FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Button, Checkbox, DropIndicator, GridList, GridListItem, type GridListItemProps, Heading, type Key, Tab, TabList, TabPanel, Tabs, Toolbar, TooltipTrigger, useDragAndDrop } from 'react-aria-components'; import { Panel, PanelResizeHandle } from 'react-resizable-panels'; -import { type ActionFunction, type LoaderFunction, redirect, useNavigate, useParams, useRouteLoaderData, useSearchParams, useSubmit } from 'react-router-dom'; +import { type ActionFunction, type LoaderFunction, useNavigate, useParams, useRouteLoaderData, useSearchParams, useSubmit } from 'react-router-dom'; import { useInterval } from 'react-use'; import { v4 as uuidv4 } from 'uuid'; @@ -15,6 +15,7 @@ import * as models from '../../models'; import type { UserUploadEnvironment } from '../../models/environment'; import type { RunnerResultPerRequest, RunnerTestResult } from '../../models/runner-test-result'; import { cancelRequestById } from '../../network/cancellation'; +import { moveAfter, moveBefore } from '../../utils'; import { invariant } from '../../utils/invariant'; import { SegmentEvent } from '../analytics'; import { Dropdown, DropdownItem, ItemContent } from '../components/base/dropdown'; @@ -30,6 +31,7 @@ import { RunnerTestResultPane } from '../components/panes/runner-test-result-pan import { ResponseTimer } from '../components/response-timer'; import { getTimeAndUnit } from '../components/tags/time-tag'; import { ResponseTimelineViewer } from '../components/viewers/response-timeline-viewer'; +import { useRunnerContext } from '../context/app/runner-context'; import { useRunnerRequestList } from '../hooks/use-runner-request-list'; import type { OrganizationLoaderData } from './organization'; import { type CollectionRunnerContext, type RunnerSource, sendActionImplementation } from './request'; @@ -108,44 +110,16 @@ export interface RequestRow { parentId: string; }; +const defaultAdvancedConfig = { + bail: true, +}; + export const Runner: FC<{}> = () => { - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const [errorMsg, setErrorMsg] = useState(null); const { currentPlan } = useRouteLoaderData('/organization') as OrganizationLoaderData; const targetFolderId = searchParams.get('folder') || ''; - const shouldRefreshRef = useRef(false); - - useEffect(() => { - if (searchParams.has('refresh-pane') || searchParams.has('error')) { - const copySearchParams = new URLSearchParams(searchParams); - if (searchParams.has('refresh-pane')) { - shouldRefreshRef.current = true; - copySearchParams.delete('refresh-pane'); - } - - if (searchParams.has('error')) { - setErrorMsg(searchParams.get('error')); - // TODO: this should be removed when we are able categorized errors better and display them in different ways. - showAlert({ - title: 'Unexpected Runner Failure', - message: ( -
-

The runner failed due to an unhandled error:

- -
{searchParams.get('error')}
-
-
- ), - }); - copySearchParams.delete('error'); - } else { - setErrorMsg(null); - } - - setSearchParams(copySearchParams); - } - }, [searchParams, setSearchParams]); const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; @@ -153,21 +127,21 @@ export const Runner: FC<{}> = () => { workspaceId: string; direction: 'vertical' | 'horizontal'; }; - const [iterationCount, setIterationCount] = useState(1); - const [delay, setDelay] = useState(0); - const [uploadData, setUploadData] = useState([]); - const [file, setFile] = useState(null); - const [bail, setBail] = useState(true); const [isRunning, setIsRunning] = useState(false); - invariant(iterationCount, 'iterationCount should not be null'); + // For backward compatibility,the runnerId we use for testResult in database is no prefix with 'runner_' + const runnerId = targetFolderId ? targetFolderId : workspaceId; const { settings } = useRootLoaderData(); const [showUploadModal, setShowUploadModal] = useState(false); const [showCLIModal, setShowCLIModal] = useState(false); const [direction, setDirection] = useState<'horizontal' | 'vertical'>(settings.forceVerticalLayout ? 'vertical' : 'horizontal'); - const { reqList, requestRows, entityMap } = useRunnerRequestList(workspaceId, targetFolderId); + const { runnerStateMap, updateRunnerState } = useRunnerContext(); + const { iterationCount = 1, delay = 0, selectedKeys = new Set(), advancedConfig = defaultAdvancedConfig, uploadData = [], file } = runnerStateMap?.[organizationId]?.[runnerId] || {}; + invariant(iterationCount, 'iterationCount should not be null'); + + const { reqList, requestRows, entityMap } = useRunnerRequestList(organizationId, targetFolderId, runnerId); useEffect(() => { if (settings.forceVerticalLayout) { @@ -191,14 +165,14 @@ export const Runner: FC<{}> = () => { }, [settings.forceVerticalLayout, direction]); const isConsistencyChanged = useMemo(() => { - if (requestRows.length !== reqList.items.length) { + if (requestRows.length !== reqList.length) { return true; - } else if (reqList.selectedKeys !== 'all' && Array.from(reqList.selectedKeys).length !== requestRows.length) { + } else if (selectedKeys !== 'all' && Array.from(selectedKeys).length !== requestRows.length) { return true; } - return requestRows.some((row: RequestRow, index: number) => row.id !== reqList.items[index].id); - }, [requestRows, reqList]); + return requestRows.some((row: RequestRow, index: number) => row.id !== reqList[index].id); + }, [reqList, requestRows, selectedKeys]); const { dragAndDropHooks: requestsDnD } = useDragAndDrop({ getItems: keys => { @@ -211,11 +185,13 @@ export const Runner: FC<{}> = () => { }); }, onReorder: event => { + let newList = reqList; if (event.target.dropPosition === 'before') { - reqList.moveBefore(event.target.key, event.keys); + newList = moveBefore(reqList, event.target.key, event.keys); } else if (event.target.dropPosition === 'after') { - reqList.moveAfter(event.target.key, event.keys); + newList = moveAfter(reqList, event.target.key, event.keys); } + updateRunnerState(organizationId, runnerId, { reqList: newList }); }, renderDragPreview(items) { return ( @@ -226,7 +202,7 @@ export const Runner: FC<{}> = () => { }, renderDropIndicator(target) { if (target.type === 'item') { - const item = reqList.items.find(item => item.id === target.key); + const item = reqList.find(item => item.id === target.key); if (item) { return ( = () => { window.main.trackSegmentEvent({ event: SegmentEvent.collectionRunExecute, properties: { plan: currentPlan?.type || 'scratchpad', iterations: iterationCount } }); - const selected = new Set(reqList.selectedKeys); - const requests = Array.from(reqList.items) - .filter(item => selected.has(item.id)); + const selected = new Set(selectedKeys); + const requests = reqList.filter(item => selected.has(item.id)); // convert uploadData to environment data const userUploadEnvs = uploadData.map(data => { @@ -273,7 +248,7 @@ export const Runner: FC<{}> = () => { iterationCount, userUploadEnvs, delay, - bail, + bail: advancedConfig?.bail, targetFolderId: targetFolderId || '', }; submit( @@ -282,6 +257,7 @@ export const Runner: FC<{}> = () => { method: 'post', encType: 'application/json', action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/runner/run`, + navigate: false, } ); }; @@ -291,29 +267,24 @@ export const Runner: FC<{}> = () => { navigate(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${requestId}`); }; const onToggleSelection = () => { - if (Array.from(reqList.selectedKeys).length === Array.from(reqList.items).length) { + if (Array.from(selectedKeys).length === reqList.length) { // unselect all - reqList.setSelectedKeys(new Set([])); + updateRunnerState(organizationId, runnerId, { selectedKeys: new Set([]) }); } else { // select all - reqList.setSelectedKeys(new Set(reqList.items.map(item => item.id))); + const allKeys = reqList.map(item => item.id); + updateRunnerState(organizationId, runnerId, { selectedKeys: new Set(allKeys) }); } }; const [testHistory, setTestHistory] = useState([]); useEffect(() => { const readResults = async () => { - const results = await models.runnerTestResult.findByParentId(workspaceId) || []; + const results = await models.runnerTestResult.findByParentId(runnerId) || []; setTestHistory(results.reverse()); }; readResults(); - }, [workspaceId]); - - useEffect(() => { - if (uploadData.length >= 1) { - setIterationCount(uploadData.length); - } - }, [setIterationCount, uploadData]); + }, [runnerId]); const [timingSteps, setTimingSteps] = useState([]); const [totalTime, setTotalTime] = useState({ @@ -335,44 +306,72 @@ export const Runner: FC<{}> = () => { if (executionResult) { const mergedTimelines = await aggregateAllTimelines(errorMsg, executionResult); setTimelines(mergedTimelines); + } else { + setTimelines([]); } }; refreshTimeline(); }, [executionResult, errorMsg]); - useInterval(() => { - const refreshPanes = async () => { - const latestTimingSteps = await window.main.getExecution({ requestId: workspaceId }); - if (latestTimingSteps) { - // there is a timingStep item and it is not ended (duration is not assigned) - const isRunning = latestTimingSteps.length > 0 && latestTimingSteps[latestTimingSteps.length - 1].stepName !== 'Done'; - setIsRunning(isRunning); - - if (isRunning) { - const duration = Date.now() - latestTimingSteps[latestTimingSteps.length - 1].startedAt; - const { number: durationNumber, unit: durationUnit } = getTimeAndUnit(duration); - - setTimingSteps(latestTimingSteps); - setTotalTime({ - duration: durationNumber, - unit: durationUnit, - }); - } else { - if (shouldRefreshRef.current) { - const results = await models.runnerTestResult.findByParentId(workspaceId) || []; - setTestHistory(results.reverse()); - if (results.length > 0) { - const latestResult = results[0]; - setExecutionResult(latestResult); - } - shouldRefreshRef.current = false; - } + const showErrorAlert = (error: string) => { + showAlert({ + title: 'Unexpected Runner Failure', + message: ( +
+

The runner failed due to an unhandled error:

+ +
{error}
+
+
+ ), + }); + }; + + const refreshPanes = useCallback(async () => { + const latestTimingSteps = await window.main.getExecution({ requestId: runnerId }); + let isRunning = false; + if (latestTimingSteps) { + // there is a timingStep item and it is not ended (duration is not assigned) + isRunning = latestTimingSteps.length > 0 && latestTimingSteps[latestTimingSteps.length - 1].stepName !== 'Done'; + } + setIsRunning(isRunning); + + if (isRunning) { + const duration = Date.now() - latestTimingSteps[latestTimingSteps.length - 1].startedAt; + const { number: durationNumber, unit: durationUnit } = getTimeAndUnit(duration); + setTimingSteps(latestTimingSteps); + setTotalTime({ + duration: durationNumber, + unit: durationUnit, + }); + } else { + const results = await models.runnerTestResult.findByParentId(runnerId) || []; + // show execution result + if (results.length > 0) { + setTestHistory(results.reverse()); + const latestResult = results[0]; + setExecutionResult(latestResult); + const { error } = getExecution(runnerId); + if (error) { + setErrorMsg(error); + showErrorAlert(error); + updateExecution(runnerId, { error: '' }); } + } else { + // show initial empty panel + setExecutionResult(null); + setErrorMsg(null); } - }; + } + }, [runnerId]); + + useInterval(() => { + refreshPanes(); + }, isRunning ? 1000 : null); + useEffect(() => { refreshPanes(); - }, 1000); + }, [refreshPanes]); const { passedTestCount, totalTestCount, testResultCountTagColor } = useMemo(() => { let passedTestCount = 0; @@ -405,11 +404,11 @@ export const Runner: FC<{}> = () => { setSelectedTab('test-results'); }, [setSelectedTab]); - const allKeys = reqList.items.map(item => item.id); + const allKeys = reqList.map(item => item.id); const disabledKeys = useMemo(() => { return isRunning ? allKeys : []; }, [isRunning, allKeys]); - const isDisabled = isRunning || Array.from(reqList.selectedKeys).length === 0; + const isDisabled = isRunning || Array.from(selectedKeys).length === 0; const [deletedItems, setDeletedItems] = useState([]); const deleteHistoryItem = (item: RunnerTestResult) => { @@ -419,13 +418,13 @@ export const Runner: FC<{}> = () => { const selectedRequestIdsForCliCommand = targetFolderId !== null && targetFolderId !== '' - ? Array.from(reqList.items) + ? reqList .filter(item => item.ancestorIds.includes(targetFolderId)) .map(item => item.id) - .filter(id => new Set(reqList.selectedKeys).has(id)) - : Array.from(reqList.items) + .filter(id => selectedKeys === 'all' || selectedKeys.has(id)) + : reqList .map(item => item.id) - .filter(id => new Set(reqList.selectedKeys).has(id)); + .filter(id => selectedKeys === 'all' || selectedKeys.has(id)); return ( <> @@ -445,7 +444,7 @@ export const Runner: FC<{}> = () => { onChange={e => { try { if (parseInt(e.target.value, 10) > 0) { - setIterationCount(parseInt(e.target.value, 10)); + updateRunnerState(organizationId, runnerId, { iterationCount: parseInt(e.target.value, 10) }); } } catch (ex) { } }} @@ -463,7 +462,7 @@ export const Runner: FC<{}> = () => { try { const delay = parseInt(e.target.value, 10); if (delay >= 0) { - setDelay(delay); // also update the temp settings + updateRunnerState(organizationId, runnerId, { delay }); // also update the temp settings } } catch (ex) { } }} @@ -544,9 +543,9 @@ export const Runner: FC<{}> = () => { { - Array.from(reqList.selectedKeys).length === Array.from(reqList.items).length ? + Array.from(selectedKeys).length === Array.from(reqList).length ? Unselect All : - Array.from(reqList.selectedKeys).length === 0 ? + Array.from(selectedKeys).length === 0 ? Select All : Select All } @@ -555,11 +554,12 @@ export const Runner: FC<{}> = () => { { + updateRunnerState(organizationId, runnerId, { selectedKeys: keys }); + }} aria-label="Request Collection" dragAndDropHooks={requestsDnD} className="w-full h-full leading-8 text-base overflow-auto" @@ -618,10 +618,17 @@ export const Runner: FC<{}> = () => { @@ -669,14 +676,17 @@ export const Runner: FC<{}> = () => { iterationCount={iterationCount} delay={delay} filePath={file?.path || ''} - bail={bail} + bail={advancedConfig?.bail} /> )} {showUploadModal && ( { - setFile(file); - setUploadData(uploadData); // also update the temp settings + updateRunnerState(organizationId, runnerId, { + uploadData, + file, + iterationCount: uploadData.length >= 1 ? uploadData.length : iterationCount, + }); }} userUploadData={uploadData} onClose={() => setShowUploadModal(false)} @@ -731,7 +741,7 @@ export const Runner: FC<{}> = () => { @@ -750,8 +760,8 @@ export const Runner: FC<{}> = () => { {isRunning &&
cancelExecution(workspaceId)} - activeRequestId={workspaceId} + handleCancel={() => cancelExecution(runnerId)} + activeRequestId={runnerId} steps={timingSteps} />
@@ -798,31 +808,34 @@ const RequestItem = ( // This is required for tracking the active request for one runner execution // Then in runner cancellation, both the active request and the runner execution will be canceled // TODO(george): Potentially it could be merged with maps in request-timing.ts and cancellation.ts -const runnerExecutions = new Map(); +interface ExecutionInfo { + activeRequestId?: string; + error?: string; +}; +const runnerExecutions = new Map(); function startExecution(workspaceId: string) { - runnerExecutions.set(workspaceId, ''); + runnerExecutions.set(workspaceId, {}); } -function stopExecution(workspaceId: string) { - runnerExecutions.delete(workspaceId); -} - -function updateExecution(workspaceId: string, requestId: string) { - runnerExecutions.set(workspaceId, requestId); +function updateExecution(workspaceId: string, executionInfo: ExecutionInfo) { + const info = runnerExecutions.get(workspaceId); + runnerExecutions.set(workspaceId, { + ...info, + ...executionInfo, + }); } function getExecution(workspaceId: string) { - return runnerExecutions.get(workspaceId); + return runnerExecutions.get(workspaceId) || {}; } function cancelExecution(workspaceId: string) { - const activeRequestId = getExecution(workspaceId); + const { activeRequestId } = getExecution(workspaceId); if (activeRequestId) { cancelRequestById(activeRequestId); window.main.completeExecutionStep({ requestId: activeRequestId }); window.main.updateLatestStepName({ requestId: workspaceId, stepName: 'Done' }); window.main.completeExecutionStep({ requestId: workspaceId }); - stopExecution(workspaceId); } } const wrapAroundIterationOverIterationData = (list?: UserUploadEnvironment[], currentIteration?: number): UserUploadEnvironment | undefined => { @@ -852,6 +865,7 @@ export const runCollectionAction: ActionFunction = async ({ request, params }) = const { requests, iterationCount, delay, userUploadEnvs, bail, targetFolderId } = await request.json() as runCollectionActionParams; const source: RunnerSource = 'runner'; + const runnerId = targetFolderId ? targetFolderId : workspaceId; let testCtx: CollectionRunnerContext = { source, @@ -876,12 +890,12 @@ export const runCollectionAction: ActionFunction = async ({ request, params }) = }, }; - window.main.startExecution({ requestId: workspaceId }); + window.main.startExecution({ requestId: runnerId }); window.main.addExecutionStep({ - requestId: workspaceId, + requestId: runnerId, stepName: 'Initializing', }); - startExecution(workspaceId); + startExecution(runnerId); try { for (let i = 0; i < iterationCount; i++) { @@ -893,7 +907,7 @@ export const runCollectionAction: ActionFunction = async ({ request, params }) = let j = 0; while (j < requests.length) { // TODO: we might find a better way to do runner cancellation - if (getExecution(workspaceId) === undefined) { + if (getExecution(runnerId) === undefined) { throw 'Runner has been stopped'; } @@ -928,9 +942,11 @@ export const runCollectionAction: ActionFunction = async ({ request, params }) = } } - updateExecution(workspaceId, targetRequest.id); + updateExecution(runnerId, { + activeRequestId: targetRequest.id, + }); window.main.updateLatestStepName({ - requestId: workspaceId, + requestId: runnerId, stepName: `Iteration ${i + 1} - Executing ${j + 1} of ${requests.length} requests - "${targetRequest.name}"`, }); @@ -1023,17 +1039,20 @@ export const runCollectionAction: ActionFunction = async ({ request, params }) = }; } - window.main.updateLatestStepName({ requestId: workspaceId, stepName: 'Done' }); - window.main.completeExecutionStep({ requestId: workspaceId }); + window.main.updateLatestStepName({ requestId: runnerId, stepName: 'Done' }); + window.main.completeExecutionStep({ requestId: runnerId }); } catch (e) { // the error could be from third party - const errMsg = encodeURIComponent(e.error || e); - return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/runner?refresh-pane&error=${errMsg}&folder=${targetFolderId}`); + const errMsg = e.error || e; + updateExecution(runnerId, { + error: errMsg, + }); + return null; } finally { - cancelExecution(workspaceId); + cancelExecution(runnerId); await models.runnerTestResult.create({ - parentId: workspaceId, + parentId: runnerId, source: testCtx.source, iterations: testCtx.iterationCount, duration: testCtx.duration, @@ -1042,8 +1061,7 @@ export const runCollectionAction: ActionFunction = async ({ request, params }) = responsesInfo: testCtx.responsesInfo, }); } - - return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/runner?refresh-pane&folder=${targetFolderId}`); + return null; }; export const collectionRunnerStatusLoader: LoaderFunction = async ({ params }) => { diff --git a/packages/insomnia/src/utils/index.ts b/packages/insomnia/src/utils/index.ts index 94b3ba88edc..cc032437e2d 100644 --- a/packages/insomnia/src/utils/index.ts +++ b/packages/insomnia/src/utils/index.ts @@ -1,6 +1,76 @@ +import type { Key } from 'react-stately'; + export const scrollElementIntoView = (element: HTMLElement, options?: ScrollIntoViewOptions) => { if (element) { // @ts-expect-error -- scrollIntoViewIfNeeded is not a standard method element.scrollIntoViewIfNeeded ? element.scrollIntoViewIfNeeded() : element.scrollIntoView(options); } }; + +// modify base on react-spectrum +// https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/data/src/useListData.ts#L279 +function move(list: T[], indices: number[], toIndex: number): T[] { + // Shift the target down by the number of items being moved from before the target + toIndex -= indices.filter(index => index < toIndex).length; + + const moves = indices.map(from => ({ + from, + to: toIndex++, + })); + + // Shift later from indices down if they have a larger index + for (let i = 0; i < moves.length; i++) { + const a = moves[i].from; + for (let j = i; j < moves.length; j++) { + const b = moves[j].from; + + if (b > a) { + moves[j].from--; + } + } + } + + // Interleave the moves so they can be applied one by one rather than all at once + for (let i = 0; i < moves.length; i++) { + const a = moves[i]; + for (let j = moves.length - 1; j > i; j--) { + const b = moves[j]; + + if (b.from < a.to) { + a.to++; + } else { + b.from++; + } + } + } + + const copy = list.slice(); + for (const move of moves) { + const [item] = copy.splice(move.from, 1); + copy.splice(move.to, 0, item); + } + + return copy; +} +export const moveBefore = (list: any[], key: Key, keys: Iterable) => { + const toIndex = list.findIndex(item => item.id === key); + if (toIndex === -1) { + return list; + } + + // Find indices of keys to move. Sort them so that the order in the list is retained. + const keyArray = Array.isArray(keys) ? keys : [...keys]; + const indices = keyArray.map(key => list.findIndex(item => item.id === key)).sort((a, b) => a - b); + return move(list, indices, toIndex); +}; + +export const moveAfter = (list: any[], key: Key, keys: Iterable) => { + const toIndex = list.findIndex(item => item.id === key); + if (toIndex === -1) { + return list; + } + + const keyArray = Array.isArray(keys) ? keys : [...keys]; + const indices = keyArray.map(key => list.findIndex(item => item.id === key)).sort((a, b) => a - b); + return move(list, indices, toIndex + 1); +};