Merge remote-tracking branch 'origin/dev' into dev

# Conflicts:
#	backend/pom.xml
This commit is contained in:
Captain.B 2020-04-16 10:57:10 +08:00
commit e9e27f4211
32 changed files with 1908 additions and 792 deletions

View File

@ -36,10 +36,6 @@
<artifactId>spring-boot-starter-tomcat</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
<exclusion>
<artifactId>hibernate-validator</artifactId>
<groupId>org.hibernate.validator</groupId>
</exclusion>
</exclusions>
</dependency>
@ -138,6 +134,14 @@
<artifactId>ApacheJMeter_core</artifactId>
<version>${jmeter.version}</version>
</dependency>
<!-- easyexcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>2.1.7</version>
</dependency>
</dependencies>
<build>

View File

@ -1,194 +0,0 @@
package io.metersphere.base.domain;
public class ZaleniumTest {
private String seleniumSessionId;
private String testName;
private String timestamp;
private String addedToDashboardTime;
private String browser;
private String browserVersion;
private String proxyName;
private String platform;
private String fileName;
private String fileExtension;
private String videoFolderPath;
private String logsFolderPath;
private String testNameNoExtension;
private String screenDimension;
private String timeZone;
private String build;
private String testFileNameTemplate;
private String browserDriverLogFileName;
private String retentionDate;
private String testStatus;
private boolean videoRecorded;
public String getSeleniumSessionId() {
return seleniumSessionId;
}
public void setSeleniumSessionId(String seleniumSessionId) {
this.seleniumSessionId = seleniumSessionId;
}
public String getTestName() {
return testName;
}
public void setTestName(String testName) {
this.testName = testName;
}
public String getTimestamp() {
return timestamp;
}
public void setTimestamp(String timestamp) {
this.timestamp = timestamp;
}
public String getAddedToDashboardTime() {
return addedToDashboardTime;
}
public void setAddedToDashboardTime(String addedToDashboardTime) {
this.addedToDashboardTime = addedToDashboardTime;
}
public String getBrowser() {
return browser;
}
public void setBrowser(String browser) {
this.browser = browser;
}
public String getBrowserVersion() {
return browserVersion;
}
public void setBrowserVersion(String browserVersion) {
this.browserVersion = browserVersion;
}
public String getProxyName() {
return proxyName;
}
public void setProxyName(String proxyName) {
this.proxyName = proxyName;
}
public String getPlatform() {
return platform;
}
public void setPlatform(String platform) {
this.platform = platform;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getFileExtension() {
return fileExtension;
}
public void setFileExtension(String fileExtension) {
this.fileExtension = fileExtension;
}
public String getVideoFolderPath() {
return videoFolderPath;
}
public void setVideoFolderPath(String videoFolderPath) {
this.videoFolderPath = videoFolderPath;
}
public String getLogsFolderPath() {
return logsFolderPath;
}
public void setLogsFolderPath(String logsFolderPath) {
this.logsFolderPath = logsFolderPath;
}
public String getTestNameNoExtension() {
return testNameNoExtension;
}
public void setTestNameNoExtension(String testNameNoExtension) {
this.testNameNoExtension = testNameNoExtension;
}
public String getScreenDimension() {
return screenDimension;
}
public void setScreenDimension(String screenDimension) {
this.screenDimension = screenDimension;
}
public String getTimeZone() {
return timeZone;
}
public void setTimeZone(String timeZone) {
this.timeZone = timeZone;
}
public String getBuild() {
return build;
}
public void setBuild(String build) {
this.build = build;
}
public String getTestFileNameTemplate() {
return testFileNameTemplate;
}
public void setTestFileNameTemplate(String testFileNameTemplate) {
this.testFileNameTemplate = testFileNameTemplate;
}
public String getBrowserDriverLogFileName() {
return browserDriverLogFileName;
}
public void setBrowserDriverLogFileName(String browserDriverLogFileName) {
this.browserDriverLogFileName = browserDriverLogFileName;
}
public String getRetentionDate() {
return retentionDate;
}
public void setRetentionDate(String retentionDate) {
this.retentionDate = retentionDate;
}
public String getTestStatus() {
return testStatus;
}
public void setTestStatus(String testStatus) {
this.testStatus = testStatus;
}
public boolean isVideoRecorded() {
return videoRecorded;
}
public void setVideoRecorded(boolean videoRecorded) {
this.videoRecorded = videoRecorded;
}
}

View File

@ -0,0 +1,5 @@
package io.metersphere.commons.constants;
public class TestCaseConstants {
public static final int MAX_NODE_DEPTH = 5;
}

View File

@ -6,16 +6,14 @@ import io.metersphere.base.domain.*;
import io.metersphere.commons.utils.PageUtils;
import io.metersphere.commons.utils.Pager;
import io.metersphere.controller.request.testcase.QueryTestCaseRequest;
import io.metersphere.controller.request.testplan.QueryTestPlanRequest;
import io.metersphere.dto.LoadTestDTO;
import io.metersphere.dto.TestCaseNodeDTO;
import io.metersphere.dto.TestPlanCaseDTO;
import io.metersphere.service.TestCaseNodeService;
import io.metersphere.excel.domain.ExcelResponse;
import io.metersphere.service.TestCaseService;
import io.metersphere.user.SessionUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
@RequestMapping("/test/case")
@ -74,5 +72,15 @@ public class TestCaseController {
return testCaseService.deleteTestCase(testCaseId);
}
@PostMapping("/import/{projectId}")
public ExcelResponse testCaseImport(MultipartFile file, @PathVariable String projectId){
return testCaseService.testCaseImport(file, projectId);
}
@GetMapping("/export/template")
public void testCaseTemplateExport(HttpServletResponse response){
testCaseService.testCaseTemplateExport(response);
}
}

View File

@ -0,0 +1,42 @@
package io.metersphere.excel.domain;
public class ExcelErrData<T> {
private T t;
private Integer rowNum;
private String errMsg;
public ExcelErrData(){}
public ExcelErrData(T t, Integer rowNum,String errMsg){
this.t = t;
this.rowNum = rowNum;
this.errMsg = errMsg;
}
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
public String getErrMsg() {
return errMsg;
}
public void setErrMsg(String errMsg) {
this.errMsg = errMsg;
}
public Integer getRowNum() {
return rowNum;
}
public void setRowNum(Integer rowNum) {
this.rowNum = rowNum;
}
}

View File

@ -0,0 +1,25 @@
package io.metersphere.excel.domain;
import java.util.List;
public class ExcelResponse<T> {
private Boolean success;
private List<ExcelErrData<T>> errList;
public Boolean getSuccess() {
return success;
}
public void setSuccess(Boolean success) {
this.success = success;
}
public List<ExcelErrData<T>> getErrList() {
return errList;
}
public void setErrList(List<ExcelErrData<T>> errList) {
this.errList = errList;
}
}

View File

@ -0,0 +1,145 @@
package io.metersphere.excel.domain;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
@ColumnWidth(15)
public class TestCaseExcelData {
@NotBlank
@Length(max=50)
@ExcelProperty("用例名称")
private String name;
@NotBlank
@Length(max=1000)
@ExcelProperty("所属模块")
@ColumnWidth(30)
@Pattern(regexp = "^(?!.*//).*$", message = "格式不正确")
private String nodePath;
@NotBlank
@ExcelProperty("用例类型")
@Pattern(regexp = "(^functional$)|(^performance$)|(^api$)", message = "必须为functional、performance、api")
private String type;
@NotBlank
@ExcelProperty("维护人")
private String maintainer;
@NotBlank
@ExcelProperty("优先级")
@Pattern(regexp = "(^P0$)|(^P1$)|(^P2$)|(^P3$)", message = "必须为P0、P1、P2、P3")
private String priority;
@NotBlank
@ExcelProperty("测试方式")
@Pattern(regexp = "(^manual$)|(^auto$)", message = "必须为manual、auto")
private String method;
@ColumnWidth(50)
@ExcelProperty("前置条件")
@Length(min=0, max=1000)
private String prerequisite;
@ColumnWidth(50)
@ExcelProperty("备注")
@Length(max=1000)
private String remark;
@ColumnWidth(50)
@ExcelProperty("步骤描述")
@Length(max=1000)
private String stepDesc;
@ColumnWidth(50)
@ExcelProperty("预期结果")
@Length(max=1000)
private String stepResult;
public String getNodePath() {
return nodePath;
}
public void setNodePath(String nodePath) {
this.nodePath = nodePath;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getMaintainer() {
return maintainer;
}
public void setMaintainer(String maintainer) {
this.maintainer = maintainer;
}
public String getPriority() {
return priority;
}
public void setPriority(String priority) {
this.priority = priority;
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public String getPrerequisite() {
return prerequisite;
}
public void setPrerequisite(String prerequisite) {
this.prerequisite = prerequisite;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public String getStepDesc() {
return stepDesc;
}
public void setStepDesc(String stepDesc) {
this.stepDesc = stepDesc;
}
public String getStepResult() {
return stepResult;
}
public void setStepResult(String stepResult) {
this.stepResult = stepResult;
}
}

View File

@ -0,0 +1,140 @@
package io.metersphere.excel.listener;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.alibaba.excel.exception.ExcelAnalysisException;
import com.alibaba.excel.util.StringUtils;
import io.metersphere.commons.utils.LogUtil;
import io.metersphere.excel.utils.ExcelValidateHelper;
import io.metersphere.excel.domain.ExcelErrData;
import java.lang.reflect.Field;
import java.util.*;
public abstract class EasyExcelListener <T> extends AnalysisEventListener<T> {
protected List<ExcelErrData<T>> errList = new ArrayList<>();
protected List<T> list = new ArrayList<>();
/**
* 每隔2000条存储数据库然后清理list 方便内存回收
*/
protected static final int BATCH_COUNT = 2000;
protected Class<T> clazz;
public EasyExcelListener(Class<T> clazz){
this.clazz = clazz;
}
/**
* 这个每一条数据解析都会来调用
*
* @param t
* @param analysisContext
*/
@Override
public void invoke(T t, AnalysisContext analysisContext) {
String errMsg;
Integer rowIndex = analysisContext.readRowHolder().getRowIndex();
try {
//根据excel数据实体中的javax.validation + 正则表达式来校验excel数据
errMsg = ExcelValidateHelper.validateEntity(t);
//自定义校验规则
errMsg = validate(t, errMsg);
} catch (NoSuchFieldException e) {
errMsg = "解析数据出错";
LogUtil.error(e.getMessage(), e);
}
if (!StringUtils.isEmpty(errMsg)) {
ExcelErrData excelErrData = new ExcelErrData(t, rowIndex, "" + rowIndex + "行出错:" + errMsg);
errList.add(excelErrData);
} else {
list.add(t);
}
if (list.size() > BATCH_COUNT) {
saveData();
list.clear();
}
}
/**
* 可重写该方法
* 自定义校验规则
* @param data
* @param errMsg
* @return
*/
public String validate(T data, String errMsg) {
return errMsg;
}
/**
* 自定义数据保存操作
*/
public abstract void saveData();
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
saveData();
list.clear();
}
/**
* 校验excel头部
* @param headMap 传入excel的头部第一行数据数据的index,name
* @param context
*/
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
super.invokeHeadMap(headMap, context);
if (clazz != null){
try {
Set<String> fieldNameSet = getFieldNameSet(clazz);
Collection<String> values = headMap.values();
for (String key : fieldNameSet) {
if (!values.contains(key)){
throw new ExcelAnalysisException("缺少头部信息:" + key);
}
}
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
}
/**
* @description: 获取注解里ExcelProperty的value
*/
public Set<String> getFieldNameSet(Class clazz) throws NoSuchFieldException {
Set<String> result = new HashSet<>();
Field field;
Field[] fields = clazz.getDeclaredFields();
for (int i = 0; i < fields.length ; i++) {
field = clazz.getDeclaredField(fields[i].getName());
field.setAccessible(true);
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if(excelProperty != null){
StringBuilder value = new StringBuilder();
for (String v : excelProperty.value()) {
value.append(v);
}
result.add(value.toString());
}
}
return result;
}
public List<ExcelErrData<T>> getErrList() {
return errList;
}
}

View File

@ -0,0 +1,141 @@
package io.metersphere.excel.listener;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import io.metersphere.excel.domain.TestCaseExcelData;
import io.metersphere.base.domain.TestCaseWithBLOBs;
import io.metersphere.commons.constants.TestCaseConstants;
import io.metersphere.commons.utils.BeanUtils;
import io.metersphere.service.TestCaseService;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class TestCaseDataListener extends EasyExcelListener<TestCaseExcelData> {
private TestCaseService testCaseService;
private String projectId;
Set<String> testCaseNames;
Set<String> userNames;
public TestCaseDataListener(TestCaseService testCaseService, String projectId,
Set<String> testCaseNames, Set<String> userNames, Class<TestCaseExcelData> clazz) {
super(clazz);
this.testCaseService = testCaseService;
this.projectId = projectId;
this.testCaseNames = testCaseNames;
this.userNames = userNames;
}
@Override
public String validate(TestCaseExcelData data, String errMsg) {
String nodePath = data.getNodePath();
StringBuilder stringBuilder = new StringBuilder(errMsg);
if ( nodePath.split("/").length > TestCaseConstants.MAX_NODE_DEPTH + 1) {
stringBuilder.append("节点最多为" + TestCaseConstants.MAX_NODE_DEPTH + "层;");
}
if ( nodePath.trim().contains(" ")) {
stringBuilder.append("所属模块不能包含空格");
}
if (!userNames.contains(data.getMaintainer())) {
stringBuilder.append("该工作空间下无该用户:" + data.getMaintainer() + ";");
}
if (testCaseNames.contains(data.getName())) {
stringBuilder.append("该项目下已存在该测试用例:" + data.getName() + ";");
}
return stringBuilder.toString();
}
@Override
public void saveData() {
//无错误数据才插入数据
if (!errList.isEmpty()) {
return;
}
List<TestCaseWithBLOBs> result = list.stream()
.map(item -> this.convert2TestCase(item))
.collect(Collectors.toList());
testCaseService.saveImportData(result, projectId);
}
private TestCaseWithBLOBs convert2TestCase(TestCaseExcelData data) {
TestCaseWithBLOBs testCase = new TestCaseWithBLOBs();
BeanUtils.copyBean(testCase, data);
testCase.setId(UUID.randomUUID().toString());
testCase.setProjectId(this.projectId);
testCase.setCreateTime(System.currentTimeMillis());
testCase.setUpdateTime(System.currentTimeMillis());
String nodePath = data.getNodePath();
if (!nodePath.startsWith("/")) {
nodePath = "/" + nodePath;
}
if (nodePath.endsWith("/")) {
nodePath = nodePath.substring(0, nodePath.length() - 1);
}
testCase.setNodePath(nodePath);
JSONArray jsonArray = new JSONArray();
String[] stepDesc = new String[0];
String[] stepRes = new String[0];
if (data.getStepDesc() != null) {
stepDesc = data.getStepDesc().split("\n");
}
if (data.getStepResult() != null) {
stepRes = data.getStepResult().split("\n");
}
String pattern = "(^\\d+)(\\.)?";
int index = stepDesc.length > stepRes.length ? stepDesc.length : stepRes.length;
for (int i = 0; i < index; i++){
JSONObject step = new JSONObject();
step.put("num", i + 1);
Pattern descPattern = Pattern.compile(pattern);
Pattern resPattern = Pattern.compile(pattern);
if (i < stepDesc.length) {
Matcher descMatcher = descPattern.matcher(stepDesc[i]);
if (descMatcher.find()) {
step.put("desc", descMatcher.replaceAll(""));
} else {
step.put("desc", stepDesc[i]);
}
}
if (i < stepRes.length) {
Matcher resMatcher = resPattern.matcher(stepRes[i]);
if (resMatcher.find()) {
step.put("result", resMatcher.replaceAll(""));
} else {
step.put("result", stepRes[i]);
}
}
jsonArray.add(step);
}
testCase.setSteps(jsonArray.toJSONString());
return testCase;
}
}

View File

@ -0,0 +1,30 @@
package io.metersphere.excel.utils;
import com.alibaba.excel.EasyExcel;
import io.metersphere.commons.utils.LogUtil;
import io.metersphere.exception.ExcelException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.List;
public class EasyExcelUtil {
public static void export(HttpServletResponse response, Class clazz, List data, String fileName, String sheetName) {
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
try {
response.setHeader("Content-disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8") + ".xlsx");
EasyExcel.write(response.getOutputStream(), clazz).sheet(sheetName).doWrite(data);
} catch (UnsupportedEncodingException e) {
LogUtil.error(e.getMessage(), e);
throw new ExcelException("不支持UTF-8编码");
} catch (IOException e) {
LogUtil.error(e.getMessage(), e);
throw new ExcelException("IO异常");
}
}
}

View File

@ -0,0 +1,32 @@
package io.metersphere.excel.utils;
import com.alibaba.excel.annotation.ExcelProperty;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.groups.Default;
import java.lang.reflect.Field;
import java.util.Set;
public class ExcelValidateHelper {
private ExcelValidateHelper(){}
private static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
public static <T> String validateEntity(T obj) throws NoSuchFieldException {
StringBuilder result = new StringBuilder();
Set<ConstraintViolation<T>> set = validator.validate(obj, Default.class);
if (set != null && !set.isEmpty()) {
for (ConstraintViolation<T> cv : set) {
Field declaredField = obj.getClass().getDeclaredField(cv.getPropertyPath().toString());
ExcelProperty annotation = declaredField.getAnnotation(ExcelProperty.class);
//拼接错误信息包含当前出错数据的标题名字+错误信息
result.append(annotation.value()[0]+cv.getMessage()).append(";");
}
}
return result.toString();
}
}

View File

@ -0,0 +1,18 @@
package io.metersphere.exception;
/**
* @author jianxing.chen
*/
public class ExcelException extends RuntimeException {
private static final long serialVersionUID = 1L;
public ExcelException(String message, Exception e){
super(message, e);
}
public ExcelException(String message){
super(message);
}
}

View File

@ -6,8 +6,11 @@ import io.metersphere.base.mapper.TestCaseMapper;
import io.metersphere.base.mapper.TestCaseNodeMapper;
import io.metersphere.base.mapper.TestPlanMapper;
import io.metersphere.base.mapper.TestPlanTestCaseMapper;
import io.metersphere.commons.constants.TestCaseConstants;
import io.metersphere.commons.utils.BeanUtils;
import io.metersphere.dto.TestCaseNodeDTO;
import io.metersphere.exception.ExcelException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -30,8 +33,8 @@ public class TestCaseNodeService {
public int addNode(TestCaseNode node) {
if(node.getLevel() > 5){
throw new RuntimeException("模块树最大深度为5层!");
if(node.getLevel() > TestCaseConstants.MAX_NODE_DEPTH){
throw new RuntimeException("模块树最大深度为" + TestCaseConstants.MAX_NODE_DEPTH + "层!");
}
node.setCreateTime(System.currentTimeMillis());
node.setUpdateTime(System.currentTimeMillis());
@ -196,4 +199,140 @@ public class TestCaseNodeService {
TestPlan testPlan = testPlanMapper.selectByPrimaryKey(planId);
return getNodeTreeByProjectId(testPlan.getProjectId());
}
public Map<String, Integer> createNodeByTestCases(List<TestCaseWithBLOBs> testCases, String projectId) {
List<TestCaseNodeDTO> nodeTrees = getNodeTreeByProjectId(projectId);
Map<String, Integer> pathMap = new HashMap<>();
List<String> nodePaths = testCases.stream()
.map(TestCase::getNodePath)
.collect(Collectors.toList());
nodePaths.forEach(path -> {
if (path == null) {
throw new ExcelException("所属模块不能为空!");
}
List<String> nodeNameList = new ArrayList<>(Arrays.asList(path.split("/")));
Iterator<String> pathIterator = nodeNameList.iterator();
Boolean hasNode = false;
String rootNodeName = null;
if (nodeNameList.size() <= 1) {
throw new ExcelException("创建模块失败:" + path);
} else {
pathIterator.next();
pathIterator.remove();
rootNodeName = pathIterator.next().trim();
//原来没有新建的树nodeTrees也不包含
for (TestCaseNodeDTO nodeTree : nodeTrees) {
if (StringUtils.equals(rootNodeName, nodeTree.getName())) {
hasNode = true;
createNodeByPathIterator(pathIterator, "/" + rootNodeName, nodeTree,
pathMap, projectId, 2);
};
}
}
if (!hasNode) {
createNodeByPath(pathIterator, rootNodeName, null, projectId, 1, "", pathMap);
}
});
return pathMap;
}
/**
* 根据目标节点路径创建相关节点
* @param pathIterator 遍历子路径
* @param path 当前路径
* @param treeNode 当前节点
* @param pathMap 记录节点路径对应的nodeId
*/
private void createNodeByPathIterator(Iterator<String> pathIterator, String path, TestCaseNodeDTO treeNode,
Map<String, Integer> pathMap, String projectId, Integer level) {
List<TestCaseNodeDTO> children = treeNode.getChildren();
if (children == null || children.isEmpty() || !pathIterator.hasNext()) {
pathMap.put(path , treeNode.getId());
if (pathIterator.hasNext()) {
createNodeByPath(pathIterator, pathIterator.next().trim(), treeNode, projectId, level, path, pathMap);
}
return;
}
String nodeName = pathIterator.next().trim();
Boolean hasNode = false;
for (TestCaseNodeDTO child : children) {
if (StringUtils.equals(nodeName, child.getName())) {
hasNode = true;
createNodeByPathIterator(pathIterator, path + "/" + child.getName(),
child, pathMap, projectId, level + 1);
};
}
//若子节点中不包含该目标节点则在该节点下创建
if (!hasNode) {
createNodeByPath(pathIterator, nodeName, treeNode, projectId, level, path, pathMap);
}
}
/**
*
* @param pathIterator 迭代器遍历子节点
* @param nodeName 当前节点
* @param pNode 父节点
*/
private void createNodeByPath(Iterator<String> pathIterator, String nodeName,
TestCaseNodeDTO pNode, String projectId, Integer level,
String rootPath, Map<String, Integer> pathMap) {
StringBuilder path = new StringBuilder(rootPath);
path.append("/" + nodeName);
Integer pid = null;
//创建过不创建
if (pathMap.get(path.toString()) != null) {
pid = pathMap.get(path.toString());
level++;
} else {
pid = insertTestCaseNode(nodeName, pNode == null ? null : pNode.getId(), projectId, level);
pathMap.put(path.toString(), pid);
}
while (pathIterator.hasNext()) {
String nextNodeName = pathIterator.next();
path.append("/" + nextNodeName);
if (pathMap.get(path.toString()) != null) {
pid = pathMap.get(path.toString());
level++;
} else {
pid = insertTestCaseNode(nextNodeName, pid, projectId, level);
pathMap.put(path.toString(), pid);
}
}
}
private Integer insertTestCaseNode(String nodeName, Integer pId, String projectId, Integer level) {
TestCaseNode testCaseNode = new TestCaseNode();
testCaseNode.setName(nodeName.trim());
testCaseNode.setpId(pId);
testCaseNode.setProjectId(projectId);
testCaseNode.setCreateTime(System.currentTimeMillis());
testCaseNode.setUpdateTime(System.currentTimeMillis());
testCaseNode.setLevel(level);
testCaseNodeMapper.insert(testCaseNode);
return testCaseNode.getId();
}
}

View File

@ -1,26 +1,36 @@
package io.metersphere.service;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.EasyExcelFactory;
import com.alibaba.fastjson.JSON;
import com.github.pagehelper.PageHelper;
import io.metersphere.base.domain.*;
import io.metersphere.base.mapper.ProjectMapper;
import io.metersphere.base.mapper.TestCaseMapper;
import io.metersphere.base.mapper.TestPlanMapper;
import io.metersphere.base.mapper.TestPlanTestCaseMapper;
import io.metersphere.base.mapper.*;
import io.metersphere.base.mapper.ext.ExtTestCaseMapper;
import io.metersphere.commons.utils.LogUtil;
import io.metersphere.controller.request.testcase.QueryTestCaseRequest;
import io.metersphere.dto.TestPlanCaseDTO;
import io.metersphere.excel.domain.ExcelErrData;
import io.metersphere.excel.domain.ExcelResponse;
import io.metersphere.excel.domain.TestCaseExcelData;
import io.metersphere.excel.listener.EasyExcelListener;
import io.metersphere.excel.listener.TestCaseDataListener;
import io.metersphere.excel.utils.EasyExcelUtil;
import io.metersphere.user.SessionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.session.ExecutorType;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@Transactional(rollbackFor = Exception.class)
@ -41,6 +51,15 @@ public class TestCaseService {
@Resource
ProjectMapper projectMapper;
@Resource
SqlSessionFactory sqlSessionFactory;
@Resource
TestCaseNodeService testCaseNodeService;
@Resource
UserMapper userMapper;
public void addTestCase(TestCaseWithBLOBs testCase) {
testCase.setId(UUID.randomUUID().toString());
testCase.setCreateTime(System.currentTimeMillis());
@ -144,4 +163,97 @@ public class TestCaseService {
}
return projectMapper.selectByPrimaryKey(testCaseWithBLOBs.getProjectId());
}
public ExcelResponse testCaseImport(MultipartFile file, String projectId) {
try {
ExcelResponse excelResponse = new ExcelResponse();
String currentWorkspaceId = SessionUtils.getCurrentWorkspaceId();
QueryTestCaseRequest queryTestCaseRequest = new QueryTestCaseRequest();
queryTestCaseRequest.setProjectId(projectId);
List<TestCase> testCases = extTestCaseMapper.getTestCaseNames(queryTestCaseRequest);
Set<String> testCaseNames = testCases.stream()
.map(TestCase::getName)
.collect(Collectors.toSet());
UserExample userExample = new UserExample();
userExample.createCriteria().andLastWorkspaceIdEqualTo(currentWorkspaceId);
List<User> users = userMapper.selectByExample(userExample);
Set<String> userNames = users.stream().map(User::getName).collect(Collectors.toSet());
EasyExcelListener easyExcelListener = new TestCaseDataListener(this, projectId,
testCaseNames, userNames, TestCaseExcelData.class);
EasyExcelFactory.read(file.getInputStream(), TestCaseExcelData.class, easyExcelListener).sheet().doRead();
List<ExcelErrData<TestCaseExcelData>> errList = easyExcelListener.getErrList();
//如果包含错误信息就导出错误信息
if (!errList.isEmpty()) {
excelResponse.setSuccess(false);
excelResponse.setErrList(errList);
} else {
excelResponse.setSuccess(true);
}
return excelResponse;
} catch (IOException e) {
LogUtil.error(e.getMessage(), e);
e.printStackTrace();
}
return null;
}
public void saveImportData(List<TestCaseWithBLOBs> testCases, String projectId) {
Map<String, Integer> nodePathMap = testCaseNodeService.createNodeByTestCases(testCases, projectId);
SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
TestCaseMapper mapper = sqlSession.getMapper(TestCaseMapper.class);
if (!testCases.isEmpty()) {
testCases.forEach(testcase -> {
testcase.setNodeId(nodePathMap.get(testcase.getNodePath()));
mapper.insert(testcase);
});
}
sqlSession.flushStatements();
}
public void testCaseTemplateExport(HttpServletResponse response) {
EasyExcelUtil.export(response, TestCaseExcelData.class, generateExportTemplate(), "测试用例模版", "模版");
}
private List<TestCaseExcelData> generateExportTemplate() {
List<TestCaseExcelData> list = new ArrayList<TestCaseExcelData>();
StringBuilder path = new StringBuilder("");
List<String> types = Arrays.asList("functional", "performance", "api");
List<String> methods = Arrays.asList("manual", "auto");
for (int i = 1; i <= 5; i++) {
TestCaseExcelData data = new TestCaseExcelData();
data.setName("测试用例" + i);
path.append("/" + "模块" + i);
data.setNodePath(path.toString());
data.setPriority("P" + i%4);
data.setType(types.get(i%3));
data.setMethod(methods.get(i%2));
data.setPrerequisite("前置条件选填");
data.setStepDesc("1. 每个步骤以换行分隔\n2. 步骤前可标序号\n3. 测试步骤和结果选填");
data.setStepResult("1. 每条结果以换行分隔\n2. 结果前可标序号\n3. 测试步骤和结果选填");
data.setMaintainer("admin");
data.setRemark("备注选填");
list.add(data);
}
list.add(new TestCaseExcelData());
TestCaseExcelData explain = new TestCaseExcelData();
explain.setName("同一项目下测试用例名称不能重复!");
explain.setNodePath("模块名称请按照'/模块1/模块2'的格式书写; 错误格式示例:('/', '/tes//test'); 若无该模块,则自动创建模块");
explain.setType("用例类型必须为functional、performance、api");
explain.setMethod("测试方式必须为manual、auto");
explain.setPriority("优先级必须为P0、P1、P2、P3");
explain.setMaintainer("维护人必须为该工作空间相关人员");
list.add(explain);
return list;
}
}

View File

@ -0,0 +1,170 @@
<template>
<div class="container">
<div class="main-content">
<el-card>
<el-container class="scenario-container">
<el-header>
<span class="scenario-title">场景配置</span>
</el-header>
<el-container>
<el-aside class="scenario-aside">
<div class="scenario-list">
<ms-api-collapse v-model="activeName" @change="handleChange" accordion>
<ms-api-collapse-item v-for="(scenario, index) in scenarios" :key="index"
:title="scenario.name" :name="index">
<template slot="title">
<div class="scenario-name">{{scenario.name}}</div>
<el-dropdown trigger="click" @command="handleCommand">
<span class="el-dropdown-link el-icon-more scenario-btn"/>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{type:'delete', index:index}">删除场景</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<ms-api-request :requests="scenario.requests" :open="select"/>
</ms-api-collapse-item>
</ms-api-collapse>
</div>
<el-button class="scenario-create" type="primary" size="mini" icon="el-icon-plus" plain @click="create"/>
</el-aside>
<el-main class="scenario-main">
<div class="scenario-form">
<ms-api-scenario-form :scenario="selected"></ms-api-scenario-form>
<ms-api-request-form :request="selected"></ms-api-request-form>
</div>
</el-main>
</el-container>
</el-container>
</el-card>
</div>
</div>
</template>
<script>
import MsApiCollapseItem from "./components/ApiCollapseItem";
import MsApiCollapse from "./components/ApiCollapse";
import MsApiRequest from "./components/ApiRequest";
import MsApiRequestForm from "./components/ApiRequestForm";
import MsApiScenarioForm from "./components/ApiScenarioForm";
export default {
name: "MsApiScenarioConfig",
components: {MsApiScenarioForm, MsApiRequestForm, MsApiRequest, MsApiCollapse, MsApiCollapseItem},
data() {
return {
activeName: 0,
scenarios: [],
selected: Object
}
},
methods: {
handleChange: function (index) {
this.select(this.scenarios[index]);
},
handleCommand: function (command) {
switch (command.type) {
case "delete":
this.deleteScenario(command.index);
break;
}
},
createScenario: function () {
return {
type: "Scenario",
name: "Scenario",
address: "",
file: "",
variables: [],
headers: [],
requests: []
}
},
deleteScenario: function (index) {
this.scenarios.splice(index, 1);
if (this.scenarios.length === 0) {
this.create();
}
},
create: function () {
let scenario = this.createScenario();
this.scenarios.push(scenario);
},
select: function (obj) {
this.selected = obj;
}
},
created() {
if (this.scenarios.length === 0) {
this.create();
this.select(this.scenarios[0]);
}
}
}
</script>
<style scoped>
.scenario-container {
height: calc(100vh - 150px);
min-height: 600px;
}
.scenario-title {
font-size: 16px;
margin-left: -20px;
}
.scenario-aside {
position: relative;
border-radius: 4px;
border: 1px solid #EBEEF5;
box-sizing: border-box;
}
.scenario-list {
overflow-y: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 28px;
}
.scenario-name {
font-size: 14px;
width: 100%;
}
.scenario-btn {
text-align: center;
padding: 13px;
}
.scenario-create {
position: absolute;
bottom: 0;
width: 100%;
}
.scenario-main {
position: relative;
margin-left: 20px;
border: 1px solid #EBEEF5;
}
.scenario-form {
padding: 20px;
overflow-y: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
</style>

View File

@ -1,242 +0,0 @@
<template>
<div class="edit-testplan-container" >
<div class="main-content">
<el-card>
<el-row>
<el-col :span="10">
<el-input :placeholder="$t('load_test.input_name')" v-model="testPlan.name" class="input-with-select">
<template v-slot:prepend>
<el-select v-model="testPlan.projectId" :placeholder="$t('load_test.select_project')">
<el-option
v-for="item in projects"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</template>
</el-input>
</el-col>
<el-button type="primary" plain @click="save">{{$t('commons.save')}}</el-button>
<el-button type="primary" plain @click="saveAndRun">{{$t('load_test.save_and_run')}}</el-button>
<el-button type="warning" plain @click="cancel">{{$t('commons.cancel')}}</el-button>
</el-row>
<el-tabs class="testplan-config" v-model="active" type="border-card" :stretch="true">
<el-tab-pane :label="$t('load_test.basic_config')">
<api-test-scene-config :test-plan="testPlan" />
</el-tab-pane>
<el-tab-pane :label="$t('load_test.runtime_config')">
<api-test-runtime-config :test-plan="testPlan" ref="runtimeConfig"/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</div>
</template>
<script>
import ApiTestSceneConfig from './components/ApiTestSceneConfig';
import ApiTestRuntimeConfig from './components/ApiTestRuntimeConfig';
export default {
name: "EditApiTest",
data() {
return {
result: {},
testPlan: {},
listProjectPath: "/project/listAll",
savePath: "/api/save",
editPath: "/api/edit",
runPath: "/api/run",
projects: [],
active: '0',
tabs: [{
title: this.$t('load_test.basic_config'),
id: '0',
component: 'ApiTestSceneConfig'
}, {
title: this.$t('load_test.runtime_config'),
id: '1',
component: 'ApiTestRuntimeConfig'
}]
}
},
components: {
ApiTestSceneConfig,
ApiTestRuntimeConfig,
},
watch: {
'$route'(to) {
//
if (to.name === 'createFucTest') {
window.location.reload();
return;
}
let testId = to.path.split('/')[4]; // find testId
if (testId) {
this.$get('/api/get/' + testId, response => {
this.testPlan = response.data;
});
}
}
},
created() {
let testId = this.$route.path.split('/')[4];
if (testId) {
this.$get('/api/get/' + testId, response => {
this.testPlan = response.data;
});
}
this.listProjects();
},
methods: {
listProjects() {
this.result = this.$get(this.listProjectPath, response => {
this.projects = response.data;
})
},
save() {
if (!this.validTestPlan()) {
return;
}
let options = this.getSaveOption();
this.result = this.$request(options, () => {
this.$message({
message: this.$t('commons.save_success'),
type: 'success'
});
this.$refs.runtimeConfig.cancelAllEdit();
this.$router.push({path: '/api/test/all'})
});
},
saveAndRun() {
if (!this.validTestPlan()) {
return;
}
let options = this.getSaveOption();
this.result = this.$request(options, (response) => {
this.testPlan.id = response.data;
this.$message({
message: this.$t('commons.save_success'),
type: 'success'
});
this.result = this.$post(this.runPath, {id: this.testPlan.id}, () => {
this.$message({
message: this.$t('load_test.is_running'),
type: 'success'
});
})
});
},
getSaveOption() {
let formData = new FormData();
let url = this.testPlan.id ? this.editPath : this.savePath;
if (!this.testPlan.file.id) {
formData.append("file", this.testPlan.file);
}
this.testPlan.runtimeConfiguration = JSON.stringify(this.$refs.runtimeConfig.configurations());
// filejson
let requestJson = JSON.stringify(this.testPlan, function (key, value) {
return key === "file" ? undefined : value
});
formData.append('request', new Blob([requestJson], {
type: "application/json"
}));
return {
method: 'POST',
url: url,
data: formData,
headers: {
'Content-Type': undefined
}
};
},
cancel() {
this.$router.push({path: '/api/test/all'})
},
validTestPlan() {
if (!this.testPlan.name) {
this.$message({
message: this.$t('load_test.test_name_is_null'),
type: 'error'
});
return false;
}
if (!this.testPlan.projectId) {
this.$message({
message: this.$t('load_test.project_is_null'),
type: 'error'
});
return false;
}
if (!this.testPlan.file) {
this.$message({
message: this.$t('load_test.jmx_is_null'),
type: 'error'
});
return false;
}
if (!this.$refs.runtimeConfig.validConfig()) {
return false;
}
/// todo:
return true;
}
}
}
</script>
<style scoped>
.edit-testplan-container {
float: none;
text-align: center;
padding: 15px;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.edit-testplan-container .main-content {
margin: 0 auto;
width: 100%;
max-width: 1200px;
}
.edit-testplan-container .testplan-config {
margin-top: 15px;
}
.el-select {
min-width: 130px;
}
.edit-testplan-container .input-with-select .el-input-group__prepend {
background-color: #fff;
}
.advanced-config {
height: calc(100vh - 280px);
overflow: auto;
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<div>
<el-radio-group v-model="body.type" size="mini">
<el-radio-button label="kv">
{{$t('api_test.request.body_kv')}}
</el-radio-button>
<el-radio-button label="text">
{{$t('api_test.request.body_text')}}
</el-radio-button>
</el-radio-group>
<ms-api-key-value :items="body.kvs" v-if="isKV"/>
<el-input class="textarea" type="textarea" v-model="body.text" :autosize="{ minRows: 10, maxRows: 25}" resize="none"
v-else/>
</div>
</template>
<script>
import MsApiKeyValue from "./ApiKeyValue";
export default {
name: "MsApiBody",
components: {MsApiKeyValue},
props: {
body: Object
},
data() {
return {};
},
methods: {},
computed: {
isKV() {
return this.body.type === "kv";
}
}
}
</script>
<style scoped>
.textarea {
margin-top: 10px;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div role="tablist" aria-multiselectable="true">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'MsApiCollapse',
componentName: 'MsApiCollapse',
props: {
accordion: Boolean,
value: {
type: [Array, String, Number],
default() {
return [];
}
}
},
data() {
return {
activeNames: [].concat(this.value)
};
},
provide() {
return {
collapse: this
};
},
watch: {
value(value) {
this.activeNames = [].concat(value);
}
},
methods: {
setActiveNames(activeNames) {
activeNames = [].concat(activeNames);
let value = this.accordion ? activeNames[0] : activeNames;
this.activeNames = activeNames;
this.$emit('input', value);
this.$emit('change', value);
},
handleItemClick(item) {
if (this.accordion) {
this.setActiveNames(
(this.activeNames[0] || this.activeNames[0] === 0) && item.name);
} else {
let activeNames = this.activeNames.slice(0);
let index = activeNames.indexOf(item.name);
if (index > -1) {
activeNames.splice(index, 1);
} else {
activeNames.push(item.name);
}
this.setActiveNames(activeNames);
}
}
},
created() {
this.$on('item-click', this.handleItemClick);
}
};
</script>

View File

@ -0,0 +1,125 @@
<template>
<div class="el-collapse-item"
:class="{'is-active': isActive, 'is-disabled': disabled }">
<div
role="tab"
:aria-expanded="isActive"
:aria-controls="`el-collapse-content-${id}`"
:aria-describedby="`el-collapse-content-${id}`"
>
<div
class="el-collapse-item__header"
@click="handleHeaderClick"
role="button"
:id="`el-collapse-head-${id}`"
:tabindex="disabled ? undefined : 0"
@keyup.space.enter.stop="handleEnterClick"
:class="{
'focusing': focusing,
'is-active': isActive
}"
@focus="handleFocus"
@blur="focusing = false"
>
<i
class="el-collapse-item__arrow el-icon-arrow-right"
:class="{'is-active': isActive}">
</i>
<slot name="title">{{title}}</slot>
</div>
</div>
<el-collapse-transition>
<div
class="el-collapse-item__wrap"
v-show="isActive"
role="tabpanel"
:aria-hidden="!isActive"
:aria-labelledby="`el-collapse-head-${id}`"
:id="`el-collapse-content-${id}`"
>
<div class="el-collapse-item__content">
<slot></slot>
</div>
</div>
</el-collapse-transition>
</div>
</template>
<script>
import Emitter from 'element-ui/src/mixins/emitter';
import {generateId} from 'element-ui/src/utils/util';
export default {
name: 'MsApiCollapseItem',
componentName: 'MsApiCollapseItem',
mixins: [Emitter],
data() {
return {
contentWrapStyle: {
height: 'auto',
display: 'block'
},
contentHeight: 0,
focusing: false,
isClick: false,
id: generateId()
};
},
inject: ['collapse'],
props: {
title: String,
name: {
type: [String, Number],
default() {
return this._uid;
}
},
disabled: Boolean
},
computed: {
isActive() {
return this.collapse.activeNames.indexOf(this.name) > -1;
}
},
methods: {
handleFocus() {
setTimeout(() => {
if (!this.isClick) {
this.focusing = true;
} else {
this.isClick = false;
}
}, 50);
},
handleHeaderClick() {
if (this.disabled) return;
this.dispatch('MsApiCollapse', 'item-click', this);
this.focusing = false;
this.isClick = true;
},
handleEnterClick() {
this.dispatch('MsApiCollapse', 'item-click', this);
}
}
};
</script>
<style scoped>
.el-collapse-item__header {
padding-left: 7px;
}
.el-collapse-item__header.is-active {
background-color: #E9E9E9;
}
.el-collapse-item__content {
padding-bottom: 0;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<div>
<span class="kv-description" v-if="description">
{{description}}
</span>
<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 :span="11">
<el-input v-model="item.key" placeholder="Key" size="small" maxlength="100" @change="check"/>
</el-col>
<el-col :span="11">
<el-input v-model="item.value" placeholder="Value" size="small" maxlength="100" @change="check"/>
</el-col>
<el-col :span="1">
<el-button size="mini" class="el-icon-delete-solid" circle @click="remove(index)"/>
</el-col>
</el-row>
</div>
</div>
</template>
<script>
export default {
name: "MsApiKeyValue",
props: {
description: String,
items: Array
},
methods: {
create: function () {
return {
key: "",
value: ""
}
},
remove: function (index) {
this.items.splice(index, 1);
if (this.items.length === 0) {
this.items.push(this.create());
}
},
check: function () {
let isNeedCreate = true;
let removeIndex = -1;
this.items.forEach((item, index) => {
if (item.key === "" && item.value === "") {
//
if (index !== this.items.length - 1) {
removeIndex = index;
}
//
isNeedCreate = false;
}
});
if (isNeedCreate) {
this.items.push(this.create());
}
if (removeIndex !== -1) {
this.remove(removeIndex);
}
// TODO key
}
},
created() {
if (this.items.length === 0) {
this.items.push(this.create());
}
}
}
</script>
<style scoped>
.kv-description {
font-size: 14px;
}
.kv-row {
margin-top: 10px;
}
</style>

View File

@ -0,0 +1,151 @@
<template>
<div class="request-container">
<div class="request-item" v-for="(request, index) in requests" :key="index" @click="select(request)"
:class="{'selected': isSelected(request)}">
<span class="request-method">
{{request.method}}
</span>
<span class="request-name">
{{request.name}}
</span>
<span class="request-btn">
<el-dropdown trigger="click" @command="handleCommand">
<span class="el-dropdown-link el-icon-more"></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{type: 'copy', index: index}">复制请求</el-dropdown-item>
<el-dropdown-item :command="{type: 'delete', index: index}">删除请求</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</span>
</div>
<el-button class="request-create" type="primary" size="mini" icon="el-icon-plus" plain @click="create"/>
</div>
</template>
<script>
import {generateId} from 'element-ui/src/utils/util';
export default {
name: "MsApiRequest",
props: {
requests: Array,
open: Function
},
data() {
return {
selected: 0
}
},
computed: {
isSelected() {
return function (request) {
return this.selected.randomId === request.randomId;
}
}
},
methods: {
create: function () {
let request = this.createRequest();
this.requests.push(request);
},
handleCommand: function (command) {
switch (command.type) {
case "copy":
this.copyRequest(command.index);
break;
case "delete":
this.deleteRequest(command.index);
break;
}
},
copyRequest: function (index) {
let request = this.requests[index];
this.requests.push(JSON.parse(JSON.stringify(request)));
},
deleteRequest: function (index) {
this.requests.splice(index, 1);
if (this.requests.length === 0) {
this.create();
}
},
createRequest: function () {
return {
randomId: generateId(),
type: "Request",
method: "GET",
name: "",
parameters: [],
headers: [],
body: {
type: "kv",
kvs: [],
text: ""
},
assertions: [],
extract: []
}
},
select: function (request) {
this.selected = request;
this.open(request);
}
},
created() {
if (this.requests.length === 0) {
this.create();
this.select(this.requests[0]);
}
}
}
</script>
<style scoped>
.request-item {
border-left: 5px solid #1E90FF;
line-height: 40px;
max-height: 40px;
border-top: 1px solid #EBEEF5;
cursor: pointer;
}
.request-item:first-child {
border-top: 0;
}
.request-item:hover, .request-item.selected:hover {
background-color: #ECF5FF;
}
.request-item.selected {
background-color: #F5F5F5;
}
.request-method {
padding: 0 5px;
width: 60px;
color: #1E90FF;
}
.request-name {
font-size: 14px;
width: 100%;
}
.request-btn {
float: right;
text-align: center;
height: 40px;
}
.request-btn .el-icon-more {
padding: 13px;
}
.request-create {
width: 100%;
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<el-form :model="request" :rules="rules" ref="request" label-width="100px" label-position="left" v-if="isRequest">
<el-form-item :label="$t('api_test.request.name')" prop="name">
<el-input v-model="request.name"></el-input>
</el-form-item>
<el-form-item :label="$t('api_test.request.url')" prop="url">
<el-input v-model="request.url" :placeholder="$t('api_test.request.url_describe')">
<el-select v-model="request.method" slot="prepend" class="request-method-select">
<el-option label="GET" value="GET"></el-option>
<el-option label="POST" value="POST"></el-option>
<el-option label="PUT" value="PUT"></el-option>
<el-option label="PATCH" value="PATCH"></el-option>
<el-option label="DELETE" value="DELETE"></el-option>
<el-option label="OPTIONS" value="OPTIONS"></el-option>
<el-option label="HEAD" value="HEAD"></el-option>
<el-option label="CONNECT" value="CONNECT"></el-option>
</el-select>
</el-input>
</el-form-item>
<el-tabs v-model="activeName">
<el-tab-pane :label="$t('api_test.request.parameters')" name="parameters">
<ms-api-key-value :items="request.parameters" :description="$t('api_test.request.parameters_desc')"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.request.headers')" name="headers">
<ms-api-key-value :items="request.headers"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.request.body')" name="body" v-if="isNotGet">
<ms-api-body :body="request.body"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.request.assertions')" name="assertions" v-if="false">
TODO
</el-tab-pane>
<el-tab-pane :label="$t('api_test.request.extract')" name="extract" v-if="false">
TODO
</el-tab-pane>
</el-tabs>
</el-form>
</template>
<script>
import MsApiKeyValue from "./ApiKeyValue";
import MsApiBody from "./ApiBody";
export default {
name: "MsApiRequestForm",
components: {MsApiBody, MsApiKeyValue},
props: {
request: Object
},
data() {
return {
activeName: "parameters",
rules: {}
}
},
computed: {
isRequest() {
return this.request.type === "Request";
},
isNotGet() {
return this.request.method !== "GET";
}
}
}
</script>
<style scoped>
.request-method-select {
width: 110px;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<el-form :model="scenario" :rules="rules" ref="scenario" label-width="100px" label-position="left" v-if="isScenario">
<el-form-item :label="$t('api_test.scenario.name')" prop="name">
<el-input v-model="scenario.name"></el-input>
</el-form-item>
<el-form-item :label="$t('api_test.scenario.base_url')" prop="url">
<el-input :placeholder="$t('api_test.scenario.base_url_describe')" v-model="scenario.url"></el-input>
</el-form-item>
<el-tabs v-model="activeName">
<el-tab-pane :label="$t('api_test.scenario.variables')" name="variables">
<ms-api-key-value :items="scenario.variables"/>
</el-tab-pane>
<el-tab-pane :label="$t('api_test.scenario.headers')" name="headers">
<ms-api-key-value :items="scenario.headers"/>
</el-tab-pane>
</el-tabs>
</el-form>
</template>
<script>
import MsApiKeyValue from "./ApiKeyValue";
export default {
name: "MsApiScenarioForm",
components: {MsApiKeyValue},
props: {
scenario: Object
},
data() {
return {
activeName: "variables",
rules: {}
}
},
computed: {
isScenario() {
return this.scenario.type === "Scenario";
}
},
}
</script>
<style scoped>
</style>

View File

@ -1,121 +0,0 @@
<template>
<div>
<el-row>
<el-col :span="5" :offset="6">
<span>浏览器</span>
</el-col>
</el-row>
<el-row >
<el-col :span="20" :offset="2">
<el-radio-group v-model="browser.value" class="browser-radio">
<el-radio v-for="item in browser.options" :key="item.label" :label="item.label">
<img :src="item.url"/>
</el-radio>
</el-radio-group>
</el-col>
</el-row>
<el-row>
<el-col :span="5" :offset="6">
<span>资源池</span>
</el-col>
</el-row>
<el-row>
<el-col :span="18" :offset="1">
<el-select v-model="resourcePool.value" filterable placeholder="请选择">
<el-option
v-for="item in resourcePool.options"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</el-col>
</el-row>
</div>
</template>
<script>
export default {
name: "ApiTestRuntimeConfig",
data() {
return {
resourcePool: {
options: [
{
value: '选项1',
label: '资源池1'
},
{
value: '选项2',
label: '资源池3'
},
{
value: '选项3',
label: '资源池3'
}],
value: ''
},
browser: {
options: [{
url: require('@/assets/browser/firefox.svg'),
label: 'firefox',
},
{
url: require('@/assets/browser/chrome.svg'),
label: 'chrome',
},
{
url: require('@/assets/browser/ie.svg'),
label: 'ie',
},
{
url: require('@/assets/browser/opera.svg'),
label: 'opera',
}
],
value: 'firefox'
}
}
},
methods: {
validConfig() {
if (this.resourcePool.value == '') {
this.$message.error(this.$t('api_test.select_resource_pool'));
return false;
}
return true;
},
configurations() {
return {
resourcePool: this.resourcePool,
browser: this.browser
}
},
cancelAllEdit() {
this.browser.value = 'firefox';
this.resourcePool.value = '';
}
}
}
</script>
<style scoped>
.el-row {
margin-top: 30px;
margin-bottom: 30px;
}
span {
font-size: 20px;
font-weight: bold;
color: dimgray;
}
</style>

View File

@ -1,196 +0,0 @@
<template>
<div v-loading="result.loading">
<el-upload
accept=".jmx"
drag
action=""
:limit="1"
:show-file-list="false"
:before-upload="beforeUpload"
:http-request="handleUpload"
:on-exceed="handleExceed"
:file-list="fileList">
<i class="el-icon-upload"/>
<div class="el-upload__text" v-html="$t('load_test.upload_tips')"></div>
<template v-slot:tip>
<div class="el-upload__tip">{{$t('load_test.upload_type')}}</div>
</template>
</el-upload>
<el-table class="basic-config" :data="tableData">
<el-table-column
prop="name"
:label="$t('load_test.file_name')">
</el-table-column>
<el-table-column
prop="size"
:label="$t('load_test.file_size')">
</el-table-column>
<el-table-column
prop="type"
:label="$t('load_test.file_type')">
</el-table-column>
<el-table-column
:label="$t('load_test.last_modify_time')">
<template v-slot:default="scope">
<i class="el-icon-time"/>
<span class="last-modified">{{ scope.row.lastModified | timestampFormatDate }}</span>
</template>
</el-table-column>
<el-table-column
prop="status"
:label="$t('load_test.file_status')">
</el-table-column>
<el-table-column
:label="$t('commons.operating')">
<template v-slot:default="scope">
<el-button @click="handleDownload(scope.row)" :disabled="!scope.row.id" type="primary" icon="el-icon-download"
size="mini" circle/>
<el-button @click="handleDelete(scope.row, scope.$index)" type="danger" icon="el-icon-delete" size="mini"
circle/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import {Message} from "element-ui";
export default {
name: "ApiTestSceneConfig",
props: ["testPlan"],
data() {
return {
result: {},
getFileMetadataPath: "/api/file/metadata",
jmxDownloadPath: '/api/file/download',
jmxDeletePath: '/api/file/delete',
fileList: [],
tableData: [],
};
},
created() {
if (this.testPlan.id) {
this.getFileMetadata(this.testPlan)
}
},
watch: {
testPlan() {
if (this.testPlan.id) {
this.getFileMetadata(this.testPlan)
}
}
},
methods: {
getFileMetadata(testPlan) {
this.fileList = [];//
this.tableData = [];//
this.result = this.$get(this.getFileMetadataPath + "/" + testPlan.id, response => {
let file = response.data;
if (!file) {
Message.error({message: this.$t('load_test.related_file_not_found'), showClose: true});
return;
}
this.testPlan.file = file;
this.fileList.push({
id: file.id,
name: file.name
});
this.tableData.push({
id: file.id,
name: file.name,
size: file.size + 'Byte', /// todo: ByteKBMB
type: 'JMX',
lastModified: file.updateTime,
status: 'todo',
});
})
},
beforeUpload(file) {
if (!this.fileValidator(file)) {
/// todo:
return false;
}
this.tableData.push({
name: file.name,
size: file.size + 'Byte', /// todo: ByteKBMB
type: 'JMX',
lastModified: file.lastModified,
status: 'todo',
});
return true;
},
handleUpload(uploadResources) {
this.testPlan.file = uploadResources.file;
},
handleDownload(file) {
let data = {
name: file.name,
id: file.id,
};
let config = {
url: this.jmxDownloadPath,
method: 'post',
data: data,
responseType: 'blob'
};
this.result = this.$request(config).then(response => {
const content = response.data;
const blob = new Blob([content]);
if ("download" in document.createElement("a")) {
// IE
// chrome/firefox
let aTag = document.createElement('a');
aTag.download = file.name;
aTag.href = URL.createObjectURL(blob);
aTag.click();
URL.revokeObjectURL(aTag.href)
} else {
// IE10+
navigator.msSaveBlob(blob, this.filename)
}
}).catch(e => {
Message.error({message: e.message, showClose: true});
});
},
handleDelete(file, index) {
this.$alert(this.$t('commons.delete_file_confirm') + file.name + "", '', {
confirmButtonText: this.$t('commons.confirm'),
callback: (action) => {
if (action === 'confirm') {
this._handleDelete(file, index);
}
}
});
},
_handleDelete(file, index) {
this.fileList.splice(index, 1);
this.tableData.splice(index, 1);
this.testPlan.file = null;
},
handleExceed() {
this.$message.error(this.$t('load_test.delete_file'));
},
fileValidator(file) {
/// todo:
return file.size > 0;
},
},
}
</script>
<style scoped>
.basic-config {
width: 100%
}
.last-modified {
margin-left: 5px;
}
</style>

View File

@ -18,7 +18,7 @@ import PerformanceTestReport from "../../performance/report/PerformanceTestRepor
import ApiTestReport from "../../api/report/ApiTestReport";
import ApiTest from "../../api/ApiTest";
import PerformanceTest from "../../performance/PerformanceTest";
import EditApiTest from "../../api/test/EditApiTest";
import ApiScenarioConfig from "../../api/test/ApiScenarioConfig";
import PerformanceTestHome from "../../performance/home/PerformanceTestHome";
import ApiTestList from "../../api/test/ApiTestList";
import ApiTestHome from "../../api/home/ApiTestHome";
@ -96,13 +96,13 @@ const router = new VueRouter({
},
{
path: 'test/create',
name: "createFucTest",
component: EditApiTest,
name: "createAPITest",
component: ApiScenarioConfig,
},
{
path: "test/edit/:testId",
name: "editFucTest",
component: EditApiTest,
name: "editAPITest",
component: ApiScenarioConfig,
props: {
content: (route) => {
return {

View File

@ -96,6 +96,10 @@
})
this.$get("/performance/report/content/load_chart/" + this.id, res => {
let data = res.data;
let userList = data.filter(m => m.groupName === "users").map(m => m.yAxis);
let hitsList = data.filter(m => m.groupName === "hits").map(m => m.yAxis);
let userMax = this._getChartMax(userList);
let hitsMax = this._getChartMax(hitsList);
let loadOption = {
title: {
text: 'Load',
@ -105,30 +109,57 @@
color: '#65A2FF'
},
},
tooltip: {
show: true,
trigger: 'axis'
},
legend: {},
xAxis: {},
yAxis: [{
name: 'User',
type: 'value',
min: 0,
max: userMax,
splitNumber: 5,
// interval: 10 / 5
interval: userMax / 5
},
{
name: 'Hits/s',
type: 'value',
splitNumber: 5,
min: 0,
// max: 5,
// interval: 5 / 5
max: hitsMax,
interval: hitsMax / 5
}
],
series: []
};
let setting = {
series: [
{
name: 'users',
color: '#0CA74A',
},
{
name: 'hits',
yAxisIndex: '1',
color: '#65A2FF',
},
{
name: 'errors',
yAxisIndex: '1',
color: '#E6113C',
}
this.loadOption = this.generateOption(loadOption, data);
]
}
this.loadOption = this.generateOption(loadOption, data, setting);
})
this.$get("/performance/report/content/res_chart/" + this.id, res => {
let data = res.data;
let userList = data.filter(m => m.groupName === "users").map(m => m.yAxis);
let responseTimeList = data.filter(m => m.groupName === "responseTime").map(m => m.yAxis);
let userMax = this._getChartMax(userList);
let resMax = this._getChartMax(responseTimeList);
let resOption = {
title: {
text: 'Response Time',
@ -138,28 +169,55 @@
color: '#99743C'
},
},
tooltip: {
show: true,
trigger: 'axis'
},
legend: {},
xAxis: {},
yAxis: [{
name: 'User',
type: 'value',
splitNumber: 5,
min: 0
min: 0,
max: userMax,
interval: userMax / 5
},
{
name: 'Response Time',
type: 'value',
splitNumber: 5,
min: 0
min: 0,
max: resMax,
interval: resMax / 5
}
],
series: []
}
this.resOption = this.generateOption(resOption, data);
let setting = {
series: [
{
name: 'users',
color: '#0CA74A',
},
{
name: "responseTime",
yAxisIndex: '1',
color: '#99743C',
}
]
}
this.resOption = this.generateOption(resOption, data, setting);
})
},
generateOption(option, data) {
generateOption(option, data, setting) {
let chartData = data;
let seriesArray = [];
for (let set in setting) {
if (set === "series") {
seriesArray = setting[set];
continue;
}
this.$set(option, set, setting[set]);
}
let legend = [], series = {}, xAxis = [], seriesData = [];
chartData.forEach(item => {
if (!xAxis.includes(item.xAxis)) {
@ -183,11 +241,24 @@
type: 'line',
data: data
};
let seriesArrayNames = seriesArray.map(m => m.name);
if (seriesArrayNames.includes(name)) {
for (let j = 0; j < seriesArray.length; j++) {
let seriesObj = seriesArray[j];
if (seriesObj['name'] === name) {
Object.assign(items, seriesObj);
}
}
}
seriesData.push(items);
}
this.$set(option, "series", seriesData);
return option;
},
_getChartMax(arr) {
const max = Math.max(...arr);
return Math.ceil(max / 4.5) * 5;
}
},
watch: {
status() {

View File

@ -26,6 +26,7 @@
:current-project="currentProject"
@openTestCaseEditDialog="openTestCaseEditDialog"
@testCaseEdit="openTestCaseEditDialog"
@refresh="refresh"
ref="testCaseList">
</test-case-list>
</el-main>

View File

@ -0,0 +1,17 @@
<template>
<div>
<el-tooltip class="item" effect="dark" content="导出用例" placement="right">
<el-button type="info" icon="el-icon-download" size="mini" circle></el-button>
</el-tooltip>
</div>
</template>
<script>
export default {
name: "TestCaseImport"
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,135 @@
<template>
<div>
<el-tooltip class="item" effect="dark" content="导入用例" placement="right">
<el-button type="info" icon="el-icon-upload2" size="mini" circle
@click="dialogVisible = true"></el-button>
</el-tooltip>
<el-dialog width="30%" title="导入测试用例" :visible.sync="dialogVisible"
@close="init">
<el-row>
<el-link type="primary" class="download-template"
href="/test/case/export/template">下载模版</el-link>
</el-row>
<el-row>
<el-upload
class="upload-demo"
:action="'/test/case/import/' + projectId"
:on-preview="handlePreview"
multiple
:limit="1"
:on-exceed="handleExceed"
:beforeUpload="UploadValidate"
:on-success="handleSuccess"
:on-error="handleError"
:file-list="fileList">
<template v-slot:trigger>
<el-button size="mini" type="success" plain>点击上传</el-button>
</template>
<template v-slot:tip>
<div class="el-upload__tip">只能上传xls/xlsx文件且不超过20M</div>
</template>
</el-upload>
</el-row>
<el-row>
<ul>
<li v-for="errFile in errList" :key="errFile.rowNum">
{{errFile.errMsg}}
</li>
</ul>
</el-row>
</el-dialog>
</div>
</template>
<script>
import ElUploadList from "element-ui/packages/upload/src/upload-list";
export default {
name: "TestCaseImport",
components: {ElUploadList},
data() {
return {
dialogVisible: false,
fileList: [],
errList: []
}
},
props: {
projectId: {
type: String
}
},
methods: {
handlePreview(file) {
console.log("init");
this.init();
},
handleExceed(files, fileList) {
this.$message.warning(`当前限制选择 1 个文件,本次选择了 ${files.length} 个文件`);
},
UploadValidate(file) {
var suffix =file.name.substring(file.name.lastIndexOf('.') + 1);
if (suffix != 'xls' && suffix != 'xlsx') {
this.$message({
message: '上传文件只能是 xls、xlsx格式!',
type: 'warning'
});
return false;
}
if (file.size / 1024 / 1024 > 20) {
this.$message({
message: '上传文件大小不能超过 20MB!',
type: 'warning'
});
return false;
}
return true;
},
handleSuccess(response) {
let res = response.data;
if (res.success) {
this.$message.success("导入成功!");
this.dialogVisible = false;
this.$emit("refresh");
} else {
this.errList = res.errList;
}
this.fileList = [];
},
handleError(err, file, fileList) {
this.$message.error(err.message);
},
init() {
this.fileList = [];
this.errList = [];
}
}
}
</script>
<style>
.el-dialog__body {
padding-top: 10px;
}
.download-template {
padding-top: 0px;
padding-bottom: 10px;
}
</style>
<style scoped>
</style>

View File

@ -4,12 +4,21 @@
<el-card v-loading="result.loading">
<template v-slot:header>
<div>
<el-row type="flex" justify="space-between" align="middle">
<el-row type="flex" justify="start" align="middle">
<el-col :span="5">
<span class="title">{{$t('test_track.test_case')}}</span>
<ms-create-box :tips="$t('test_track.create')" :exec="testCaseCreate"/>
</el-col>
<el-col :span="1" :offset="12">
<test-case-import :projectId="currentProject == null? null : currentProject.id"
@refresh="refresh"/>
</el-col>
<el-col :span="1">
<test-case-export/>
</el-col>
<el-col :span="5">
<span class="search">
<el-input type="text" size="small" :placeholder="$t('load_test.search_by_name')"
@ -105,10 +114,12 @@
<script>
import MsCreateBox from '../../../settings/CreateBox';
import TestCaseImport from '../components/TestCaseImport';
import TestCaseExport from '../components/TestCaseExport';
export default {
name: "TestCaseList",
components: {MsCreateBox},
components: {MsCreateBox, TestCaseImport, TestCaseExport},
data() {
return {
result: {},
@ -195,6 +206,9 @@
type: 'success'
});
});
},
refresh() {
this.$emit('refresh');
}
}
}

View File

@ -168,7 +168,27 @@ export default {
'resource_pool_is_null': '资源池为空',
},
api_test: {
'select_resource_pool': '请选择资源池'
scenario: {
name: "场景名称",
base_url: "基础URL",
base_url_describe: "基础URL作为所有请求的URL前缀",
variables: "变量",
headers: "请求头"
},
request: {
name: "请求名称",
method: "请求方法",
url: "请求URL",
url_describe: "例如: https://fit2cloud.com",
parameters: "请求参数",
parameters_desc: "参数追加到URL例如https://fit2cloud.com/entries?key1=Value1&Key2=Value2",
headers: "请求头",
body: "请求内容",
body_kv: "键值对",
body_text: "文本",
assertions: "断言",
extract: "提取"
}
},
test_track: {
'test_track': '测试跟踪',