diff --git a/backend/pom.xml b/backend/pom.xml index 645db8762f..546dad03f6 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -36,10 +36,6 @@ spring-boot-starter-tomcat org.springframework.boot - - hibernate-validator - org.hibernate.validator - @@ -138,6 +134,14 @@ ApacheJMeter_core ${jmeter.version} + + + + com.alibaba + easyexcel + 2.1.7 + + diff --git a/backend/src/main/java/io/metersphere/base/domain/ZaleniumTest.java b/backend/src/main/java/io/metersphere/base/domain/ZaleniumTest.java deleted file mode 100644 index 3631aba0d8..0000000000 --- a/backend/src/main/java/io/metersphere/base/domain/ZaleniumTest.java +++ /dev/null @@ -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; - } -} diff --git a/backend/src/main/java/io/metersphere/commons/constants/TestCaseConstants.java b/backend/src/main/java/io/metersphere/commons/constants/TestCaseConstants.java new file mode 100644 index 0000000000..06410341dc --- /dev/null +++ b/backend/src/main/java/io/metersphere/commons/constants/TestCaseConstants.java @@ -0,0 +1,5 @@ +package io.metersphere.commons.constants; + +public class TestCaseConstants { + public static final int MAX_NODE_DEPTH = 5; +} diff --git a/backend/src/main/java/io/metersphere/controller/TestCaseController.java b/backend/src/main/java/io/metersphere/controller/TestCaseController.java index 69be699528..307f8f2fd7 100644 --- a/backend/src/main/java/io/metersphere/controller/TestCaseController.java +++ b/backend/src/main/java/io/metersphere/controller/TestCaseController.java @@ -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); + } + } diff --git a/backend/src/main/java/io/metersphere/excel/domain/ExcelErrData.java b/backend/src/main/java/io/metersphere/excel/domain/ExcelErrData.java new file mode 100644 index 0000000000..66544759bb --- /dev/null +++ b/backend/src/main/java/io/metersphere/excel/domain/ExcelErrData.java @@ -0,0 +1,42 @@ +package io.metersphere.excel.domain; + +public class ExcelErrData { + + 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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/excel/domain/ExcelResponse.java b/backend/src/main/java/io/metersphere/excel/domain/ExcelResponse.java new file mode 100644 index 0000000000..aac623316f --- /dev/null +++ b/backend/src/main/java/io/metersphere/excel/domain/ExcelResponse.java @@ -0,0 +1,25 @@ +package io.metersphere.excel.domain; + +import java.util.List; + +public class ExcelResponse { + + private Boolean success; + private List> errList; + + public Boolean getSuccess() { + return success; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public List> getErrList() { + return errList; + } + + public void setErrList(List> errList) { + this.errList = errList; + } +} diff --git a/backend/src/main/java/io/metersphere/excel/domain/TestCaseExcelData.java b/backend/src/main/java/io/metersphere/excel/domain/TestCaseExcelData.java new file mode 100644 index 0000000000..c04bea2fc6 --- /dev/null +++ b/backend/src/main/java/io/metersphere/excel/domain/TestCaseExcelData.java @@ -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; + } + +} diff --git a/backend/src/main/java/io/metersphere/excel/listener/EasyExcelListener.java b/backend/src/main/java/io/metersphere/excel/listener/EasyExcelListener.java new file mode 100644 index 0000000000..1d95d382ba --- /dev/null +++ b/backend/src/main/java/io/metersphere/excel/listener/EasyExcelListener.java @@ -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 extends AnalysisEventListener { + + protected List> errList = new ArrayList<>(); + + protected List list = new ArrayList<>(); + + /** + * 每隔2000条存储数据库,然后清理list ,方便内存回收 + */ + protected static final int BATCH_COUNT = 2000; + + protected Class clazz; + + + public EasyExcelListener(Class 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 headMap, AnalysisContext context) { + super.invokeHeadMap(headMap, context); + if (clazz != null){ + try { + Set fieldNameSet = getFieldNameSet(clazz); + Collection 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 getFieldNameSet(Class clazz) throws NoSuchFieldException { + Set 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> getErrList() { + return errList; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/excel/listener/TestCaseDataListener.java b/backend/src/main/java/io/metersphere/excel/listener/TestCaseDataListener.java new file mode 100644 index 0000000000..6e53148a3f --- /dev/null +++ b/backend/src/main/java/io/metersphere/excel/listener/TestCaseDataListener.java @@ -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 { + + private TestCaseService testCaseService; + + private String projectId; + + Set testCaseNames; + + Set userNames; + + public TestCaseDataListener(TestCaseService testCaseService, String projectId, + Set testCaseNames, Set userNames, Class 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 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; + } + +} diff --git a/backend/src/main/java/io/metersphere/excel/utils/EasyExcelUtil.java b/backend/src/main/java/io/metersphere/excel/utils/EasyExcelUtil.java new file mode 100644 index 0000000000..a759e03d54 --- /dev/null +++ b/backend/src/main/java/io/metersphere/excel/utils/EasyExcelUtil.java @@ -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异常"); + } + + } +} diff --git a/backend/src/main/java/io/metersphere/excel/utils/ExcelValidateHelper.java b/backend/src/main/java/io/metersphere/excel/utils/ExcelValidateHelper.java new file mode 100644 index 0000000000..e4e29fa144 --- /dev/null +++ b/backend/src/main/java/io/metersphere/excel/utils/ExcelValidateHelper.java @@ -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 String validateEntity(T obj) throws NoSuchFieldException { + StringBuilder result = new StringBuilder(); + Set> set = validator.validate(obj, Default.class); + if (set != null && !set.isEmpty()) { + for (ConstraintViolation 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(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/exception/ExcelException.java b/backend/src/main/java/io/metersphere/exception/ExcelException.java new file mode 100644 index 0000000000..ca388a2864 --- /dev/null +++ b/backend/src/main/java/io/metersphere/exception/ExcelException.java @@ -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); + } + +} diff --git a/backend/src/main/java/io/metersphere/service/TestCaseNodeService.java b/backend/src/main/java/io/metersphere/service/TestCaseNodeService.java index fcf74b3250..d790c20012 100644 --- a/backend/src/main/java/io/metersphere/service/TestCaseNodeService.java +++ b/backend/src/main/java/io/metersphere/service/TestCaseNodeService.java @@ -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 createNodeByTestCases(List testCases, String projectId) { + + List nodeTrees = getNodeTreeByProjectId(projectId); + + Map pathMap = new HashMap<>(); + + List nodePaths = testCases.stream() + .map(TestCase::getNodePath) + .collect(Collectors.toList()); + + nodePaths.forEach(path -> { + + if (path == null) { + throw new ExcelException("所属模块不能为空!"); + } + List nodeNameList = new ArrayList<>(Arrays.asList(path.split("/"))); + Iterator 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 pathIterator, String path, TestCaseNodeDTO treeNode, + Map pathMap, String projectId, Integer level) { + + List 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 pathIterator, String nodeName, + TestCaseNodeDTO pNode, String projectId, Integer level, + String rootPath, Map 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(); + } } diff --git a/backend/src/main/java/io/metersphere/service/TestCaseService.java b/backend/src/main/java/io/metersphere/service/TestCaseService.java index 6dbf5bec4f..dc167c2a55 100644 --- a/backend/src/main/java/io/metersphere/service/TestCaseService.java +++ b/backend/src/main/java/io/metersphere/service/TestCaseService.java @@ -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 testCases = extTestCaseMapper.getTestCaseNames(queryTestCaseRequest); + Set testCaseNames = testCases.stream() + .map(TestCase::getName) + .collect(Collectors.toSet()); + + UserExample userExample = new UserExample(); + userExample.createCriteria().andLastWorkspaceIdEqualTo(currentWorkspaceId); + List users = userMapper.selectByExample(userExample); + Set 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> 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 testCases, String projectId) { + + Map 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 generateExportTemplate() { + List list = new ArrayList(); + StringBuilder path = new StringBuilder(""); + List types = Arrays.asList("functional", "performance", "api"); + List 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; + } } diff --git a/frontend/src/business/components/api/test/ApiScenarioConfig.vue b/frontend/src/business/components/api/test/ApiScenarioConfig.vue new file mode 100644 index 0000000000..193d2f7926 --- /dev/null +++ b/frontend/src/business/components/api/test/ApiScenarioConfig.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/business/components/api/test/EditApiTest.vue b/frontend/src/business/components/api/test/EditApiTest.vue deleted file mode 100644 index a77d0c1af9..0000000000 --- a/frontend/src/business/components/api/test/EditApiTest.vue +++ /dev/null @@ -1,242 +0,0 @@ - - - - - diff --git a/frontend/src/business/components/api/test/components/ApiBody.vue b/frontend/src/business/components/api/test/components/ApiBody.vue new file mode 100644 index 0000000000..a6074a6e65 --- /dev/null +++ b/frontend/src/business/components/api/test/components/ApiBody.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/business/components/api/test/components/ApiCollapse.vue b/frontend/src/business/components/api/test/components/ApiCollapse.vue new file mode 100644 index 0000000000..303c06f80a --- /dev/null +++ b/frontend/src/business/components/api/test/components/ApiCollapse.vue @@ -0,0 +1,70 @@ + + diff --git a/frontend/src/business/components/api/test/components/ApiCollapseItem.vue b/frontend/src/business/components/api/test/components/ApiCollapseItem.vue new file mode 100644 index 0000000000..add6a4e7ff --- /dev/null +++ b/frontend/src/business/components/api/test/components/ApiCollapseItem.vue @@ -0,0 +1,125 @@ + + + + diff --git a/frontend/src/business/components/api/test/components/ApiKeyValue.vue b/frontend/src/business/components/api/test/components/ApiKeyValue.vue new file mode 100644 index 0000000000..9cab3f286e --- /dev/null +++ b/frontend/src/business/components/api/test/components/ApiKeyValue.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/frontend/src/business/components/api/test/components/ApiRequest.vue b/frontend/src/business/components/api/test/components/ApiRequest.vue new file mode 100644 index 0000000000..464027ab64 --- /dev/null +++ b/frontend/src/business/components/api/test/components/ApiRequest.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/frontend/src/business/components/api/test/components/ApiRequestForm.vue b/frontend/src/business/components/api/test/components/ApiRequestForm.vue new file mode 100644 index 0000000000..e642811645 --- /dev/null +++ b/frontend/src/business/components/api/test/components/ApiRequestForm.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/business/components/api/test/components/ApiScenarioForm.vue b/frontend/src/business/components/api/test/components/ApiScenarioForm.vue new file mode 100644 index 0000000000..953240aab7 --- /dev/null +++ b/frontend/src/business/components/api/test/components/ApiScenarioForm.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/business/components/api/test/components/ApiTestRuntimeConfig.vue b/frontend/src/business/components/api/test/components/ApiTestRuntimeConfig.vue deleted file mode 100644 index 9dbcb03caa..0000000000 --- a/frontend/src/business/components/api/test/components/ApiTestRuntimeConfig.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - - - diff --git a/frontend/src/business/components/api/test/components/ApiTestSceneConfig.vue b/frontend/src/business/components/api/test/components/ApiTestSceneConfig.vue deleted file mode 100644 index c89c04e8ab..0000000000 --- a/frontend/src/business/components/api/test/components/ApiTestSceneConfig.vue +++ /dev/null @@ -1,196 +0,0 @@ - - - - - diff --git a/frontend/src/business/components/common/router/router.js b/frontend/src/business/components/common/router/router.js index 7a70a42f38..01831e0eb5 100644 --- a/frontend/src/business/components/common/router/router.js +++ b/frontend/src/business/components/common/router/router.js @@ -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 { diff --git a/frontend/src/business/components/performance/report/components/TestOverview.vue b/frontend/src/business/components/performance/report/components/TestOverview.vue index 7607967732..b8d3bd73f3 100644 --- a/frontend/src/business/components/performance/report/components/TestOverview.vue +++ b/frontend/src/business/components/performance/report/components/TestOverview.vue @@ -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() { diff --git a/frontend/src/business/components/track/case/TestCase.vue b/frontend/src/business/components/track/case/TestCase.vue index 7dce809631..921e3c1d17 100644 --- a/frontend/src/business/components/track/case/TestCase.vue +++ b/frontend/src/business/components/track/case/TestCase.vue @@ -26,6 +26,7 @@ :current-project="currentProject" @openTestCaseEditDialog="openTestCaseEditDialog" @testCaseEdit="openTestCaseEditDialog" + @refresh="refresh" ref="testCaseList"> diff --git a/frontend/src/business/components/track/case/components/TestCaseExport.vue b/frontend/src/business/components/track/case/components/TestCaseExport.vue new file mode 100644 index 0000000000..596d20b78e --- /dev/null +++ b/frontend/src/business/components/track/case/components/TestCaseExport.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/frontend/src/business/components/track/case/components/TestCaseImport.vue b/frontend/src/business/components/track/case/components/TestCaseImport.vue new file mode 100644 index 0000000000..90c315b148 --- /dev/null +++ b/frontend/src/business/components/track/case/components/TestCaseImport.vue @@ -0,0 +1,135 @@ + + + + + + + diff --git a/frontend/src/business/components/track/case/components/TestCaseList.vue b/frontend/src/business/components/track/case/components/TestCaseList.vue index 48f9ab4e25..d87ed3f552 100644 --- a/frontend/src/business/components/track/case/components/TestCaseList.vue +++ b/frontend/src/business/components/track/case/components/TestCaseList.vue @@ -4,12 +4,21 @@