This commit is contained in:
shiziyuan9527 2020-07-22 11:35:30 +08:00
commit 0b1a0964d1
45 changed files with 1347 additions and 179 deletions

View File

@ -172,7 +172,7 @@ v1.1.0 是 v1.0.0 之后的功能版本。
</tbody>
</table>
详细版本规划请参考 [版本路线图](https://github.com/metersphere/metersphere/blob/master/ROADMAP.md)
详细版本规划请参考 [版本路线图](https://github.com/metersphere/metersphere/blob/master/ROADMAP.md)
## 技术栈

View File

@ -123,7 +123,7 @@
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.45</version>
<version>1.2.72</version>
</dependency>
<dependency>
@ -201,17 +201,6 @@
<version>2.1.7</version>
</dependency>
<!--
该依赖是私有仓库的依赖,现已经发布到 Github Packages下载请在 settings 文件中配置自己的 GITHUB_TOKEN
示例:
<servers>
<server>
<id>github</id>
<username>USERNAME</username>
<password>TOKEN</password>
</server>
</servers>
-->
<dependency>
<groupId>com.fit2cloud</groupId>
<artifactId>quartz-spring-boot-starter</artifactId>
@ -358,18 +347,33 @@
</plugins>
</build>
<!--
项目中依赖了某些私有仓库的包,现已经发布到 Github Packages
开发者下载依赖可按照 Github Maven 仓库的使用指导添加 Maven 仓库地址https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-apache-maven-for-use-with-github-packages
示例:下载请在 settings 文件中配置自己 Github 账号和 GITHUB_TOKEN
<servers>
<server>
<id>fit2cloud</id>
<username>USERNAME</username>
<password>TOKEN</password>
</server>
<server>
<id>metersphere</id>
<username>USERNAME</username>
<password>TOKEN</password>
</server>
</servers>
-->
<repositories>
<repository>
<id>github</id>
<id>fit2cloud</id>
<name>fit2cloud</name>
<url>https://maven.pkg.github.com/fit2cloud/quartz-spring-boot-starter</url>
</repository>
<repository>
<id>github</id>
<id>metersphere</id>
<name>metersphere</name>
<url>https://maven.pkg.github.com/metersphere/jmeter-plugins-for-apache-dubbo</url>
</repository>
</repositories>
</project>

View File

@ -3,6 +3,7 @@ package io.metersphere.api.controller;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import io.metersphere.api.dto.*;
import io.metersphere.api.dto.scenario.request.dubbo.RegistryCenter;
import io.metersphere.api.service.APITestService;
import io.metersphere.base.domain.ApiTest;
import io.metersphere.base.domain.Schedule;
@ -98,4 +99,8 @@ public class APITestController {
return apiTestService.apiTestImport(file, request);
}
@PostMapping("/dubbo/providers")
public List<DubboProvider> getProviders(@RequestBody RegistryCenter registry) {
return apiTestService.getProviders(registry);
}
}

View File

@ -0,0 +1,13 @@
package io.metersphere.api.dto;
import lombok.Data;
import java.util.List;
@Data
public class DubboProvider {
private String version;
private String service;
private String serviceInterface;
private List<String> methods;
}

View File

@ -0,0 +1,13 @@
package io.metersphere.api.dto.scenario;
import io.metersphere.api.dto.scenario.request.dubbo.ConfigCenter;
import io.metersphere.api.dto.scenario.request.dubbo.ConsumerAndService;
import io.metersphere.api.dto.scenario.request.dubbo.RegistryCenter;
import lombok.Data;
@Data
public class DubboConfig {
private ConfigCenter configCenter;
private RegistryCenter registryCenter;
private ConsumerAndService consumerAndService;
}

View File

@ -1,21 +0,0 @@
package io.metersphere.api.dto.scenario;
import io.metersphere.api.dto.scenario.assertions.Assertions;
import io.metersphere.api.dto.scenario.extract.Extract;
import lombok.Data;
import java.util.List;
@Data
public class Request {
private String name;
private String url;
private String method;
private Boolean useEnvironment;
private String path;
private List<KeyValue> parameters;
private List<KeyValue> headers;
private Body body;
private Assertions assertions;
private Extract extract;
}

View File

@ -1,5 +1,6 @@
package io.metersphere.api.dto.scenario;
import io.metersphere.api.dto.scenario.request.Request;
import lombok.Data;
import java.util.List;
@ -12,4 +13,5 @@ public class Scenario {
private List<KeyValue> variables;
private List<KeyValue> headers;
private List<Request> requests;
private DubboConfig dubboConfig;
}

View File

@ -0,0 +1,46 @@
package io.metersphere.api.dto.scenario.request;
import com.alibaba.fastjson.annotation.JSONField;
import com.alibaba.fastjson.annotation.JSONType;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.metersphere.api.dto.scenario.KeyValue;
import io.metersphere.api.dto.scenario.assertions.Assertions;
import io.metersphere.api.dto.scenario.extract.Extract;
import io.metersphere.api.dto.scenario.request.dubbo.ConfigCenter;
import io.metersphere.api.dto.scenario.request.dubbo.ConsumerAndService;
import io.metersphere.api.dto.scenario.request.dubbo.RegistryCenter;
import lombok.Data;
import java.util.List;
@Data
@JSONType(typeName = RequestType.DUBBO)
public class DubboRequest implements Request {
// type 必须放最前面以便能够转换正确的类
private String type = RequestType.DUBBO;
@JSONField(ordinal = 1)
private String name;
@JSONField(ordinal = 2)
private String protocol;
@JsonProperty(value = "interface")
@JSONField(ordinal = 3, name = "interface")
private String _interface;
@JSONField(ordinal = 4)
private String method;
@JSONField(ordinal = 5)
private ConfigCenter configCenter;
@JSONField(ordinal = 6)
private RegistryCenter registryCenter;
@JSONField(ordinal = 7)
private ConsumerAndService consumerAndService;
@JSONField(ordinal = 8)
private List<KeyValue> args;
@JSONField(ordinal = 9)
private List<KeyValue> attachmentArgs;
@JSONField(ordinal = 10)
private Assertions assertions;
@JSONField(ordinal = 11)
private Extract extract;
}

View File

@ -0,0 +1,38 @@
package io.metersphere.api.dto.scenario.request;
import com.alibaba.fastjson.annotation.JSONField;
import com.alibaba.fastjson.annotation.JSONType;
import io.metersphere.api.dto.scenario.Body;
import io.metersphere.api.dto.scenario.KeyValue;
import io.metersphere.api.dto.scenario.assertions.Assertions;
import io.metersphere.api.dto.scenario.extract.Extract;
import lombok.Data;
import java.util.List;
@Data
@JSONType(typeName = RequestType.HTTP)
public class HttpRequest implements Request {
// type 必须放最前面以便能够转换正确的类
private String type = RequestType.HTTP;
@JSONField(ordinal = 1)
private String name;
@JSONField(ordinal = 2)
private String url;
@JSONField(ordinal = 3)
private String method;
@JSONField(ordinal = 4)
private String path;
@JSONField(ordinal = 5)
private Boolean useEnvironment;
@JSONField(ordinal = 6)
private List<KeyValue> parameters;
@JSONField(ordinal = 7)
private List<KeyValue> headers;
@JSONField(ordinal = 8)
private Body body;
@JSONField(ordinal = 9)
private Assertions assertions;
@JSONField(ordinal = 10)
private Extract extract;
}

View File

@ -0,0 +1,14 @@
package io.metersphere.api.dto.scenario.request;
import com.alibaba.fastjson.annotation.JSONType;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = HttpRequest.class, name = RequestType.HTTP),
@JsonSubTypes.Type(value = DubboRequest.class, name = RequestType.DUBBO)
})
@JSONType(seeAlso = {HttpRequest.class, DubboRequest.class}, typeKey = "type")
public interface Request {
}

View File

@ -0,0 +1,8 @@
package io.metersphere.api.dto.scenario.request;
public class RequestType {
public static final String HTTP = "HTTP";
public static final String DUBBO = "DUBBO";
}

View File

@ -0,0 +1,14 @@
package io.metersphere.api.dto.scenario.request.dubbo;
import lombok.Data;
@Data
public class ConfigCenter {
private String protocol;
private String group;
private String namespace;
private String username;
private String address;
private String password;
private String timeout;
}

View File

@ -0,0 +1,15 @@
package io.metersphere.api.dto.scenario.request.dubbo;
import lombok.Data;
@Data
public class ConsumerAndService {
private String timeout;
private String version;
private String retries;
private String cluster;
private String group;
private String connections;
private String async;
private String loadBalance;
}

View File

@ -0,0 +1,13 @@
package io.metersphere.api.dto.scenario.request.dubbo;
import lombok.Data;
@Data
public class RegistryCenter {
private String protocol;
private String group;
private String username;
private String address;
private String password;
private String timeout;
}

View File

@ -1,5 +1,6 @@
package io.metersphere.api.jmeter;
import io.github.ningyu.jmeter.plugin.dubbo.sample.ProviderService;
import io.metersphere.api.service.APIReportService;
import io.metersphere.api.service.APITestService;
import io.metersphere.commons.constants.APITestStatus;
@ -107,14 +108,11 @@ public class APIBackendListenerClient extends AbstractBackendListenerClient impl
}
private RequestResult getRequestResult(SampleResult result) {
String body = result.getSamplerData();
String method = StringUtils.substringBefore(body, " ");
RequestResult requestResult = new RequestResult();
requestResult.setName(result.getSampleLabel());
requestResult.setUrl(result.getUrlAsString());
requestResult.setMethod(method);
requestResult.setBody(body);
requestResult.setMethod(getMethod(result));
requestResult.setBody(result.getSamplerData());
requestResult.setHeaders(result.getRequestHeaders());
requestResult.setRequestSize(result.getSentBytes());
requestResult.setTotalAssertions(result.getAssertionResults().length);
@ -143,6 +141,19 @@ public class APIBackendListenerClient extends AbstractBackendListenerClient impl
return requestResult;
}
private String getMethod(SampleResult result) {
String body = result.getSamplerData();
// Dubbo Protocol
String start = "RPC Protocol: ";
String end = "://";
if (StringUtils.contains(body, start)) {
return StringUtils.substringBetween(body, start, end).toUpperCase();
} else {
// Http Method
return StringUtils.substringBefore(body, " ");
}
}
private ResponseAssertionResult getResponseAssertionResult(AssertionResult assertionResult) {
ResponseAssertionResult responseAssertionResult = new ResponseAssertionResult();
responseAssertionResult.setMessage(assertionResult.getFailureMessage());

View File

@ -2,8 +2,8 @@ package io.metersphere.api.parse;
import io.metersphere.api.dto.ApiTestImportRequest;
import io.metersphere.api.dto.scenario.KeyValue;
import io.metersphere.api.dto.scenario.Request;
import io.metersphere.api.dto.scenario.Scenario;
import io.metersphere.api.dto.scenario.request.HttpRequest;
import io.metersphere.commons.exception.MSException;
import io.metersphere.commons.utils.LogUtil;
import org.apache.commons.lang3.StringUtils;
@ -50,11 +50,11 @@ public abstract class ApiImportAbstractParser implements ApiImportParser {
}
}
protected void addContentType(Request request, String contentType) {
protected void addContentType(HttpRequest request, String contentType) {
addHeader(request, HttpHeader.CONTENT_TYPE.toString(), contentType);
}
protected void addCookie(Request request, String key, String value) {
protected void addCookie(HttpRequest request, String key, String value) {
List<KeyValue> headers = Optional.ofNullable(request.getHeaders()).orElse(new ArrayList<>());
boolean hasCookie = false;
for (KeyValue header : headers) {
@ -69,7 +69,7 @@ public abstract class ApiImportAbstractParser implements ApiImportParser {
}
}
protected void addHeader(Request request, String key, String value) {
protected void addHeader(HttpRequest request, String key, String value) {
List<KeyValue> headers = Optional.ofNullable(request.getHeaders()).orElse(new ArrayList<>());
boolean hasContentType = false;
for (KeyValue header : headers) {

View File

@ -7,8 +7,9 @@ import io.metersphere.api.dto.parse.ApiImport;
import io.metersphere.api.dto.parse.postman.*;
import io.metersphere.api.dto.scenario.Body;
import io.metersphere.api.dto.scenario.KeyValue;
import io.metersphere.api.dto.scenario.Request;
import io.metersphere.api.dto.scenario.request.HttpRequest;
import io.metersphere.api.dto.scenario.Scenario;
import io.metersphere.api.dto.scenario.request.Request;
import io.metersphere.commons.constants.MsRequestBodyType;
import io.metersphere.commons.constants.PostmanRequestBodyMode;
import org.apache.commons.lang3.StringUtils;
@ -62,7 +63,7 @@ public class PostmanParser extends ApiImportAbstractParser {
List<PostmanItem> item = postmanCollection.getItem();
List<Request> requests = new ArrayList<>();
for (PostmanItem requestItem : item) {
Request request = new Request();
HttpRequest request = new HttpRequest();
PostmanRequest requestDesc = requestItem.getRequest();
PostmanUrl url = requestDesc.getUrl();
request.setName(requestItem.getName());
@ -77,7 +78,7 @@ public class PostmanParser extends ApiImportAbstractParser {
return requests;
}
private Body parseBody(PostmanRequest requestDesc, Request request) {
private Body parseBody(PostmanRequest requestDesc, HttpRequest request) {
Body body = new Body();
JSONObject postmanBody = requestDesc.getBody();
if (postmanBody == null) {

View File

@ -6,8 +6,9 @@ import io.metersphere.api.dto.ApiTestImportRequest;
import io.metersphere.api.dto.parse.ApiImport;
import io.metersphere.api.dto.scenario.Body;
import io.metersphere.api.dto.scenario.KeyValue;
import io.metersphere.api.dto.scenario.Request;
import io.metersphere.api.dto.scenario.request.HttpRequest;
import io.metersphere.api.dto.scenario.Scenario;
import io.metersphere.api.dto.scenario.request.Request;
import io.metersphere.commons.constants.MsRequestBodyType;
import io.metersphere.commons.constants.SwaggerParameterType;
import io.swagger.models.*;
@ -51,7 +52,7 @@ public class Swagger2Parser extends ApiImportAbstractParser {
Set<HttpMethod> httpMethods = operationMap.keySet();
for (HttpMethod method : httpMethods) {
Operation operation = operationMap.get(method);
Request request = new Request();
HttpRequest request = new HttpRequest();
request.setName(operation.getOperationId());
request.setPath(pathName);
request.setUseEnvironment(true);
@ -76,13 +77,11 @@ public class Swagger2Parser extends ApiImportAbstractParser {
}
}
scenarioMap.values().forEach(scenario -> {
scenarios.add(scenario);
});
scenarios.addAll(scenarioMap.values());
return scenarios;
}
private void parseParameters(Operation operation, Map<String, Model> definitions, Request request) {
private void parseParameters(Operation operation, Map<String, Model> definitions, HttpRequest request) {
List<Parameter> parameters = operation.getParameters();
@ -113,17 +112,17 @@ public class Swagger2Parser extends ApiImportAbstractParser {
}
}
private void parseCookieParameters(Parameter parameter, Request request) {
private void parseCookieParameters(Parameter parameter, HttpRequest request) {
CookieParameter cookieParameter = (CookieParameter) parameter;
addCookie(request, cookieParameter.getName(), cookieParameter.getDescription());
}
private void parseHeaderParameters(Parameter parameter, Request request) {
private void parseHeaderParameters(Parameter parameter, HttpRequest request) {
HeaderParameter headerParameter = (HeaderParameter) parameter;
addHeader(request, headerParameter.getName(), headerParameter.getDescription());
}
private void parseBodyParameters(Parameter parameter, Request request, Map<String, Model> definitions) {
private void parseBodyParameters(Parameter parameter, HttpRequest request, Map<String, Model> definitions) {
BodyParameter bodyParameter = (BodyParameter) parameter;
Body body = Optional.ofNullable(request.getBody()).orElse(new Body());
body.setType(MsRequestBodyType.RAW.value());
@ -175,7 +174,7 @@ public class Swagger2Parser extends ApiImportAbstractParser {
return jsonObject;
}
private void parseFormDataParameters(Parameter parameter, Request request) {
private void parseFormDataParameters(Parameter parameter, HttpRequest request) {
Body body = Optional.ofNullable(request.getBody()).orElse(new Body());
body.setType(MsRequestBodyType.FORM_DATA.value());
List<KeyValue> keyValues = Optional.ofNullable(body.getKvs()).orElse(new ArrayList<>());
@ -184,7 +183,7 @@ public class Swagger2Parser extends ApiImportAbstractParser {
request.setBody(body);
}
private void parseQueryParameters(Parameter parameter, Request request) {
private void parseQueryParameters(Parameter parameter, HttpRequest request) {
QueryParameter queryParameter = (QueryParameter) parameter;
List<KeyValue> parameters = Optional.ofNullable(request.getParameters()).orElse(new ArrayList<>());
parameters.add(new KeyValue(queryParameter.getName(), "", queryParameter.getDescription()));

View File

@ -1,11 +1,10 @@
package io.metersphere.api.service;
import com.alibaba.fastjson.JSONObject;
import io.metersphere.api.dto.APITestResult;
import io.metersphere.api.dto.ApiTestImportRequest;
import io.metersphere.api.dto.QueryAPITestRequest;
import io.metersphere.api.dto.SaveAPITestRequest;
import io.github.ningyu.jmeter.plugin.dubbo.sample.ProviderService;
import io.metersphere.api.dto.*;
import io.metersphere.api.dto.parse.ApiImport;
import io.metersphere.api.dto.scenario.request.dubbo.RegistryCenter;
import io.metersphere.api.jmeter.JMeterService;
import io.metersphere.api.parse.ApiImportParser;
import io.metersphere.api.parse.ApiImportParserFactory;
@ -27,18 +26,17 @@ import io.metersphere.job.sechedule.ApiTestJob;
import io.metersphere.service.FileService;
import io.metersphere.service.ScheduleService;
import io.metersphere.track.service.TestCaseService;
import org.apache.dubbo.common.URL;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;
@Service
@ -290,4 +288,23 @@ public class APITestService {
}
return request;
}
public List<DubboProvider> getProviders(RegistryCenter registry) {
ProviderService providerService = ProviderService.get("provider");
List<String> providers = providerService.getProviders(registry.getProtocol(), registry.getAddress(), registry.getGroup());
List<DubboProvider> providerList = new ArrayList<>();
providers.forEach(p -> {
Map<String, URL> services = providerService.findByService(p);
services.forEach((k, v) -> {
DubboProvider provider = new DubboProvider();
provider.setVersion(v.getParameter("version"));
provider.setService(v.getServiceKey());
provider.setServiceInterface(v.getServiceInterface());
String[] methods = v.getParameter("methods").split(",");
provider.setMethods(Arrays.asList(methods));
providerList.add(provider);
});
});
return providerList;
}
}

View File

@ -101,7 +101,7 @@
<select id="getLoadTestByProjectId" resultType="io.metersphere.base.domain.LoadTest">
select id,name
from load_test
where project_id = #{projectId};
where project_id = #{projectId}
</select>
</mapper>

View File

@ -1,5 +1,6 @@
package io.metersphere.track.domain;
import io.metersphere.commons.constants.TestPlanTestCaseStatus;
import io.metersphere.track.dto.TestCaseReportMetricDTO;
import io.metersphere.track.dto.TestCaseReportStatusResultDTO;
import io.metersphere.track.dto.TestPlanCaseDTO;
@ -7,6 +8,7 @@ import io.metersphere.track.dto.TestPlanDTO;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class ReportResultChartComponent extends ReportComponent {
@ -25,7 +27,24 @@ public class ReportResultChartComponent extends ReportComponent {
@Override
public void afterBuild(TestCaseReportMetricDTO testCaseReportMetric) {
testCaseReportMetric.setExecuteResult(new ArrayList<>(reportStatusResultMap.values()));
testCaseReportMetric.setExecuteResult(getReportStatusResult());
}
private List<TestCaseReportStatusResultDTO> getReportStatusResult() {
List<TestCaseReportStatusResultDTO> reportStatusResult = new ArrayList<>();
addToReportStatusResultList(reportStatusResult, TestPlanTestCaseStatus.Pass.name());
addToReportStatusResultList(reportStatusResult, TestPlanTestCaseStatus.Failure.name());
addToReportStatusResultList(reportStatusResult, TestPlanTestCaseStatus.Blocking.name());
addToReportStatusResultList(reportStatusResult, TestPlanTestCaseStatus.Skip.name());
addToReportStatusResultList(reportStatusResult, TestPlanTestCaseStatus.Underway.name());
addToReportStatusResultList(reportStatusResult, TestPlanTestCaseStatus.Prepare.name());
return reportStatusResult;
}
private void addToReportStatusResultList(List<TestCaseReportStatusResultDTO> reportStatusResultList, String status) {
if (reportStatusResultMap.get(status) != null) {
reportStatusResultList.add(reportStatusResultMap.get(status));
}
}
private void getStatusResultMap(Map<String, TestCaseReportStatusResultDTO> reportStatusResultMap, TestPlanCaseDTO testCase) {

View File

@ -67,7 +67,7 @@
import {Test} from "./model/ScenarioModel"
import MsApiReportStatus from "../report/ApiReportStatus";
import MsApiReportDialog from "./ApiReportDialog";
import {checkoutTestManagerOrTestUser, downloadFile} from "../../../../common/js/utils";
import {checkoutTestManagerOrTestUser, downloadFile} from "@/common/js/utils";
import MsScheduleConfig from "../../common/components/MsScheduleConfig";
import ApiImport from "./components/import/ApiImport";

View File

@ -6,15 +6,17 @@
<div class="kv-row" v-for="(item, index) in items" :key="index">
<el-row type="flex" :gutter="20" justify="space-between" align="middle">
<el-col>
<el-input v-if="!suggestions" :disabled="isReadOnly" v-model="item.name" size="small" maxlength="100" @change="change"
:placeholder="$t('api_test.key')" show-word-limit/>
<el-input v-if="!suggestions" :disabled="isReadOnly" v-model="item.name" size="small" maxlength="100"
@change="change"
:placeholder="keyText" show-word-limit/>
<el-autocomplete :maxlength="100" v-if="suggestions" v-model="item.name" size="small"
:fetch-suggestions="querySearch" @change="change" :placeholder="$t('api_test.key')" show-word-limit/>
:fetch-suggestions="querySearch" @change="change" :placeholder="keyText"
show-word-limit/>
</el-col>
<el-col>
<el-input :disabled="isReadOnly" v-model="item.value" size="small" maxlength="500" @change="change"
:placeholder="$t('api_test.value')" show-word-limit/>
:placeholder="valueText" show-word-limit/>
</el-col>
<el-col class="kv-delete">
<el-button size="mini" class="el-icon-delete-solid" circle @click="remove(index)"
@ -32,6 +34,8 @@
name: "MsApiKeyValue",
props: {
keyPlaceholder: String,
valuePlaceholder: String,
description: String,
items: Array,
isReadOnly: {
@ -41,6 +45,15 @@
suggestions: Array
},
computed: {
keyText() {
return this.keyPlaceholder || this.$t("api_test.key");
},
valueText() {
return this.valuePlaceholder || this.$t("api_test.value");
}
},
methods: {
remove: function (index) {
this.items.splice(index, 1);

View File

@ -25,7 +25,7 @@
</el-dropdown-menu>
</el-dropdown>
</template>
<ms-api-request-config :is-read-only="isReadOnly" :scenario="scenario" :open="select"/>
<ms-api-request-config :is-read-only="isReadOnly" :scenario="scenario" @select="select"/>
</ms-api-collapse-item>
</draggable>
</ms-api-collapse>
@ -36,7 +36,7 @@
<el-main class="scenario-main">
<div class="scenario-form">
<ms-api-scenario-form :is-read-only="isReadOnly" :scenario="selected" :project-id="projectId" v-if="isScenario"/>
<ms-api-request-form :is-read-only="isReadOnly" :request="selected" :project-id="projectId" v-if="isRequest"/>
<ms-api-request-form :is-read-only="isReadOnly" :request="selected" v-if="isRequest"/>
</div>
</el-main>
</el-container>
@ -46,8 +46,8 @@
import MsApiCollapseItem from "./collapse/ApiCollapseItem";
import MsApiCollapse from "./collapse/ApiCollapse";
import MsApiRequestConfig from "./ApiRequestConfig";
import MsApiRequestForm from "./ApiRequestForm";
import MsApiRequestConfig from "./request/ApiRequestConfig";
import MsApiRequestForm from "./request/ApiRequestForm";
import MsApiScenarioForm from "./ApiScenarioForm";
import {Scenario, Request} from "../model/ScenarioModel";
import draggable from 'vuedraggable';

View File

@ -5,12 +5,19 @@
</el-form-item>
<el-form-item :label="$t('api_test.environment.environment')">
<el-select :disabled="isReadOnly" v-model="scenario.environmentId" class="environment-select" @change="environmentChange" clearable>
<el-option v-for="(environment, index) in environments" :key="index" :label="environment.name + ': ' + environment.protocol + '://' + environment.socket" :value="environment.id"/>
<el-button class="environment-button" size="mini" type="primary" @click="openEnvironmentConfig">{{$t('api_test.environment.environment_config')}}</el-button>
<el-select :disabled="isReadOnly" v-model="scenario.environmentId" class="environment-select"
@change="environmentChange" clearable>
<el-option v-for="(environment, index) in environments" :key="index"
:label="environment.name + ': ' + environment.protocol + '://' + environment.socket"
:value="environment.id"/>
<el-button class="environment-button" size="mini" type="primary" @click="openEnvironmentConfig">
{{$t('api_test.environment.environment_config')}}
</el-button>
<template v-slot:empty>
<div class="empty-environment">
<el-button class="environment-button" size="mini" type="primary" @click="openEnvironmentConfig">{{$t('api_test.environment.environment_config')}}</el-button>
<el-button class="environment-button" size="mini" type="primary" @click="openEnvironmentConfig">
{{$t('api_test.environment.environment_config')}}
</el-button>
</div>
</template>
</el-select>
@ -18,10 +25,20 @@
<el-tabs v-model="activeName">
<el-tab-pane :label="$t('api_test.scenario.variables')" name="parameters">
<ms-api-scenario-variables :is-read-only="isReadOnly" :items="scenario.variables" :description="$t('api_test.scenario.kv_description')"/>
<ms-api-scenario-variables :is-read-only="isReadOnly" :items="scenario.variables"
:description="$t('api_test.scenario.kv_description')"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.scenario.headers')" name="headers">
<ms-api-key-value :is-read-only="isReadOnly" :items="scenario.headers" :suggestions="headerSuggestions" :description="$t('api_test.scenario.kv_description')"/>
<ms-api-key-value :is-read-only="isReadOnly" :items="scenario.headers" :suggestions="headerSuggestions"
:description="$t('api_test.scenario.kv_description')"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.scenario.dubbo')" name="dubbo">
<div class="dubbo-config-title">Config Center</div>
<ms-dubbo-config-center :config="scenario.dubboConfig.configCenter"/>
<div class="dubbo-config-title">Registry Center</div>
<ms-dubbo-registry-center :registry="scenario.dubboConfig.registryCenter"/>
<div class="dubbo-config-title">Consumer & Service</div>
<ms-dubbo-consumer-service :consumer="scenario.dubboConfig.consumerAndService"/>
</el-tab-pane>
</el-tabs>
@ -36,11 +53,17 @@
import {Scenario} from "../model/ScenarioModel";
import MsApiScenarioVariables from "./ApiScenarioVariables";
import ApiEnvironmentConfig from "./ApiEnvironmentConfig";
import {requestHeaders} from "../../../../../common/js/constants";
import {REQUEST_HEADERS} from "@/common/js/constants";
import MsDubboRegistryCenter from "@/business/components/api/test/components/request/dubbo/RegistryCenter";
import MsDubboConfigCenter from "@/business/components/api/test/components/request/dubbo/ConfigCenter";
import MsDubboConsumerService from "@/business/components/api/test/components/request/dubbo/ConsumerAndService";
export default {
name: "MsApiScenarioForm",
components: {ApiEnvironmentConfig, MsApiScenarioVariables, MsApiKeyValue},
components: {
MsDubboConsumerService,
MsDubboConfigCenter, MsDubboRegistryCenter, ApiEnvironmentConfig, MsApiScenarioVariables, MsApiKeyValue
},
props: {
scenario: Scenario,
projectId: String,
@ -65,7 +88,7 @@
{max: 100, message: this.$t('commons.input_limit', [1, 100]), trigger: 'blur'}
]
},
headerSuggestions: requestHeaders
headerSuggestions: REQUEST_HEADERS
}
},
watch: {
@ -136,7 +159,13 @@
}
.empty-environment {
padding: 10px 0px;
padding: 10px 0;
}
.dubbo-config-title {
margin-bottom: 10px;
font-size: 15px;
font-weight: 600;
}
</style>

View File

@ -24,5 +24,7 @@
</script>
<style scoped>
.request-method-select {
width: 110px;
}
</style>

View File

@ -41,7 +41,7 @@
import MsApiScenarioVariables from "../ApiScenarioVariables";
import MsApiKeyValue from "../ApiKeyValue";
import MsDialogFooter from "../../../../common/components/MsDialogFooter";
import {requestHeaders} from "../../../../../../common/js/constants";
import {REQUEST_HEADERS} from "../../../../../../common/js/constants";
export default {
name: "EnvironmentEdit",
@ -71,7 +71,7 @@
],
socket :[{required: true, validator: socketValidator, trigger: 'blur'}],
},
headerSuggestions: requestHeaders
headerSuggestions: REQUEST_HEADERS
}
},
methods: {

View File

@ -0,0 +1,132 @@
<template>
<el-form :model="request" :rules="rules" ref="request" label-width="100px" :disabled="isReadOnly">
<el-form-item :label="$t('api_test.request.name')" prop="name">
<el-input v-model="request.name" maxlength="300" show-word-limit/>
</el-form-item>
<el-form-item :label="$t('api_test.request.dubbo.protocol')" prop="protocol">
<el-select v-model="request.protocol">
<el-option label="dubbo://" :value="protocols.DUBBO"/>
</el-select>
</el-form-item>
<el-tabs v-model="activeName">
<el-tab-pane label="Interface" name="interface">
<ms-dubbo-interface :request="request" :is-read-only="isReadOnly"/>
</el-tab-pane>
<el-tab-pane label="Config Center" name="config">
<ms-dubbo-config-center :config="request.configCenter" :is-read-only="isReadOnly"
:description="$t('api_test.request.dubbo.form_description')"/>
</el-tab-pane>
<el-tab-pane label="Registry Center" name="registry">
<ms-dubbo-registry-center :registry="request.registryCenter" :is-read-only="isReadOnly"
:description="$t('api_test.request.dubbo.form_description')"/>
</el-tab-pane>
<el-tab-pane label="Consumer & Service" name="consumer">
<ms-dubbo-consumer-service :consumer="request.consumerAndService" :is-read-only="isReadOnly"
:description="$t('api_test.request.dubbo.form_description')"/>
</el-tab-pane>
</el-tabs>
<el-tabs v-model="activeName2">
<el-tab-pane label="Args" name="args">
<ms-api-key-value :is-read-only="isReadOnly" :items="request.args"
key-placeholder="Param Type" value-placeholder="Param Value"/>
</el-tab-pane>
<el-tab-pane label="Attachment Args" name="attachment">
<ms-api-key-value :is-read-only="isReadOnly" :items="request.attachmentArgs"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.request.assertions.label')" name="assertions">
<ms-api-assertions :is-read-only="isReadOnly" :assertions="request.assertions"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.request.extract.label')" name="extract">
<ms-api-extract :is-read-only="isReadOnly" :extract="request.extract"/>
</el-tab-pane>
</el-tabs>
</el-form>
</template>
<script>
import MsApiKeyValue from "../ApiKeyValue";
import MsApiBody from "../ApiBody";
import MsApiAssertions from "../assertion/ApiAssertions";
import {DubboRequest} from "../../model/ScenarioModel";
import MsApiExtract from "../extract/ApiExtract";
import ApiRequestMethodSelect from "../collapse/ApiRequestMethodSelect";
import MsDubboInterface from "@/business/components/api/test/components/request/dubbo/Interface";
import MsDubboRegistryCenter from "@/business/components/api/test/components/request/dubbo/RegistryCenter";
import MsDubboConfigCenter from "@/business/components/api/test/components/request/dubbo/ConfigCenter";
import MsDubboConsumerService from "@/business/components/api/test/components/request/dubbo/ConsumerAndService";
export default {
name: "MsApiDubboRequestForm",
components: {
MsDubboConsumerService,
MsDubboConfigCenter,
MsDubboRegistryCenter,
MsDubboInterface, ApiRequestMethodSelect, MsApiExtract, MsApiAssertions, MsApiBody, MsApiKeyValue
},
props: {
request: DubboRequest,
isReadOnly: {
type: Boolean,
default: false
}
},
data() {
return {
activeName: "interface",
activeName2: "args",
protocols: DubboRequest.PROTOCOLS,
rules: {
name: [
{max: 300, message: this.$t('commons.input_limit', [1, 300]), trigger: 'blur'}
],
}
}
},
methods: {
useEnvironmentChange(value) {
if (value && !this.request.environment) {
this.$error(this.$t('api_test.request.please_add_environment_to_scenario'), 2000);
this.request.useEnvironment = false;
}
this.$refs["request"].clearValidate();
}
},
computed: {}
}
</script>
<style scoped>
.request-method-select {
width: 110px;
}
.el-tag {
width: 100%;
height: 40px;
line-height: 40px;
}
.environment-display {
font-size: 14px;
}
.environment-name {
font-weight: bold;
font-style: italic;
}
.adjust-margin-bottom {
margin-bottom: 10px;
}
.environment-url-tip {
color: #F56C6C;
}
</style>

View File

@ -5,7 +5,8 @@
<el-input :disabled="isReadOnly" v-model="request.name" maxlength="300" show-word-limit/>
</el-form-item>
<el-form-item v-if="!request.useEnvironment" :label="$t('api_test.request.url')" prop="url" class="adjust-margin-bottom">
<el-form-item v-if="!request.useEnvironment" :label="$t('api_test.request.url')" prop="url"
class="adjust-margin-bottom">
<el-input :disabled="isReadOnly" v-model="request.url" maxlength="500"
:placeholder="$t('api_test.request.url_description')" @change="urlChange" clearable>
<template v-slot:prepend>
@ -60,19 +61,20 @@
</template>
<script>
import MsApiKeyValue from "./ApiKeyValue";
import MsApiBody from "./ApiBody";
import MsApiAssertions from "./assertion/ApiAssertions";
import {KeyValue, Request} from "../model/ScenarioModel";
import MsApiExtract from "./extract/ApiExtract";
import ApiRequestMethodSelect from "./collapse/ApiRequestMethodSelect";
import {requestHeaders} from "../../../../../common/js/constants";
import MsApiKeyValue from "../ApiKeyValue";
import MsApiBody from "../ApiBody";
import MsApiAssertions from "../assertion/ApiAssertions";
import {KeyValue} from "../../model/ScenarioModel";
import MsApiExtract from "../extract/ApiExtract";
import ApiRequestMethodSelect from "../collapse/ApiRequestMethodSelect";
import {REQUEST_HEADERS} from "@/common/js/constants";
import {HttpRequest} from "../../model/ScenarioModel";
export default {
name: "MsApiRequestForm",
name: "MsApiHttpRequestForm",
components: {ApiRequestMethodSelect, MsApiExtract, MsApiAssertions, MsApiBody, MsApiKeyValue},
props: {
request: Request,
request: HttpRequest,
isReadOnly: {
type: Boolean,
default: false
@ -101,7 +103,7 @@
{max: 500, required: true, message: this.$t('commons.input_limit', [1, 500]), trigger: 'blur'},
]
},
headerSuggestions: requestHeaders
headerSuggestions: REQUEST_HEADERS
}
},
@ -119,7 +121,8 @@
this.request.path = '/' + this.request.path;
}
let url = this.getURL(this.displayUrl);
this.request.path = decodeURIComponent(url.pathname);
this
.request.path = decodeURIComponent(url.pathname);
this.request.urlWirhEnv = decodeURIComponent(url.origin + url.pathname);
},
getURL(urlStr) {
@ -169,10 +172,6 @@
</script>
<style scoped>
.request-method-select {
width: 110px;
}
.el-tag {
width: 100%;
height: 40px;

View File

@ -3,9 +3,12 @@
<draggable :list="this.scenario.requests" group="Request" class="request-draggable" ghost-class="request-ghost">
<div class="request-item" v-for="(request, index) in this.scenario.requests" :key="index" @click="select(request)"
:class="{'selected': isSelected(request)}">
<el-row type="flex">
<el-row type="flex" align="middle">
<div class="request-type">
{{request.showType()}}
</div>
<div class="request-method">
{{request.method}}
{{request.showMethod()}}
</div>
<div class="request-name">
{{request.name}}
@ -26,12 +29,20 @@
</el-row>
</div>
</draggable>
<el-button :disabled="isReadOnly" class="request-create" type="primary" size="mini" icon="el-icon-plus" plain @click="createRequest"/>
<el-popover placement="top" v-model="visible">
<el-radio-group v-model="type" @change="createRequest">
<el-radio :label="types.HTTP">HTTP</el-radio>
<el-radio :label="types.DUBBO">DUBBO</el-radio>
</el-radio-group>
<el-button slot="reference" :disabled="isReadOnly"
class="request-create" type="primary" size="mini" icon="el-icon-plus" plain/>
</el-popover>
</div>
</template>
<script>
import {Request} from "../model/ScenarioModel";
import {RequestFactory} from "../../model/ScenarioModel";
import draggable from 'vuedraggable';
export default {
@ -41,7 +52,6 @@
props: {
scenario: Object,
open: Function,
isReadOnly: {
type: Boolean,
default: false
@ -51,6 +61,9 @@
data() {
return {
selected: 0,
visible: false,
types: RequestFactory.TYPES,
type: ""
}
},
@ -63,13 +76,15 @@
},
methods: {
createRequest: function () {
let request = new Request();
createRequest: function (type) {
let request = new RequestFactory({type: type});
this.scenario.requests.push(request);
this.type = "";
this.visible = false;
},
copyRequest: function (index) {
let request = this.scenario.requests[index];
this.scenario.requests.push(new Request(request));
this.scenario.requests.push(new RequestFactory(request));
},
deleteRequest: function (index) {
this.scenario.requests.splice(index, 1);
@ -92,8 +107,9 @@
if (!request.useEnvironment) {
request.useEnvironment = false;
}
request.dubboConfig = this.scenario.dubboConfig;
this.selected = request;
this.open(request);
this.$emit("select", request);
}
},
@ -106,7 +122,6 @@
<style scoped>
.request-item {
border-left: 5px solid #1E90FF;
line-height: 40px;
max-height: 40px;
border-top: 1px solid #EBEEF5;
cursor: pointer;
@ -124,6 +139,19 @@
background-color: #F5F5F5;
}
.request-type {
background-color: #409eff;
color: #fff;
margin-left: 5px;
padding: 4px 8px;
border-radius: 20px;
white-space: nowrap;
font-size: 12px;
display: inline-block;
line-height: 1;
text-align: center;
}
.request-method {
padding: 0 5px;
color: #1E90FF;

View File

@ -0,0 +1,38 @@
<template>
<component :is="component" :is-read-only="isReadOnly" :request="request"/>
</template>
<script>
import {Request, RequestFactory} from "../../model/ScenarioModel";
import MsApiHttpRequestForm from "./ApiHttpRequestForm";
import MsApiDubboRequestForm from "./ApiDubboRequestForm";
export default {
name: "MsApiRequestForm",
components: {MsApiDubboRequestForm, MsApiHttpRequestForm},
props: {
request: Request,
isReadOnly: {
type: Boolean,
default: false
}
},
computed: {
component({request: {type}}) {
let name;
switch (type) {
case RequestFactory.TYPES.DUBBO:
name = "MsApiDubboRequestForm";
break;
default:
name = "MsApiHttpRequestForm";
}
return name;
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,83 @@
<template>
<el-form :model="config" :rules="rules" ref="config" label-width="100px" size="small" :disabled="isReadOnly">
<div class="dubbo-form-description" v-if="description">
{{description}}
</div>
<el-form-item label="Protocol" prop="protocol" class="dubbo-form-item">
<el-select v-model="config.protocol" class="select-100">
<el-option v-for="p in protocols" :key="p" :label="p" :value="p"/>
</el-select>
</el-form-item>
<el-form-item label="Group" prop="group" class="dubbo-form-item">
<el-input v-model="config.group" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Namespace" prop="namespace" class="dubbo-form-item">
<el-input v-model="config.namespace" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Timeout" prop="timeout" class="dubbo-form-item">
<el-input type="number" v-model="config.timeout" :placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Address" prop="address" class="dubbo-form-item-long">
<el-input v-model="config.address" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="UserName" prop="username" class="dubbo-form-item">
<el-input v-model="config.username" maxlength="100" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Password" prop="password" class="dubbo-form-item">
<el-input v-model="config.password" maxlength="30" show-word-limit show-password autocomplete="new-password"
:placeholder="$t('commons.input_content')"/>
</el-form-item>
</el-form>
</template>
<script>
import './dubbo.css'
import {ConfigCenter} from "@/business/components/api/test/model/ScenarioModel";
export default {
name: "MsDubboConfigCenter",
props: {
description: String,
config: ConfigCenter,
isReadOnly: {
type: Boolean,
default: false
}
},
data() {
return {
protocols: ConfigCenter.PROTOCOLS,
methods: [],
rules: {
group: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
],
namespace: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
],
username: [
{max: 100, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
],
password: [
{max: 30, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
],
address: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
]
}
}
}
}
</script>

View File

@ -0,0 +1,82 @@
<template>
<el-form :model="consumer" :rules="rules" ref="consumer" label-width="100px" size="small" :disabled="isReadOnly">
<div class="dubbo-form-description" v-if="description">
{{description}}
</div>
<el-form-item label="Timeout" prop="timeout" class="dubbo-form-item">
<el-input type="number" v-model="consumer.timeout" :placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Version" prop="version" class="dubbo-form-item">
<el-input v-model="consumer.version" maxlength="30" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Retries" prop="retries" class="dubbo-form-item">
<el-input type="number" v-model="consumer.retries" :placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Cluster" prop="cluster" class="dubbo-form-item">
<el-input v-model="consumer.cluster" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Group" prop="group" class="dubbo-form-item">
<el-input v-model="consumer.group" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Connections" prop="connections" class="dubbo-form-item">
<el-input type="number" v-model="consumer.connections" :placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Async" prop="async" class="dubbo-form-item">
<el-select v-model="consumer.async" class="select-100">
<el-option v-for="option in asyncOptions" :key="option" :label="option" :value="option"/>
</el-select>
</el-form-item>
<el-form-item label="LoadBalance" prop="loadBalance" class="dubbo-form-item">
<el-select v-model="consumer.loadBalance" class="select-100">
<el-option v-for="option in loadBalances" :key="option" :label="option" :value="option"/>
</el-select>
</el-form-item>
</el-form>
</template>
<script>
import './dubbo.css'
import {ConsumerAndService, RegistryCenter} from "@/business/components/api/test/model/ScenarioModel";
export default {
name: "MsDubboConsumerService",
props: {
description: String,
consumer: ConsumerAndService,
isReadOnly: {
type: Boolean,
default: false
}
},
data() {
return {
asyncOptions: ConsumerAndService.ASYNC_OPTIONS,
loadBalances: ConsumerAndService.LOAD_BALANCE_OPTIONS,
methods: [],
rules: {
version: [
{max: 30, message: this.$t('commons.input_limit', [0, 30]), trigger: 'blur'}
],
cluster: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
],
group: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
]
}
}
}
}
</script>

View File

@ -0,0 +1,121 @@
<template>
<el-form :model="request" :rules="rules" ref="request" label-width="100px" size="small" v-loading="loading"
:disabled="isReadOnly">
<el-button class="get-provider" type="primary" size="small" @click="getProviderList">Get Provider List</el-button>
<el-row>
<el-col :span="12">
<el-form-item label="Interfaces" prop="interfaces">
<el-select v-model="serviceInterface" class="select-100" @change="changeInterface" :disabled="isDisable">
<el-option v-for="i in interfaces" :key="i.value" :label="i.label" :value="i.value"/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Methods" prop="methods">
<el-select v-model="method" class="select-100" @change="changeMethod" :disabled="isDisable">
<el-option v-for="m in methods" :key="m" :label="m" :value="m"/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="Interface" prop="interface">
<el-input v-model="request.interface" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Method" prop="method">
<el-input v-model="request.method" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<script>
import {DubboRequest, RegistryCenter} from "@/business/components/api/test/model/ScenarioModel";
export default {
name: "MsDubboInterface",
props: {
request: DubboRequest,
registryCenter: RegistryCenter,
isReadOnly: {
type: Boolean,
default: false
}
},
data() {
return {
loading: false,
providerMap: {},
serviceInterface: "",
method: "",
interfaces: [],
methods: [],
rules: {
interface: [
{max: 300, message: this.$t('commons.input_limit', [1, 300]), trigger: 'blur'}
],
method: [
{max: 300, message: this.$t('commons.input_limit', [1, 300]), trigger: 'blur'}
]
}
}
},
methods: {
changeInterface(value) {
this.methods = this.providerMap[value].methods;
this.request.interface = value;
this.request.consumerAndService.version = this.providerMap[value].version;
},
changeMethod(value) {
this.request.method = value;
},
getProviderList() {
let param = {
protocol: this.request.registryCenter.protocol || this.request.dubboConfig.registryCenter.protocol,
address: this.request.registryCenter.address || this.request.dubboConfig.registryCenter.address,
group: this.request.registryCenter.group || this.request.dubboConfig.registryCenter.group,
};
this.loading = true;
this.$post("/api/dubbo/providers", param).then(response => {
this.methodMap = {};
this.interfaces = [];
response.data.data.forEach(p => {
this.providerMap[p.serviceInterface] = p;
this.interfaces.push({label: p.service, value: p.serviceInterface})
});
if (this.methodMap[this.request.interface]) {
this.methods = this.methodMap[this.request.interface].methods;
}
this.loading = false;
}).catch(() => {
this.loading = false;
this.$warning(this.$t('api_test.request.dubbo.check_registry_center'));
});
}
},
computed: {
isDisable() {
return this.interfaces.length === 0;
}
}
}
</script>
<style scoped>
.get-provider {
margin-bottom: 22px;
}
.select-100 {
width: 100%;
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<el-form :model="registry" :rules="rules" ref="registry" label-width="100px" size="small" :disabled="isReadOnly">
<div class="dubbo-form-description" v-if="description">
{{description}}
</div>
<el-form-item label="Protocol" prop="protocol" class="dubbo-form-item">
<el-select v-model="registry.protocol" class="select-100">
<el-option v-for="p in protocols" :key="p" :label="p" :value="p"/>
</el-select>
</el-form-item>
<el-form-item label="Group" prop="group" class="dubbo-form-item">
<el-input v-model="registry.group" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="UserName" prop="username" class="dubbo-form-item">
<el-input v-model="registry.username" maxlength="100" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Password" prop="password" class="dubbo-form-item">
<el-input v-model="registry.password" maxlength="30" show-word-limit show-password autocomplete="new-password"
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Address" prop="address" class="dubbo-form-item-long">
<el-input v-model="registry.address" maxlength="300" show-word-limit
:placeholder="$t('commons.input_content')"/>
</el-form-item>
<el-form-item label="Timeout" prop="timeout" class="dubbo-form-item">
<el-input type="number" v-model="registry.timeout" :placeholder="$t('commons.input_content')"/>
</el-form-item>
</el-form>
</template>
<script>
import './dubbo.css'
import {RegistryCenter} from "@/business/components/api/test/model/ScenarioModel";
export default {
name: "MsDubboRegistryCenter",
props: {
description: String,
registry: RegistryCenter,
isReadOnly: {
type: Boolean,
default: false
}
},
data() {
return {
protocols: RegistryCenter.PROTOCOLS,
methods: [],
rules: {
group: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
],
username: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
],
password: [
{max: 30, message: this.$t('commons.input_limit', [0, 30]), trigger: 'blur'}
],
address: [
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
]
}
}
}
}
</script>

View File

@ -0,0 +1,43 @@
@media only screen and (max-width: 1200px) {
.dubbo-form-item {
width: 50%;
}
.dubbo-form-item-long {
width: 100%;
}
}
@media only screen and (min-width: 1201px) and (max-width: 1600px) {
.dubbo-form-item {
width: 33.33%;
}
.dubbo-form-item-long {
width: 66.67%;
}
}
@media only screen and (min-width: 1601px) {
.dubbo-form-item {
width: 25%;
}
.dubbo-form-item-long {
width: 50%;
}
}
.dubbo-form-item, .dubbo-form-item-long {
display: inline-block;
}
.select-100 {
width: 100%;
}
.dubbo-form-description {
width: 100%;
font-size: 13px;
margin-bottom: 12px;
}

View File

@ -220,6 +220,60 @@ export class ThreadGroup extends DefaultTestElement {
}
}
export class DubboSample extends DefaultTestElement {
constructor(testName, request = {}) {
super('io.github.ningyu.jmeter.plugin.dubbo.sample.DubboSample',
'io.github.ningyu.jmeter.plugin.dubbo.gui.DubboSampleGui',
'io.github.ningyu.jmeter.plugin.dubbo.sample.DubboSample', testName);
this.request = request;
this.stringProp("FIELD_DUBBO_CONFIG_CENTER_PROTOCOL", this.request.configCenter.protocol);
this.stringProp("FIELD_DUBBO_CONFIG_CENTER_GROUP", this.request.configCenter.group);
this.stringProp("FIELD_DUBBO_CONFIG_CENTER_NAMESPACE", this.request.configCenter.namespace);
this.stringProp("FIELD_DUBBO_CONFIG_CENTER_USER_NAME", this.request.configCenter.username);
this.stringProp("FIELD_DUBBO_CONFIG_CENTER_PASSWORD", this.request.configCenter.password);
this.stringProp("FIELD_DUBBO_CONFIG_CENTER_ADDRESS", this.request.configCenter.address);
this.stringProp("FIELD_DUBBO_CONFIG_CENTER_TIMEOUT", this.request.configCenter.timeout);
this.stringProp("FIELD_DUBBO_REGISTRY_PROTOCOL", this.request.registryCenter.protocol);
this.stringProp("FIELD_DUBBO_REGISTRY_GROUP", this.request.registryCenter.group);
this.stringProp("FIELD_DUBBO_REGISTRY_USER_NAME", this.request.registryCenter.username);
this.stringProp("FIELD_DUBBO_REGISTRY_PASSWORD", this.request.registryCenter.password);
this.stringProp("FIELD_DUBBO_ADDRESS", this.request.registryCenter.address);
this.stringProp("FIELD_DUBBO_REGISTRY_TIMEOUT", this.request.registryCenter.timeout);
this.stringProp("FIELD_DUBBO_TIMEOUT", this.request.consumerAndService.timeout);
this.stringProp("FIELD_DUBBO_VERSION", this.request.consumerAndService.version);
this.stringProp("FIELD_DUBBO_RETRIES", this.request.consumerAndService.retries);
this.stringProp("FIELD_DUBBO_GROUP", this.request.consumerAndService.group);
this.stringProp("FIELD_DUBBO_CONNECTIONS", this.request.consumerAndService.connections);
this.stringProp("FIELD_DUBBO_LOADBALANCE", this.request.consumerAndService.loadBalance);
this.stringProp("FIELD_DUBBO_ASYNC", this.request.consumerAndService.async);
this.stringProp("FIELD_DUBBO_CLUSTER", this.request.consumerAndService.cluster);
this.stringProp("FIELD_DUBBO_RPC_PROTOCOL", this.request.protocol);
this.stringProp("FIELD_DUBBO_INTERFACE", this.request.interface);
this.stringProp("FIELD_DUBBO_METHOD", this.request.method);
this.intProp("FIELD_DUBBO_METHOD_ARGS_SIZE", this.request.args.length);
this.intProp("FIELD_DUBBO_ATTACHMENT_ARGS_SIZE", this.request.attachmentArgs.length);
this.request.args.forEach((arg, i) => {
if (!!arg.name || !!arg.value) {
let index = i + 1;
this.stringProp("FIELD_DUBBO_METHOD_ARGS_PARAM_TYPE" + index, arg.name);
this.stringProp("FIELD_DUBBO_METHOD_ARGS_PARAM_VALUE" + index, arg.value);
}
})
this.request.attachmentArgs.forEach((arg, i) => {
if (!!arg.name || !!arg.value) {
let index = i + 1;
this.stringProp("FIELD_DUBBO_ATTACHMENT_ARGS_KEY" + index, arg.name);
this.stringProp("FIELD_DUBBO_ATTACHMENT_ARGS_VALUE" + index, arg.value);
}
})
}
}
export class HTTPSamplerProxy extends DefaultTestElement {
constructor(testName, request) {
super('HTTPSamplerProxy', 'HttpTestSampleGui', 'HTTPSamplerProxy', testName);

View File

@ -12,7 +12,7 @@ import {
ResponseCodeAssertion,
ResponseDataAssertion,
ResponseHeadersAssertion,
RegexExtractor, JSONPostProcessor, XPath2Extractor,
RegexExtractor, JSONPostProcessor, XPath2Extractor, DubboSample,
} from "./JMX";
export const uuid = function () {
@ -74,7 +74,7 @@ export class BaseConfig {
if (types) {
for (let name in types) {
if (types.hasOwnProperty(name) && options.hasOwnProperty(name)) {
options[name].forEach((o) => {
options[name].forEach(o => {
this[name].push(new types[name](o));
})
}
@ -95,7 +95,7 @@ export class Test extends BaseConfig {
constructor(options) {
super();
this.type = "MS API CONFIG";
this.version = '1.0.0';
this.version = '1.1.0';
this.id = uuid();
this.name = undefined;
this.projectId = undefined;
@ -152,7 +152,7 @@ export class Test extends BaseConfig {
}
export class Scenario extends BaseConfig {
constructor(options) {
constructor(options = {}) {
super();
this.name = undefined;
this.url = undefined;
@ -160,15 +160,17 @@ export class Scenario extends BaseConfig {
this.headers = [];
this.requests = [];
this.environmentId = undefined;
this.dubboConfig = undefined;
this.environment = undefined;
this.set(options);
this.sets({variables: KeyValue, headers: KeyValue, requests: Request}, options);
this.sets({variables: KeyValue, headers: KeyValue, requests: RequestFactory}, options);
}
initOptions(options) {
options = options || {};
options.requests = options.requests || [new Request()];
options.requests = options.requests || [new RequestFactory()];
options.dubboConfig = new DubboConfig(options.dubboConfig);
return options;
}
@ -187,9 +189,50 @@ export class Scenario extends BaseConfig {
}
}
export class Request extends BaseConfig {
constructor(options) {
class DubboConfig extends BaseConfig {
constructor(options = {}) {
super();
this.configCenter = new ConfigCenter(options.configCenter)
this.registryCenter = new RegistryCenter(options.registryCenter)
this.consumerAndService = new ConsumerAndService(options.consumerAndService)
}
}
export class RequestFactory {
static TYPES = {
HTTP: "HTTP",
DUBBO: "DUBBO",
}
constructor(options = {}) {
options.type = options.type || RequestFactory.TYPES.HTTP
switch (options.type) {
case RequestFactory.TYPES.DUBBO:
return new DubboRequest(options);
default:
return new HttpRequest(options);
}
}
}
export class Request extends BaseConfig {
constructor(type) {
super();
this.type = type;
}
showType() {
return this.type;
}
showMethod() {
return "";
}
}
export class HttpRequest extends Request {
constructor(options) {
super(RequestFactory.TYPES.HTTP);
this.name = undefined;
this.url = undefined;
this.path = undefined;
@ -238,6 +281,153 @@ export class Request extends BaseConfig {
isValid: true
}
}
showType() {
return this.type;
}
showMethod() {
return this.method.toUpperCase();
}
}
export class DubboRequest extends Request {
static PROTOCOLS = {
DUBBO: "dubbo://",
RMI: "rmi://",
}
constructor(options = {}) {
super(RequestFactory.TYPES.DUBBO);
this.name = options.name;
this.protocol = options.protocol || DubboRequest.PROTOCOLS.DUBBO;
this.interface = options.interface;
this.method = options.method;
this.configCenter = new ConfigCenter(options.configCenter);
this.registryCenter = new RegistryCenter(options.registryCenter);
this.consumerAndService = new ConsumerAndService(options.consumerAndService);
this.args = [];
this.attachmentArgs = [];
this.assertions = new Assertions(options.assertions);
this.extract = new Extract(options.extract);
// Scenario.dubboConfig
this.dubboConfig = undefined;
this.sets({args: KeyValue, attachmentArgs: KeyValue}, options);
}
isValid() {
if (!this.interface) {
return {
isValid: false,
info: 'api_test.request.dubbo.input_interface'
}
}
if (!this.method) {
return {
isValid: false,
info: 'api_test.request.dubbo.input_method'
}
}
if (!this.configCenter.isValid()) {
return {
isValid: false,
info: 'api_test.request.dubbo.input_config_center'
}
}
if (!this.registryCenter.isValid()) {
return {
isValid: false,
info: 'api_test.request.dubbo.input_registry_center'
}
}
if (!this.consumerAndService.isValid()) {
return {
isValid: false,
info: 'api_test.request.dubbo.input_consumer_service'
}
}
return {
isValid: true
}
}
showType() {
return "RPC";
}
showMethod() {
// dubbo:// -> DUBBO
return this.protocol.substr(0, this.protocol.length - 3).toUpperCase();
}
clone() {
return new DubboRequest(this);
}
}
export class ConfigCenter extends BaseConfig {
static PROTOCOLS = ["zookeeper", "nacos", "apollo"];
constructor(options) {
super();
this.protocol = undefined;
this.group = undefined;
this.namespace = undefined;
this.username = undefined;
this.address = undefined;
this.password = undefined;
this.timeout = undefined;
this.set(options);
}
isValid() {
return !!this.protocol || !!this.group || !!this.namespace || !!this.username || !!this.address || !!this.password || !!this.timeout;
}
}
export class RegistryCenter extends BaseConfig {
static PROTOCOLS = ["none", "zookeeper", "nacos", "apollo", "multicast", "redis", "simple"];
constructor(options) {
super();
this.protocol = undefined;
this.group = undefined;
this.username = undefined;
this.address = undefined;
this.password = undefined;
this.timeout = undefined;
this.set(options);
}
isValid() {
return !!this.protocol || !!this.group || !!this.username || !!this.address || !!this.password || !!this.timeout;
}
}
export class ConsumerAndService extends BaseConfig {
static ASYNC_OPTIONS = ["sync", "async"];
static LOAD_BALANCE_OPTIONS = ["random", "roundrobin", "leastactive", "consistenthash"];
constructor(options) {
super();
this.timeout = "1000";
this.version = "1.0";
this.retries = "0";
this.cluster = "failfast";
this.group = undefined;
this.connections = "100";
this.async = "sync";
this.loadBalance = "random";
this.set(options);
}
isValid() {
return !!this.timeout || !!this.version || !!this.retries || !!this.cluster || !!this.group || !!this.connections || !!this.async || !!this.loadBalance;
}
}
export class Body extends BaseConfig {
@ -423,9 +613,9 @@ const JMX_ASSERTION_CONDITION = {
OR: 1 << 5
}
class JMXRequest {
class JMXHttpRequest {
constructor(request, environment) {
if (request && request instanceof Request && (request.url || request.path)) {
if (request && request instanceof HttpRequest && (request.url || request.path)) {
this.useEnvironment = request.useEnvironment;
this.method = request.method;
if (!request.useEnvironment) {
@ -457,7 +647,37 @@ class JMXRequest {
}
return path;
}
}
class JMXDubboRequest {
constructor(request, dubboConfig) {
// Request 复制
let obj = request.clone();
// 去掉无效的kv
obj.args = obj.args.filter(arg => {
return arg.isValid();
});
obj.attachmentArgs = obj.attachmentArgs.filter(arg => {
return arg.isValid();
});
// Scenario DubboConfig复制
this.copy(obj.configCenter, dubboConfig.configCenter);
this.copy(obj.registryCenter, dubboConfig.registryCenter);
this.copy(obj.consumerAndService, dubboConfig.consumerAndService);
return obj;
}
copy(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (source[key] !== undefined && !target[key]) {
target[key] = source[key];
}
}
}
}
}
class JMeterTestPlan extends Element {
@ -499,22 +719,27 @@ class JMXGenerator {
scenario.requests.forEach(request => {
if (!request.isValid()) return;
let sampler;
let httpSamplerProxy = new HTTPSamplerProxy(request.name || "", new JMXRequest(request, scenario.environment));
this.addRequestHeader(httpSamplerProxy, request);
if (request.method.toUpperCase() === 'GET') {
this.addRequestArguments(httpSamplerProxy, request);
} else {
this.addRequestBody(httpSamplerProxy, request);
if (request instanceof DubboRequest) {
sampler = new DubboSample(request.name || "", new JMXDubboRequest(request, scenario.dubboConfig));
}
this.addRequestAssertion(httpSamplerProxy, request);
if (request instanceof HttpRequest) {
sampler = new HTTPSamplerProxy(request.name || "", new JMXHttpRequest(request, scenario.environment));
this.addRequestHeader(sampler, request);
if (request.method.toUpperCase() === 'GET') {
this.addRequestArguments(sampler, request);
} else {
this.addRequestBody(sampler, request);
}
}
this.addRequestExtractor(httpSamplerProxy, request);
this.addRequestAssertion(sampler, request);
threadGroup.put(httpSamplerProxy);
this.addRequestExtractor(sampler, request);
threadGroup.put(sampler);
})
testPlan.put(threadGroup);

View File

@ -51,9 +51,11 @@
callback(new Error(this.$t('commons.input_content')));
} else if (!cronValidate(cronValue)) {
callback(new Error(this.$t('schedule.cron_expression_format_error')));
} else if(!this.intervalShortValidate()) {
callback(new Error(this.$t('schedule.cron_expression_interval_short_error')));
} else if (!customValidate.pass){
}
// else if(!this.intervalShortValidate()) {
// callback(new Error(this.$t('schedule.cron_expression_interval_short_error')));
// }
else if (!customValidate.pass){
callback(new Error(customValidate.info));
} else {
callback();
@ -87,6 +89,7 @@
saveCron () {
this.$refs['from'].validate((valid) => {
if (valid) {
this.intervalShortValidate();
this.save(this.form.cronValue);
this.dialogVisible = false;
} else {
@ -103,8 +106,9 @@
}
},
intervalShortValidate() {
if (this.getIntervalTime() < 5*60*1000) {
return false;
if (this.getIntervalTime() < 3*60*1000) {
// return false;
this.$info(this.$t('schedule.cron_expression_interval_short_error'));
}
return true;
},

View File

@ -24,11 +24,11 @@
return {
dataMap: new Map([
["Pass", {name: this.$t('test_track.plan_view.pass'), itemStyle: {color: '#67C23A'}}],
["Failure", {name: this.$t('test_track.plan_view.failure'), itemStyle: {color: '#F56C6C'}}],
["Blocking", {name: this.$t('test_track.plan_view.blocking'), itemStyle: {color: '#E6A23C'}}],
["Skip", {name: this.$t('test_track.plan_view.skip'), itemStyle: {color: '#909399'}}],
["Prepare", {name: this.$t('test_track.plan.plan_status_prepare'), itemStyle: {color: '#DEDE10'}}],
["Failure", {name: this.$t('test_track.plan_view.failure'), itemStyle: {color: '#F56C6C'}}],
["Underway", {name: this.$t('test_track.plan.plan_status_running'), itemStyle: {color: 'lightskyblue'}}]
["Underway", {name: this.$t('test_track.plan.plan_status_running'), itemStyle: {color: 'lightskyblue'}}],
["Prepare", {name: this.$t('test_track.plan.plan_status_prepare'), itemStyle: {color: '#DEDE10'}}]
]),
charData: [],
isShow: true
@ -40,11 +40,11 @@
default() {
return [
{status: 'Pass', count: '235'},
{status: 'Failure', count: '310'},
{status: 'Blocking', count: '274'},
{status: 'Skip', count: '335'},
{status: 'Prepare', count: '265'},
{status: 'Failure', count: '310'},
{status: 'Underway', count: '245'},
{status: 'Prepare', count: '265'},
]
}
}

View File

@ -56,7 +56,7 @@ export default {
result.loading = false;
window.console.error(error.response || error.message);
if (error.response && error.response.data) {
if (error.response.headers["authentication-status"] != "invalid") {
if (error.response.headers["authentication-status"] !== "invalid") {
Message.error({message: error.response.data.message, showClose: true});
}
} else {

View File

@ -19,7 +19,7 @@ export const ZH_CN = 'zh_CN';
export const ZH_TW = 'zh_TW';
export const EN_US = 'en_US';
export const requestHeaders = [
export const REQUEST_HEADERS = [
{value: 'Accept'},
{value: 'Accept-Charset'},
{value: 'Accept-Language'},

View File

@ -350,6 +350,7 @@ export default {
please_save_test: "Please Save Test First",
},
scenario: {
dubbo: "Dubbo Config",
config: "Scenario Config",
input_name: "Please enter the scenario name",
name: "Scenario Name",
@ -409,6 +410,15 @@ export default {
regex_expression: "Regular expression",
json_path_expression: "JSONPath expression",
xpath_expression: "XPath expression",
},
dubbo: {
protocol: "protocol",
input_interface: "Please enter the interface",
input_method: "Please enter the method",
input_config_center: "Please enter the config center",
input_registry_center: "Please enter the registry center",
input_consumer_service: "Please enter the consumer & service",
check_registry_center: "Can't get interface list, please check the registry center",
}
},
api_import: {
@ -418,8 +428,8 @@ export default {
file_size_limit: "The file size does not exceed 20 M",
tip: "Instructions",
export_tip: "Export Tip",
ms_tip: "Support for Metersphere JSON format",
ms_export_tip: "Export jSON-formatted files via Metersphere website or browser plug-ins",
ms_tip: "Support for MeterSphere JSON format",
ms_export_tip: "Export jSON-formatted files via MeterSphere website or browser plug-ins",
swagger_tip: "Only Swagger2.x json files are supported",
postman_tip: "Only Postman Collection V2.1 json files are supported",
postman_export_tip: "Export the test collection by Postman",
@ -686,7 +696,7 @@ export default {
please_input_cron_expression: "Please Input Cron Expression",
generate_expression: "Generate Expression",
cron_expression_format_error: "Cron Expression Format Error",
cron_expression_interval_short_error: "Interval Time Should Longer than 5 Minutes",
cron_expression_interval_short_error: "Interval time shorter than 3 minutes, please avoid running tests that take too long",
cron: {
seconds: "Seconds",
minutes: "Minutes",

View File

@ -349,6 +349,7 @@ export default {
please_save_test: "请先保存测试",
},
scenario: {
dubbo: "Dubbo配置",
config: "场景配置",
input_name: "请输入场景名称",
name: "场景名称",
@ -408,6 +409,16 @@ export default {
regex_expression: "Perl型正则表达式",
json_path_expression: "JSONPath表达式",
xpath_expression: "XPath表达式",
},
dubbo: {
protocol: "协议",
input_interface: "请输入Interface",
input_method: "请输入Method",
input_config_center: "请输入Config Center",
input_registry_center: "请输入Registry Center",
input_consumer_service: "请输入Consumer & Service",
check_registry_center: "获取失败请检查Registry Center",
form_description: "如果当前配置项无值,则取场景配置项的值",
}
},
api_import: {
@ -685,7 +696,7 @@ export default {
please_input_cron_expression: "请输入 Cron 表达式",
generate_expression: "生成表达式",
cron_expression_format_error: "Cron 表达式格式错误",
cron_expression_interval_short_error: "间隔时间请大于 5 分钟",
cron_expression_interval_short_error: "间隔时间小于 3 分钟, 请避免执行耗时过长的测试",
cron: {
seconds: "秒",
minutes: "分钟",

View File

@ -348,6 +348,7 @@ export default {
please_save_test: "請先保存測試",
},
scenario: {
dubbo: "Dubbo配寘",
creator: "創建人",
config: "場景配寘",
input_name: "請輸入場景名稱",
@ -408,6 +409,15 @@ export default {
regex_expression: "Perl型規則運算式",
json_path_expression: "JSONPath運算式",
xpath_expression: "XPath運算式",
},
dubbo: {
protocol: "協定",
input_interface: "請輸入Interface",
input_method: "請輸入Method",
input_config_center: "請輸入Config Center",
input_registry_center: "請輸入Registry Center",
input_consumer_service: "請輸入Consumer & Service",
check_registry_center: "獲取失敗請檢查Registry Center",
}
},
api_import: {
@ -417,8 +427,8 @@ export default {
file_size_limit: "文件大小不超過 20 M",
tip: "說明",
export_tip: "導出方法",
ms_tip: "支持 Metersphere json 格式",
ms_export_tip: "通過 Metersphere Api 測試頁面或者瀏覽器插件導出 json 格式文件",
ms_tip: "支持 MeterSphere json 格式",
ms_export_tip: "通過 MeterSphere Api 測試頁面或者瀏覽器插件導出 json 格式文件",
postman_tip: "只支持 Postman Collection v2.1 格式的 json 文件",
swagger_tip: "只支持 Swagger2.x 版本的 json 文件",
post_export_tip: "通過 Postman 導出測試集合",
@ -684,7 +694,7 @@ export default {
please_input_cron_expression: "請輸入 Cron 表達式",
generate_expression: "生成表達式",
cron_expression_format_error: "Cron 表達式格式錯誤",
cron_expression_interval_short_error: "間隔時間請大於 5 分鐘",
cron_expression_interval_short_error: "間隔時間小於 3 分鐘, 請避免執行耗時過長的測試",
cron: {
seconds: "秒",
minutes: "分鐘",