feat(接口场景): 场景步骤批量调试&响应

This commit is contained in:
baiqi 2024-03-26 17:03:57 +08:00 committed by Craftsman
parent 0d716882d9
commit 4e0a3fd33b
20 changed files with 391 additions and 221 deletions

View File

@ -244,6 +244,7 @@ export enum ScenarioDetailComposition {
// 场景执行状态
export enum ScenarioExecuteStatus {
SUCCESS = 'SUCCESS',
EXECUTING = 'EXECUTING',
FAILED = 'FAILED',
STOP = 'STOP',
}

View File

@ -408,6 +408,8 @@ export interface RequestResult {
url: string;
method: string;
responseResult: ResponseResult;
isSuccessful?: boolean;
console?: string;
[key: string]: any;
}
export interface RequestTaskResult {

View File

@ -21,6 +21,7 @@ import {
ExecuteApiRequestFullParams,
ExecuteAssertionItem,
ExecuteConditionConfig,
RequestResult,
ResponseDefinition,
} from './common';
@ -320,7 +321,6 @@ export interface ScenarioStepItem {
id: string | number;
sort: number;
name: string;
executeStatus?: ScenarioExecuteStatus;
enable: boolean; // 是否启用
copyFromStepId?: string; // 如果步骤是复制的这个字段是复制的步骤id如果复制的步骤也是复制的并且没有加载过详情则这个 id 是最原始的 被复制的步骤 id
resourceId?: string; // 详情或者引用的类型才有
@ -337,9 +337,11 @@ export interface ScenarioStepItem {
checked?: boolean; // 是否选中
expanded?: boolean; // 是否展开
createActionsVisible?: boolean; // 是否展示创建步骤下拉
responsePopoverVisible?: boolean; // 是否展示步骤响应 popover
parent?: ScenarioStepItem; // 父级节点第一层的父级节点为undefined
resourceName?: string; // 引用复制接口、用例、场景时的源资源名称
method?: RequestMethods;
executeStatus?: ScenarioExecuteStatus;
}
// 场景
export interface Scenario {
@ -367,11 +369,12 @@ export interface Scenario {
unSaved: boolean;
executeLoading: boolean; // 执行loading
executeTime?: string | number; // 执行时间
executeSuccessCount?: number; // 执行成功数量
executeFailCount?: number; // 执行失败数量
executeSuccessCount: number; // 执行成功数量
executeFailCount: number; // 执行失败数量
reportId?: string | number; // 场景报告 id
stepReportId?: string | number; // 步骤报告 id单个或批量调试
stepResponses: Record<string | number, RequestResult>; // 步骤响应集合key 为步骤 idvalue 为步骤响应内容
isExecute?: boolean; // 是否从列表执行进去场景详情
isDebug?: boolean; // 是否调试,区分执行场景和批量调试步骤
}
export interface ScenarioDetail extends Scenario {
stepTotal: number;

View File

@ -1,3 +1,5 @@
import { TreeNodeData } from '@arco-design/web-vue';
import { RequestMethods } from '@/enums/apiEnum';
// 请求返回结构
@ -61,7 +63,7 @@ export interface AddModuleParams {
parentId: string;
}
// 模块树节点
export interface ModuleTreeNode {
export interface ModuleTreeNode extends TreeNodeData {
id: string;
name: string;
type: 'MODULE' | 'API';

View File

@ -249,7 +249,7 @@ export function mapTree<T>(
*/
export function filterTree<T>(
tree: TreeNode<T> | TreeNode<T>[] | T | T[],
filterFn: (node: TreeNode<T>) => boolean,
filterFn: (node: T) => boolean,
customChildrenKey = 'children'
): T[] {
if (!Array.isArray(tree)) {
@ -257,10 +257,10 @@ export function filterTree<T>(
}
const filteredTree: T[] = [];
for (let i = 0; i < tree.length; i++) {
const node = tree[i];
const node: T = tree[i];
// 如果节点满足过滤条件,则保留该节点,并递归过滤子节点
if (filterFn(node)) {
const newNode: T = { ...node };
const newNode: T = cloneDeep(node);
if (node[customChildrenKey] && node[customChildrenKey].length > 0) {
// 递归过滤子节点,并将过滤后的子节点添加到当前节点中
newNode[customChildrenKey] = filterTree(node[customChildrenKey], filterFn, customChildrenKey);

View File

@ -110,7 +110,8 @@
:is-priority-local-exec="props.isPriorityLocalExec"
:request-url="requestVModel.url"
:is-expanded="true"
:request-task-result="requestVModel.response"
:request-result="requestVModel.response?.requestResults[0]"
:console="requestVModel.response?.console"
:is-edit="false"
hide-layout-switch
:upload-temp-file-api="props.uploadTempFileApi"

View File

@ -276,7 +276,8 @@
:request-url="requestVModel.url"
:is-expanded="isVerticalExpanded"
:hide-layout-switch="props.hideResponseLayoutSwitch"
:request-task-result="requestVModel.response"
:request-result="requestVModel.response?.requestResults[0]"
:console="requestVModel.response?.console"
:is-edit="props.isDefinition && isHttpProtocol && !props.isCase"
:upload-temp-file-api="props.uploadTempFileApi"
:loading="requestVModel.executeLoading || loading"
@ -1039,7 +1040,7 @@
const saveModalFormRef = ref<FormInstance>();
const saveLoading = ref(false);
const selectTree = computed(() =>
filterTree(cloneDeep(props.moduleTree), (e) => {
filterTree(cloneDeep(props.moduleTree || []), (e) => {
e.draggable = false;
return e.type === 'MODULE';
})
@ -1227,6 +1228,7 @@
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
websocket.value?.close();
requestVModel.value.executeLoading = false;
}
} else {
@ -1244,6 +1246,7 @@
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
websocket.value?.close();
requestVModel.value.executeLoading = false;
}
} else {

View File

@ -1,6 +1,7 @@
<template>
<div class="response flex h-full min-w-[300px] flex-col">
<div :class="['response-head', props.isExpanded ? '' : 'border-t']">
<slot name="titleLeft">
<div class="flex items-center justify-between">
<template v-if="props.activeLayout === 'vertical'">
<MsButton
@ -24,7 +25,7 @@
</MsButton>
</template>
<div
v-if="props.isEdit && props.requestTaskResult?.requestResults[0]?.responseResult?.responseCode"
v-if="props.isEdit && props.requestResult?.responseResult?.responseCode"
class="ml-[4px] flex items-center"
>
<MsButton
@ -55,19 +56,20 @@
<a-radio value="horizontal">{{ t('apiTestDebug.horizontal') }}</a-radio>
</a-radio-group>
</div>
</slot>
<div
v-if="props.requestTaskResult?.requestResults[0]?.responseResult?.responseCode"
class="flex items-center justify-between gap-[24px]"
v-if="props.requestResult?.responseResult?.responseCode"
class="flex items-center justify-between gap-[24px] text-[14px]"
>
<a-popover position="left" content-class="response-popover-content">
<div class="one-line-text max-w-[200px]" :style="{ color: statusCodeColor }">
{{ props.requestTaskResult.requestResults[0].responseResult.responseCode }}
{{ props.requestResult.responseResult.responseCode }}
</div>
<template #content>
<div class="flex items-center gap-[8px] text-[14px]">
<div class="text-[var(--color-text-4)]">{{ t('apiTestDebug.statusCode') }}</div>
<div :style="{ color: statusCodeColor }">
{{ props.requestTaskResult.requestResults[0].responseResult.responseCode }}
{{ props.requestResult.responseResult.responseCode }}
</div>
</div>
</template>
@ -84,13 +86,13 @@
</a-popover>
<a-popover position="left" content-class="response-popover-content">
<div class="one-line-text text-[rgb(var(--success-7))]">
{{ props.requestTaskResult.requestResults[0].responseResult.responseSize }} bytes
{{ props.requestResult.responseResult.responseSize }} bytes
</div>
<template #content>
<div class="flex items-center gap-[8px] text-[14px]">
<div class="text-[var(--color-text-4)]">{{ t('apiTestDebug.responseSize') }}</div>
<div class="one-line-text text-[rgb(var(--success-7))]">
{{ props.requestTaskResult.requestResults[0].responseResult.responseSize }} bytes
{{ props.requestResult.responseResult.responseSize }} bytes
</div>
</div>
</template>
@ -128,12 +130,13 @@
<result
v-else-if="!props.isEdit || (props.isEdit && activeResponseType === 'result')"
v-model:active-tab="innerActiveTab"
:request-result="props.requestTaskResult?.requestResults[0]"
:console="props.requestTaskResult?.console"
:request-result="props.requestResult"
:console="props.console"
:is-http-protocol="props.isHttpProtocol"
:is-priority-local-exec="props.isPriorityLocalExec"
:request-url="props.requestUrl"
:is-definition="props.isDefinition"
:show-empty="props.showEmpty"
@execute="emit('execute', props.isPriorityLocalExec ? 'localExec' : 'serverExec')"
/>
</a-spin>
@ -149,29 +152,33 @@
import { useI18n } from '@/hooks/useI18n';
import { RequestTaskResult } from '@/models/apiTest/common';
import { RequestResult } from '@/models/apiTest/common';
import { ResponseBodyFormat, ResponseComposition } from '@/enums/apiEnum';
const props = withDefaults(
defineProps<{
activeTab: ResponseComposition;
isExpanded: boolean;
isPriorityLocalExec: boolean;
isExpanded?: boolean;
isPriorityLocalExec?: boolean;
requestUrl?: string;
isHttpProtocol: boolean;
isHttpProtocol?: boolean;
activeLayout?: Direction;
responseDefinition?: ResponseItem[];
requestTaskResult?: RequestTaskResult;
requestResult?: RequestResult;
console?: string;
hideLayoutSwitch?: boolean; //
loading?: boolean;
isEdit?: boolean; //
uploadTempFileApi?: (...args) => Promise<any>; //
isDefinition?: boolean;
isResponseModel?: boolean;
showEmpty?: boolean;
}>(),
{
isExpanded: true,
activeLayout: 'vertical',
hideLayoutSwitch: false,
showEmpty: true,
}
);
const emit = defineEmits<{
@ -194,7 +201,7 @@
});
//
const timingInfo = computed(() => {
if (props.requestTaskResult) {
if (props.requestResult) {
const {
dnsLookupTime,
downloadTime,
@ -204,7 +211,7 @@
sslHandshakeTime,
tcpHandshakeTime,
transferStartTime,
} = props.requestTaskResult.requestResults[0].responseResult;
} = props.requestResult.responseResult;
return {
dnsLookupTime,
tcpHandshakeTime,
@ -220,8 +227,8 @@
});
//
const statusCodeColor = computed(() => {
if (props.requestTaskResult) {
const code = props.requestTaskResult.requestResults[0].responseResult.responseCode;
if (props.requestResult) {
const code = props.requestResult.responseResult.responseCode;
if (code >= 200 && code < 300) {
return 'rgb(var(--success-7)';
}
@ -295,9 +302,9 @@
}
watch(
() => props.requestTaskResult,
(task) => {
if (task?.requestResults[0]?.responseResult?.responseCode) {
() => props.requestResult,
(requestResult) => {
if (requestResult?.responseResult?.responseCode) {
setActiveResponse('result');
}
}

View File

@ -19,6 +19,7 @@
</div>
</div>
<a-empty
v-if="props.showEmpty"
v-show="!props.requestResult?.responseResult.responseCode"
class="flex h-[150px] items-center gap-[16px] p-[16px]"
>
@ -55,14 +56,20 @@
import { RequestResult } from '@/models/apiTest/common';
import { ResponseComposition } from '@/enums/apiEnum';
const props = defineProps<{
const props = withDefaults(
defineProps<{
requestResult?: RequestResult;
console?: string;
isPriorityLocalExec: boolean;
requestUrl?: string;
isHttpProtocol: boolean;
isHttpProtocol?: boolean;
isDefinition?: boolean;
}>();
showEmpty?: boolean;
}>(),
{
showEmpty: true,
}
);
const emit = defineEmits(['execute']);
const { t } = useI18n();

View File

@ -104,7 +104,8 @@
:is-http-protocol="props.protocol === 'HTTP'"
:is-priority-local-exec="false"
:active-tab="ResponseComposition.BODY"
:request-task-result="responseContent"
:request-result="responseContent?.requestResults[0]"
:console="responseContent?.console"
:is-definition="true"
:is-response-model="true"
></response>

View File

@ -260,14 +260,14 @@
:is-priority-local-exec="isPriorityLocalExec"
:request-url="requestVModel.url"
:is-expanded="isVerticalExpanded"
:request-task-result="requestVModel.response"
:request-result="props.stepResponses?.[requestVModel.stepId]"
:console="props.stepResponses?.[requestVModel.stepId].console"
:show-empty="false"
:is-edit="false"
:upload-temp-file-api="uploadTempFile"
is-definition
:loading="requestVModel.executeLoading || loading"
:is-definition="false"
@change-expand="changeVerticalExpand"
@change-layout="handleActiveLayoutChange"
@change="handleActiveDebugChange"
@execute="execute"
/>
</template>
@ -311,6 +311,7 @@
ExecuteConditionConfig,
ExecuteRequestParams,
PluginConfig,
RequestResult,
RequestTaskResult,
} from '@/models/apiTest/common';
import { ScenarioStepItem } from '@/models/apiTest/scenario';
@ -383,6 +384,7 @@
create: string;
update: string;
};
stepResponses?: Record<string | number, RequestResult>;
}>();
const emit = defineEmits<{

View File

@ -102,6 +102,7 @@
import { getLocalConfig } from '@/api/modules/user/index';
import { characterLimit, getGenerateId } from '@/utils';
import { RequestResult } from '@/models/apiTest/common';
import { ScenarioStepItem } from '@/models/apiTest/scenario';
import { LocalConfig } from '@/models/user';
import {
@ -118,6 +119,7 @@
const props = defineProps<{
request?: RequestParam; //
stepResponses?: Record<string | number, RequestResult>;
}>();
const emit = defineEmits<{
(e: 'applyStep', request: RequestParam): void;
@ -355,7 +357,14 @@
() => visible.value,
async (val) => {
if (val) {
requestVModel.value = { ...cloneDeep(defaultCaseParams), ...props.request };
requestVModel.value = {
...cloneDeep(defaultCaseParams),
...props.request,
response: {
requestResults: [props.stepResponses?.[props.request?.stepId] || defaultResponse.requestResults[0]],
console: props.stepResponses?.[props.request?.stepId].console || '',
},
};
if (isQuote.value || isCopyNeedInit.value) {
// (request.requestrequest null)
initQuoteCaseDetail();

View File

@ -22,10 +22,15 @@
color: 'rgb(var(--link-6))',
text: 'common.stop',
},
[ScenarioExecuteStatus.EXECUTING]: {
bgColor: 'rgb(var(--link-2))',
color: 'rgb(var(--link-6))',
text: 'apiScenario.running',
},
[ScenarioExecuteStatus.FAILED]: {
bgColor: 'rgb(var(--danger-2))',
color: 'rgb(var(--danger-6))',
text: 'common.failed',
text: 'common.fail',
},
[ScenarioExecuteStatus.SUCCESS]: {
bgColor: 'rgb(var(--success-2))',

View File

@ -63,6 +63,7 @@ export const defaultStepItemCommon = {
enable: true,
},
createActionsVisible: false,
responsePopoverVisible: false,
};
export const defaultScenario: Scenario = {
@ -112,6 +113,8 @@ export const defaultScenario: Scenario = {
isNew: true,
unSaved: false,
executeLoading: false, // 执行loading
isDebug: false,
stepResponses: {},
};
export const conditionOptions = [

View File

@ -6,6 +6,7 @@
v-show="scenario.steps.length > 0"
v-model:model-value="checkedAll"
:indeterminate="indeterminate"
:disabled="scenarioExecuteLoading"
@change="handleChangeAll"
/>
<div class="flex items-center gap-[4px]">
@ -28,16 +29,16 @@
</a-button>
</a-tooltip>
<template v-if="checkedAll || indeterminate">
<a-button type="outline" size="mini" @click="batchEnable">
<a-button type="outline" size="mini" :disabled="scenarioExecuteLoading" @click="batchEnable">
{{ t('common.batchEnable') }}
</a-button>
<a-button type="outline" size="mini" @click="batchDisable">
<a-button type="outline" size="mini" :disabled="scenarioExecuteLoading" @click="batchDisable">
{{ t('common.batchDisable') }}
</a-button>
<a-button type="outline" size="mini" @click="batchDebug">
<a-button type="outline" size="mini" :disabled="scenarioExecuteLoading" @click="batchDebug">
{{ t('common.batchDebug') }}
</a-button>
<a-button type="outline" size="mini" @click="batchDelete">
<a-button type="outline" size="mini" :disabled="scenarioExecuteLoading" @click="batchDelete">
{{ t('common.batchDelete') }}
</a-button>
</template>
@ -57,7 +58,9 @@
<div class="text-[var(--color-text-1)]">{{ t('common.fail') }}</div>
<div class="text-[rgb(var(--success-6))]">{{ scenario.executeFailCount }}</div>
</div>
<MsButton type="text" @click="checkReport">{{ t('apiScenario.checkReport') }}</MsButton>
<MsButton v-if="scenario.isDebug === false" type="text" @click="checkReport">
{{ t('apiScenario.checkReport') }}
</MsButton>
</div>
</template>
<div v-if="!checkedAll && !indeterminate" class="action-group ml-auto">
@ -87,6 +90,8 @@
v-model:stepKeyword="keyword"
:expand-all="isExpandAll"
:step-details="scenario.stepDetails"
:step-responses="scenario.stepResponses"
@update-resource="handleUpdateResource"
/>
</div>
</div>
@ -118,27 +123,26 @@
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import stepTree from './stepTree.vue';
import { localExecuteApiDebug } from '@/api/modules/api-test/common';
import { debugScenario } from '@/api/modules/api-test/scenario';
import { getSocket } from '@/api/modules/project-management/commonScript';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { deleteNodes, filterTree, getGenerateId } from '@/utils';
import { countNodes } from '@/utils/tree';
import { ApiScenarioDebugRequest, Scenario } from '@/models/apiTest/scenario';
import { EnvConfig } from '@/models/projectManagement/environmental';
import { ScenarioExecuteStatus } from '@/enums/apiEnum';
const props = defineProps<{
isNew?: boolean; //
}>();
const emit = defineEmits<{
(e: 'batchDebug', data: Pick<ApiScenarioDebugRequest, 'steps' | 'stepDetails' | 'reportId'>): void;
}>();
const appStore = useAppStore();
const { t } = useI18n();
const scenario = defineModel<Scenario>('scenario', {
required: true,
});
const scenarioExecuteLoading = inject<Ref<boolean>>('scenarioExecuteLoading');
const checkedAll = ref(false); //
const indeterminate = ref(false); //
@ -229,95 +233,46 @@
console.log('刷新步骤信息');
}
const currentEnvConfig = inject<Ref<EnvConfig>>('currentEnvConfig');
const stepReportId = ref('');
const websocket = ref<WebSocket>();
const temporaryStepReportMap = {}; // websockettab
/**
* 开启websocket监听接收执行结果
*/
function debugSocket(executeType?: 'localExec' | 'serverExec', localExecuteUrl?: string) {
websocket.value = getSocket(
stepReportId.value,
executeType === 'localExec' ? '/ws/debug' : '',
executeType === 'localExec' ? localExecuteUrl : ''
);
websocket.value.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.msgType === 'EXEC_RESULT') {
if (scenario.value.stepReportId === data.reportId) {
// tabtab
scenario.value.executeLoading = false;
scenario.value.isExecute = false;
} else {
// tab
temporaryStepReportMap[data.reportId] = data.taskResult;
}
} else if (data.msgType === 'EXEC_END') {
// websocket
websocket.value?.close();
if (scenario.value.reportId === data.reportId) {
scenario.value.executeLoading = false;
scenario.value.isExecute = false;
}
}
});
}
async function realExecute(
executeParams: ApiScenarioDebugRequest,
executeType?: 'localExec' | 'serverExec',
localExecuteUrl?: string
) {
try {
scenario.value.executeLoading = true;
stepReportId.value = getGenerateId();
scenario.value.reportId = stepReportId.value; // ID
debugSocket(executeType, localExecuteUrl); // websocket
executeParams.environmentId = currentEnvConfig?.value.id || '';
const res = await debugScenario(executeParams);
if (executeType === 'localExec' && localExecuteUrl) {
await localExecuteApiDebug(localExecuteUrl, res);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
scenario.value.executeLoading = false;
}
}
function batchDebug() {
const selectedKeysSet = new Set(checkedKeys.value);
const waitTingDebugSteps = filterTree(
scenario.value.steps,
(node) => {
if (selectedKeysSet.has(node.id)) {
selectedKeysSet.delete(node.id);
return true;
scenario.value.executeLoading = true;
const checkedKeysSet = new Set(checkedKeys.value);
const waitTingDebugSteps = filterTree(scenario.value.steps, (node) => {
if (checkedKeysSet.has(node.id)) {
if (!node.enable) {
// id便waitingDebugStepDetails
checkedKeysSet.delete(node.id);
} else {
node.executeStatus = ScenarioExecuteStatus.EXECUTING;
}
return !!node.enable;
}
return false;
},
'id'
);
});
const waitingDebugStepDetails = {};
Object.keys(scenario.value.stepDetails).forEach((key) => {
if (selectedKeysSet.has(key)) {
if (checkedKeysSet.has(key)) {
waitingDebugStepDetails[key] = scenario.value.stepDetails[key];
}
});
realExecute({
id: scenario.value.id || '',
emit('batchDebug', {
steps: waitTingDebugSteps,
stepDetails: waitingDebugStepDetails,
grouped: false,
environmentId: currentEnvConfig?.value.id || '',
uploadFileIds: scenario.value.uploadFileIds,
linkFileIds: scenario.value.linkFileIds,
projectId: appStore.currentProjectId,
scenarioConfig: scenario.value.scenarioConfig,
reportId: getGenerateId(),
});
}
function handleUpdateResource(uploadFileIds, linkFileIds) {
const uploadFileIdsSet = new Set(scenario.value.uploadFileIds);
const linkFileIdsSet = new Set(scenario.value.linkFileIds);
uploadFileIds.forEach((id) => {
uploadFileIdsSet.add(id);
});
linkFileIds.forEach((id) => {
linkFileIdsSet.add(id);
});
// scenario.value.uploadFileIds = Array.from(uploadFileIdsSet);
// scenario.value.linkFileIds = Array.from(linkFileIdsSet);
}
</script>
<style lang="less">

View File

@ -168,7 +168,33 @@
/>
</template>
<template #extraEnd="step">
<executeStatus v-if="step.executeStatus" :status="step.executeStatus" size="small" />
<a-popover
v-if="step.executeStatus"
position="br"
content-class="scenario-step-response-popover"
@popup-visible-change="handleResponsePopoverVisibleChange($event, step)"
>
<executeStatus :status="getExecuteStatus(step) || step.executeStatus" size="small" />
<template #content>
<responseResult
:active-tab="ResponseComposition.BODY"
:request-result="props.stepResponses?.[step.id]"
:console="props.stepResponses?.[step.id].console"
:show-empty="false"
:is-edit="false"
is-definition
>
<template #titleLeft>
<div class="flex items-center text-[14px]">
<div class="font-medium text-[var(--color-text-1)]">{{ t('apiScenario.response') }}</div>
<a-tooltip :content="step.name">
<div class="one-line-text">({{ step.name }})</div>
</a-tooltip>
</div>
</template>
</responseResult>
</template>
</a-popover>
</template>
<template v-if="steps.length === 0 && stepKeyword.trim() !== ''" #empty>
<div
@ -192,6 +218,7 @@
:env-detail-item="{ id: 'demp-id-112233', projectId: '123456', name: 'demo环境' }"
:request="currentStepDetail"
:step="activeStep"
:step-responses="props.stepResponses"
@add-step="addCustomApiStep"
@apply-step="applyApiStep"
/>
@ -199,6 +226,7 @@
v-model:visible="customCaseDrawerVisible"
:active-step="activeStep"
:request="currentStepDetail"
:step-responses="props.stepResponses"
@apply-step="applyApiStep"
@delete-step="deleteCaseStep"
/>
@ -266,6 +294,7 @@
import waitTimeContent from './stepNodeComposition/waitTimeContent.vue';
import apiMethodName from '@/views/api-test/components/apiMethodName.vue';
import { RequestParam as CaseRequestParam } from '@/views/api-test/components/requestComposition/index.vue';
import responseResult from '@/views/api-test/components/requestComposition/response/index.vue';
import { getScenarioStep } from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n';
@ -280,9 +309,15 @@
TreeNode,
} from '@/utils';
import { ExecuteConditionProcessor } from '@/models/apiTest/common';
import { ExecuteConditionProcessor, RequestResult } from '@/models/apiTest/common';
import { CreateStepAction, ScenarioStepItem } from '@/models/apiTest/scenario';
import { ScenarioAddStepActionType, ScenarioStepRefType, ScenarioStepType } from '@/enums/apiEnum';
import {
ResponseComposition,
ScenarioAddStepActionType,
ScenarioExecuteStatus,
ScenarioStepRefType,
ScenarioStepType,
} from '@/enums/apiEnum';
import type { RequestParam } from '../common/customApiDrawer.vue';
import useCreateActions from './createAction/useCreateActions';
@ -299,6 +334,10 @@
const props = defineProps<{
stepKeyword: string;
expandAll?: boolean;
stepResponses?: Record<string | number, RequestResult>;
}>();
const emit = defineEmits<{
(e: 'updateResource', uploadFileIds: string[], linkFileIds: string[]): void;
}>();
const appStore = useAppStore();
@ -314,11 +353,31 @@
const stepDetails = defineModel<Record<string, any>>('stepDetails', {
required: true,
});
const scenarioExecuteLoading = inject<Ref<boolean>>('scenarioExecuteLoading');
const selectedKeys = ref<(string | number)[]>([]); //
const loading = ref(false);
const treeRef = ref<InstanceType<typeof MsTree>>();
const focusStepKey = ref<string>(''); // key
const focusStepKey = ref<string | number>(''); // key
function setFocusNodeKey(id: string | number) {
focusStepKey.value = id || '';
}
function getExecuteStatus(step: ScenarioStepItem) {
if (props.stepResponses && props.stepResponses[step.id]) {
return props.stepResponses[step.id].isSuccessful ? ScenarioExecuteStatus.SUCCESS : ScenarioExecuteStatus.FAILED;
}
return step.executeStatus;
}
function handleResponsePopoverVisibleChange(visible: boolean, step: ScenarioStepItem) {
if (visible) {
setFocusNodeKey(step.id);
} else {
setFocusNodeKey('');
}
}
/**
* 根据步骤类型获取步骤内容组件
@ -342,10 +401,6 @@
}
}
function setFocusNodeKey(id: string) {
focusStepKey.value = id || '';
}
function checkStepIsApi(step: ScenarioStepItem) {
return [ScenarioStepType.API, ScenarioStepType.API_CASE, ScenarioStepType.CUSTOM_REQUEST].includes(step.stepType);
}
@ -630,6 +685,9 @@
}
function executeStep(node: MsTreeNodeData) {
if (scenarioExecuteLoading?.value) {
return;
}
console.log('执行步骤', node);
}
@ -721,6 +779,7 @@
function addCustomApiStep(request: RequestParam) {
request.isNew = false;
stepDetails.value[request.stepId] = request;
emit('updateResource', request.uploadFileIds, request.linkFileIds);
if (activeStep.value && activeCreateAction.value) {
handleCreateStep(
{
@ -761,6 +820,7 @@
if (activeStep.value) {
request.isNew = false;
stepDetails.value[activeStep.value?.id] = request;
emit('updateResource', request.uploadFileIds, request.linkFileIds);
activeStep.value = undefined;
}
}
@ -947,6 +1007,27 @@
color: rgb(var(--primary-5));
background-color: rgb(var(--primary-1));
}
.scenario-step-response-popover {
width: 500px;
height: 450px;
.arco-popover-content {
@apply h-full;
.response {
.response-head {
background-color: var(--color-text-n9);
}
border: 1px solid var(--color-text-n8);
border-radius: var(--border-radius-small);
.arco-spin {
padding: 0;
.response-container {
padding: 0 16px 14px;
}
}
}
}
}
</style>
<style lang="less" scoped>

View File

@ -7,7 +7,12 @@
:title="t('apiScenario.step')"
class="scenario-create-tab-pane"
>
<step v-if="activeKey === ScenarioCreateComposition.STEP" v-model:scenario="scenario" is-new />
<step
v-if="activeKey === ScenarioCreateComposition.STEP"
v-model:scenario="scenario"
is-new
@batch-debug="emit('batchDebug', $event)"
/>
</a-tab-pane>
<a-tab-pane
:key="ScenarioCreateComposition.PARAMS"
@ -171,7 +176,7 @@
import { useI18n } from '@/hooks/useI18n';
import { Scenario } from '@/models/apiTest/scenario';
import { ApiScenarioDebugRequest, Scenario } from '@/models/apiTest/scenario';
import { ModuleTreeNode } from '@/models/common';
import { ApiScenarioStatus, ScenarioCreateComposition } from '@/enums/apiEnum';
@ -187,6 +192,9 @@
const props = defineProps<{
moduleTree: ModuleTreeNode[]; //
}>();
const emit = defineEmits<{
(e: 'batchDebug', data: Pick<ApiScenarioDebugRequest, 'steps' | 'stepDetails' | 'reportId'>): void;
}>();
const { t } = useI18n();

View File

@ -48,7 +48,11 @@
:title="t('apiScenario.step')"
class="scenario-detail-tab-pane"
>
<step v-if="activeKey === ScenarioDetailComposition.STEP" v-model:scenario="scenario" />
<step
v-if="activeKey === ScenarioDetailComposition.STEP"
v-model:scenario="scenario"
@batch-debug="emit('batchDebug', $event)"
/>
</a-tab-pane>
<a-tab-pane
:key="ScenarioDetailComposition.PARAMS"
@ -133,7 +137,7 @@
import step from '../components/step/index.vue';
import apiStatus from '@/views/api-test/components/apiStatus.vue';
import { Scenario, ScenarioDetail } from '@/models/apiTest/scenario';
import { ApiScenarioDebugRequest, Scenario, ScenarioDetail } from '@/models/apiTest/scenario';
import { ScenarioDetailComposition } from '@/enums/apiEnum';
//
@ -146,7 +150,10 @@
// const quote = defineAsyncComponent(() => import('../components/quote.vue'));
const setting = defineAsyncComponent(() => import('../components/setting.vue'));
const emit = defineEmits(['updateFollow']);
const emit = defineEmits<{
(e: 'batchDebug', data: Pick<ApiScenarioDebugRequest, 'steps' | 'stepDetails' | 'reportId'>): void;
(e: 'updateFollow'): void;
}>();
const { copy, isSupported } = useClipboard();
const { t } = useI18n();

View File

@ -66,10 +66,15 @@
</MsSplitBox>
</div>
<div v-else-if="activeScenarioTab.isNew" class="pageWrap">
<create ref="createRef" v-model:scenario="activeScenarioTab" :module-tree="folderTree"></create>
<create
ref="createRef"
v-model:scenario="activeScenarioTab"
:module-tree="folderTree"
@batch-debug="realExecute"
></create>
</div>
<div v-else class="pageWrap">
<detail v-model:scenario="activeScenarioTab"></detail>
<detail v-model:scenario="activeScenarioTab" @batch-debug="realExecute"></detail>
</div>
</MsCard>
</template>
@ -81,6 +86,7 @@
import { Message } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
import dayjs from 'dayjs';
import MsCard from '@/components/pure/ms-card/index.vue';
import MsEditableTab from '@/components/pure/ms-editable-tab/index.vue';
@ -104,7 +110,7 @@
import { useI18n } from '@/hooks/useI18n';
import router from '@/router';
import useAppStore from '@/store/modules/app';
import { getGenerateId } from '@/utils';
import { filterTree, getGenerateId } from '@/utils';
import {
ApiScenarioDebugRequest,
@ -114,6 +120,7 @@
} from '@/models/apiTest/scenario';
import { ModuleTreeNode } from '@/models/common';
import { EnvConfig } from '@/models/projectManagement/environmental';
import { ScenarioExecuteStatus } from '@/enums/apiEnum';
import { ApiTestRouteEnum } from '@/enums/routeEnum';
import { defaultScenario } from './components/config';
@ -124,6 +131,7 @@
export type ScenarioParams = Scenario & TabItem;
const appStore = useAppStore();
const { t } = useI18n();
const scenarioTabs = ref<ScenarioParams[]>([
@ -142,6 +150,7 @@
id: isCopy ? getGenerateId() : defaultScenarioInfo.id || '',
label: isCopy ? `copy-${defaultScenarioInfo.name}` : defaultScenarioInfo.name,
isNew: false,
stepResponses: {},
});
} else {
scenarioTabs.value.push({
@ -149,6 +158,7 @@
id: getGenerateId(),
label: `${t('apiScenario.createScenario')}${scenarioTabs.value.length}`,
moduleId: 'root',
projectId: appStore.currentProjectId,
priority: 'P0',
});
}
@ -166,7 +176,6 @@
const getActiveClass = (type: string) => {
return activeFolder.value === type ? 'folder-text case-active' : 'folder-text';
};
const appStore = useAppStore();
const recycleModulesCount = ref(0);
const scenarioModuleTreeRef = ref<InstanceType<typeof scenarioModuleTree>>();
@ -272,16 +281,15 @@
}
const currentEnvConfig = ref<EnvConfig>();
const reportId = ref('');
const websocket = ref<WebSocket>();
const temporaryScenarioReportMap = {}; // websockettab
/**
* 开启websocket监听接收执行结果
*/
function debugSocket(executeType?: 'localExec' | 'serverExec', localExecuteUrl?: string) {
function debugSocket(reportId?: string | number, executeType?: 'localExec' | 'serverExec', localExecuteUrl?: string) {
websocket.value = getSocket(
reportId.value,
reportId || '',
executeType === 'localExec' ? '/ws/debug' : '',
executeType === 'localExec' ? localExecuteUrl : ''
);
@ -289,12 +297,31 @@
const data = JSON.parse(event.data);
if (data.msgType === 'EXEC_RESULT') {
if (activeScenarioTab.value.reportId === data.reportId) {
// tabtab
activeScenarioTab.value.executeLoading = false;
activeScenarioTab.value.isExecute = false;
// tabtab
data.taskResult.requestResults.forEach((result) => {
activeScenarioTab.value.stepResponses[result.stepId] = {
...result,
console: data.taskResult.console,
};
if (result.isSuccessful) {
activeScenarioTab.value.executeSuccessCount += 1;
} else {
activeScenarioTab.value.executeFailCount += 1;
}
});
} else {
// tab
temporaryScenarioReportMap[data.reportId] = data.taskResult;
data.taskResult.requestResults.forEach((result) => {
if (activeScenarioTab.value.reportId) {
if (temporaryScenarioReportMap[activeScenarioTab.value.reportId] === undefined) {
temporaryScenarioReportMap[activeScenarioTab.value.reportId] = {};
}
temporaryScenarioReportMap[activeScenarioTab.value.reportId][result.stepId] = {
...result,
console: data.taskResult.console,
};
}
});
}
} else if (data.msgType === 'EXEC_END') {
// websocket
@ -308,34 +335,55 @@
}
async function realExecute(
executeParams: ApiScenarioDebugRequest,
executeParams: Pick<ApiScenarioDebugRequest, 'steps' | 'stepDetails' | 'reportId'>,
executeType?: 'localExec' | 'serverExec',
localExecuteUrl?: string
) {
try {
activeScenarioTab.value.executeLoading = true;
reportId.value = getGenerateId();
activeScenarioTab.value.reportId = reportId.value; // ID
debugSocket(executeType, localExecuteUrl); // websocket
executeParams.environmentId = currentEnvConfig.value?.id || '';
const res = await debugScenario(executeParams);
debugSocket(executeParams.reportId, executeType, localExecuteUrl); // websocket
//
activeScenarioTab.value.executeTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
activeScenarioTab.value.executeSuccessCount = 0;
activeScenarioTab.value.executeFailCount = 0;
activeScenarioTab.value.stepResponses = {};
activeScenarioTab.value.reportId = executeParams.reportId; // ID
activeScenarioTab.value.isDebug = true;
const res = await debugScenario({
id: activeScenarioTab.value.id,
grouped: false,
environmentId: currentEnvConfig.value?.id || '',
projectId: appStore.currentProjectId,
scenarioConfig: activeScenarioTab.value.scenarioConfig,
uploadFileIds: activeScenarioTab.value.uploadFileIds,
linkFileIds: activeScenarioTab.value.linkFileIds,
...executeParams,
});
if (executeType === 'localExec' && localExecuteUrl) {
await localExecuteApiDebug(localExecuteUrl, res);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
websocket.value?.close();
activeScenarioTab.value.executeLoading = false;
}
}
function handleExecute(executeType?: 'localExec' | 'serverExec', localExecuteUrl?: string) {
const environmentId = currentEnvConfig.value?.id || '';
const waitingDebugStepDetails = {};
const waitTingDebugSteps = filterTree(activeScenarioTab.value.steps, (node) => {
if (node.enable) {
node.executeStatus = ScenarioExecuteStatus.EXECUTING;
waitingDebugStepDetails[node.id] = activeScenarioTab.value.stepDetails[node.id];
}
return !!node.enable;
});
realExecute(
{
grouped: false,
environmentId,
...activeScenarioTab.value,
steps: waitTingDebugSteps,
stepDetails: waitingDebugStepDetails,
reportId: getGenerateId(),
},
executeType,
localExecuteUrl
@ -347,13 +395,36 @@
activeScenarioTab.value.executeLoading = false;
}
watch(
() => activeScenarioTab.value.id,
(val) => {
if (val !== 'all' && activeScenarioTab.value.reportId && !activeScenarioTab.value.executeLoading) {
// tab tab ID
const cacheReport = temporaryScenarioReportMap[activeScenarioTab.value.reportId];
if (cacheReport) {
//
Object.keys(cacheReport).forEach((stepId) => {
const result = cacheReport[stepId];
activeScenarioTab.value.stepResponses[stepId] = result;
if (result.isSuccessful) {
activeScenarioTab.value.executeSuccessCount += 1;
} else {
activeScenarioTab.value.executeFailCount += 1;
}
});
activeScenarioTab.value.executeLoading = false;
delete temporaryScenarioReportMap[activeScenarioTab.value.reportId]; //
}
}
}
);
const scenarioId = computed(() => activeScenarioTab.value.id);
const scenarioExecuteLoading = computed(() => activeScenarioTab.value.executeLoading);
//
provide('currentEnvConfig', readonly(currentEnvConfig));
provide('scenarioId', scenarioId);
provide('scenarioExecuteLoading', scenarioExecuteLoading);
provide('temporaryScenarioReportMap', readonly(temporaryScenarioReportMap));
</script>
<style scoped lang="less">

View File

@ -136,6 +136,8 @@ export default {
'apiScenario.allStep': '所有子步骤',
'apiScenario.saveAsApi': '保存为新接口',
'apiScenario.scenarioLevel': '场景等级',
'apiScenario.running': '执行中',
'apiScenario.response': '响应内容',
// 执行历史
'apiScenario.executeHistory.searchPlaceholder': '通过ID或名称搜索',
'apiScenario.executeHistory.num': '序号',