From 3f17c18741b0253b817cd9b6234865dd5f377a1d Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Fri, 14 Jul 2023 15:43:50 +0800 Subject: [PATCH 1/9] draft: parser xml file to tree node --- center/build.gradle | 1 + .../center/service/CaseGenerationService.java | 82 ++++++ .../TestCaseGenerationServiceTest.java | 19 ++ center/src/test/resources/test_route_map.xml | 272 ++++++++++++++++++ .../hydralab/common/util/PageNode.java | 44 +++ 5 files changed, 418 insertions(+) create mode 100644 center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java create mode 100644 center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java create mode 100644 center/src/test/resources/test_route_map.xml create mode 100644 common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java diff --git a/center/build.gradle b/center/build.gradle index c2f4c4172..411dc4caf 100644 --- a/center/build.gradle +++ b/center/build.gradle @@ -27,6 +27,7 @@ tasks.withType(JavaCompile) { } dependencies { + implementation 'org.dom4j:dom4j:2.1.4' testCompile 'org.mockito:mockito-core:3.12.4' testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: springBootWebVersion testCompile 'me.paulschwarz:spring-dotenv:2.3.0' diff --git a/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java b/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java new file mode 100644 index 000000000..f38b19d5e --- /dev/null +++ b/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.center.service; + +import com.microsoft.hydralab.common.util.PageNode; +import org.dom4j.Document; +import org.dom4j.DocumentException; +import org.dom4j.Element; +import org.dom4j.io.SAXReader; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author zhoule + * @date 07/14/2023 + */ + +@Service +public class CaseGenerationService { + + public PageNode parserXMLToPageNode(String xmlFilePath) { + // leverage dom4j to load xml + Document document = null; + SAXReader saxReader = new SAXReader(); + try { + document = saxReader.read(xmlFilePath); + } catch (DocumentException e) { + throw new RuntimeException(e); + } + List pages = document.getRootElement().element("graph").element("nodes").elements("node"); + List actions = document.getRootElement().element("graph").element("edges").elements("edge"); + + Map pageNodes = new HashMap<>(); + + for (Element page : pages) { + PageNode pageNode = new PageNode(); + int id = Integer.parseInt(page.attributeValue("id")); + pageNode.setId(id); + pageNodes.put(id, pageNode); + } + + for (Element action : actions) { + int source = Integer.parseInt(action.attributeValue("source")); + int target = Integer.parseInt(action.attributeValue("target")); + if(source == target) { + continue; + } + int actionId = Integer.parseInt(action.attributeValue("id")); + pageNodes.get(source).getActionInfoList().add(parserAction(action)); + pageNodes.get(source).getChildPageNodeMap().put(actionId, pageNodes.get(target)); + } + + return pageNodes.get(0); + } + + private PageNode.ActionInfo parserAction(Element element) { + PageNode.ActionInfo actionInfo = new PageNode.ActionInfo(); + actionInfo.setId(Integer.parseInt(element.attributeValue("id"))); + actionInfo.setActionType("click"); + + PageNode.ElementInfo elementInfo = new PageNode.ElementInfo(); + String sourceCode = element.element("attvalues").element("attvalue").attributeValue("value"); + elementInfo.setText(extractElementAttr("Text", sourceCode)); + elementInfo.setClassName(extractElementAttr("Class", sourceCode)); + elementInfo.setClickable(Boolean.parseBoolean(extractElementAttr("Clickable", sourceCode))); + elementInfo.setResourceId(extractElementAttr("ResourceID", sourceCode)); + actionInfo.setTestElement(elementInfo); + return actionInfo; + } + + private static String extractElementAttr(String attrName, String elementStr) { + String[] attrs = elementStr.split(attrName + ": "); + if (attrs.length > 1 && !attrs[1].startsWith(",")) { + return attrs[1].split(",")[0]; + } + return ""; + } +} diff --git a/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java b/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java new file mode 100644 index 000000000..97b00eb7c --- /dev/null +++ b/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java @@ -0,0 +1,19 @@ +package com.microsoft.hydralab.center.service; + +import com.microsoft.hydralab.center.test.BaseTest; +import com.microsoft.hydralab.common.util.PageNode; +import org.junit.jupiter.api.Test; + +import javax.annotation.Resource; + +class TestCaseGenerationServiceTest extends BaseTest { + + @Resource + CaseGenerationService caseGenerationService; + + @Test + void testParserXMLToPageNode() { + PageNode rootNode = caseGenerationService.parserXMLToPageNode("src/test/resources/test_route_map.xml"); + System.out.println(rootNode); + } +} \ No newline at end of file diff --git a/center/src/test/resources/test_route_map.xml b/center/src/test/resources/test_route_map.xml new file mode 100644 index 000000000..d203f0a3c --- /dev/null +++ b/center/src/test/resources/test_route_map.xml @@ -0,0 +1,272 @@ + + + + NetworkX 2.8.8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java new file mode 100644 index 000000000..6133047c6 --- /dev/null +++ b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.common.util; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author zhoule + * @date 07/14/2023 + */ + +@Data +public class PageNode { + int id; + List actionInfoList = new ArrayList<>(); + Map childPageNodeMap = new HashMap<>(); + + @Data + public static class ElementInfo { + int index; + String className; + String text; + boolean clickable; + String resourceId; + } + + @Data + public static class ActionInfo { + Integer id; + ElementInfo testElement; + String actionType; + String driverId; + + String description; + Map arguments; + boolean isOptional; + } +} From 2f11560c3b77ae558105a51620cbde0cb066def1 Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Fri, 14 Jul 2023 15:48:52 +0800 Subject: [PATCH 2/9] Update PageNode.java --- .../main/java/com/microsoft/hydralab/common/util/PageNode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java index 6133047c6..b854db540 100644 --- a/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java +++ b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java @@ -19,6 +19,7 @@ public class PageNode { int id; List actionInfoList = new ArrayList<>(); + // key: action id, value: child page node Map childPageNodeMap = new HashMap<>(); @Data From 1b980a6e3db2a0cf9279f7d1e3aa6efcbb8a55d9 Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Tue, 18 Jul 2023 10:00:49 +0800 Subject: [PATCH 3/9] update Prompt --- center/build.gradle | 2 + .../center/service/LongChainExample.java | 41 ++++++++++++++++ .../center/service/LongChainExampleTest.java | 48 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 center/src/main/java/com/microsoft/hydralab/center/service/LongChainExample.java create mode 100644 center/src/test/java/com/microsoft/hydralab/center/service/LongChainExampleTest.java diff --git a/center/build.gradle b/center/build.gradle index 411dc4caf..c9e4798d6 100644 --- a/center/build.gradle +++ b/center/build.gradle @@ -27,6 +27,8 @@ tasks.withType(JavaCompile) { } dependencies { + implementation 'dev.langchain4j:langchain4j:0.11.0' + implementation 'dev.langchain4j:langchain4j-pinecone:0.11.0' implementation 'org.dom4j:dom4j:2.1.4' testCompile 'org.mockito:mockito-core:3.12.4' testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: springBootWebVersion diff --git a/center/src/main/java/com/microsoft/hydralab/center/service/LongChainExample.java b/center/src/main/java/com/microsoft/hydralab/center/service/LongChainExample.java new file mode 100644 index 000000000..8aa738f25 --- /dev/null +++ b/center/src/main/java/com/microsoft/hydralab/center/service/LongChainExample.java @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.center.service; + +import dev.langchain4j.model.input.structured.StructuredPrompt; + +/** + * @author zhoule + * @date 07/13/2023 + */ + +public class LongChainExample { + + @StructuredPrompt({ + "I want you to act as a software tester. I will provide a route map of a mobile application and it will be your job to write a test case. ", + "The case should be in maestro script format. This is a maestro example", + "{{maestroExample}}", + "Firstly I will introduce the format of the route map.", + "1. It is a unidirectional ordered graph in xml format, the nodes attribute are the pages of app and the id property of each node is the unique id of page. " + + "By the way the id of node equals -1 means the app has not been opened.", + "2. The edges attributes means the only way of jumping from a page to another page. The source property is the unique id of original page and the target property " + + "is the unique id of the page after jumping. The attvalue of each edge means the operation type such launch app, click button, click testview etc..", + "The commands that maestro supported is in the site https://maestro.mobile.dev/api-reference/commands.", + "Requirements:", + "1. the case should start from node which id is -1.", + "2. the case must follow the direction of the edge.", + "3. the case should jump as many pages as possible of the app.", + "4. the page can be visited only once", + "5. you can't use the back command", + "6. add comment to case declare current page id", + "The first route map is {{routeMap}}", + "please generate a maestro script for this route map." + }) + static class MaestroCaseGeneration { + + String maestroExample; + String routeMap; + } + +} diff --git a/center/src/test/java/com/microsoft/hydralab/center/service/LongChainExampleTest.java b/center/src/test/java/com/microsoft/hydralab/center/service/LongChainExampleTest.java new file mode 100644 index 000000000..5963d239d --- /dev/null +++ b/center/src/test/java/com/microsoft/hydralab/center/service/LongChainExampleTest.java @@ -0,0 +1,48 @@ +package com.microsoft.hydralab.center.service; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.output.Result; +import org.dom4j.Document; +import org.dom4j.DocumentException; +import org.dom4j.io.SAXReader; +import org.junit.jupiter.api.Test; + +class LongChainExampleTest { + static String apiKey = "**************"; // https://platform.openai.com/account/api-keys + static ChatLanguageModel model = OpenAiChatModel.withApiKey(apiKey); + + @Test + void testChain() { + + LongChainExample.MaestroCaseGeneration prompt = new LongChainExample.MaestroCaseGeneration(); + Document document = null; + SAXReader saxReader = new SAXReader(); + try { + document = saxReader.read("src/test/resources/test_route_map.xml"); + } catch (DocumentException e) { + throw new RuntimeException(e); + } + prompt.maestroExample = "appId: {the id of test app }\n" + + "---\n" + + "- launchApp\n" + + "- tapOn: \"Create new contact\"\n" + + "- tapOn: \"First name\"\n" + + "- inputRandomPersonName\n" + + "- tapOn: \"Last name\"\n" + + "- inputRandomPersonName\n" + + "- tapOn: \"Phone\"\n" + + "- inputRandomNumber:\n" + + " length: 10\n" + + "- back\n" + + "- tapOn: \"Email\"\n" + + "- inputRandomEmail\n" + + "- tapOn: \"Save\""; + prompt.routeMap = document.asXML(); + + Result result = model.sendUserMessage(prompt); + System.out.println(result.get().text()); + } + +} \ No newline at end of file From 7affe360533aaca5a307f28478f8b98d6e465921 Mon Sep 17 00:00:00 2001 From: dexterdreeeam <43837899+DexterDreeeam@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:16:52 +0800 Subject: [PATCH 4/9] update --- center/src/main/resources/prompts/case_generation/_.template | 0 center/src/main/resources/prompts/exploration/_.template | 0 .../resources/prompts/result_analysis/exception_analysis.template | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 center/src/main/resources/prompts/case_generation/_.template create mode 100644 center/src/main/resources/prompts/exploration/_.template create mode 100644 center/src/main/resources/prompts/result_analysis/exception_analysis.template diff --git a/center/src/main/resources/prompts/case_generation/_.template b/center/src/main/resources/prompts/case_generation/_.template new file mode 100644 index 000000000..e69de29bb diff --git a/center/src/main/resources/prompts/exploration/_.template b/center/src/main/resources/prompts/exploration/_.template new file mode 100644 index 000000000..e69de29bb diff --git a/center/src/main/resources/prompts/result_analysis/exception_analysis.template b/center/src/main/resources/prompts/result_analysis/exception_analysis.template new file mode 100644 index 000000000..e69de29bb From 42c3a4e5ca13d3b0a76ced841cad447bca26ffd1 Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Wed, 19 Jul 2023 10:09:40 +0800 Subject: [PATCH 5/9] Maestro case generation --- .../center/service/CaseGenerationService.java | 124 +++++++++++++++++- .../TestCaseGenerationServiceTest.java | 12 +- .../hydralab/common/util/PageNode.java | 14 +- 3 files changed, 144 insertions(+), 6 deletions(-) diff --git a/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java b/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java index f38b19d5e..e443264b8 100644 --- a/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java +++ b/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java @@ -3,13 +3,20 @@ package com.microsoft.hydralab.center.service; +import com.microsoft.hydralab.center.util.CenterConstant; +import com.microsoft.hydralab.common.util.DateUtil; +import com.microsoft.hydralab.common.util.FileUtil; +import com.microsoft.hydralab.common.util.HydraLabRuntimeException; import com.microsoft.hydralab.common.util.PageNode; import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.Element; import org.dom4j.io.SAXReader; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import java.io.File; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -23,7 +30,7 @@ public class CaseGenerationService { public PageNode parserXMLToPageNode(String xmlFilePath) { - // leverage dom4j to load xml + // read xml file, get page node and action info Document document = null; SAXReader saxReader = new SAXReader(); try { @@ -35,14 +42,14 @@ public PageNode parserXMLToPageNode(String xmlFilePath) { List actions = document.getRootElement().element("graph").element("edges").elements("edge"); Map pageNodes = new HashMap<>(); - + // init page node for (Element page : pages) { PageNode pageNode = new PageNode(); int id = Integer.parseInt(page.attributeValue("id")); pageNode.setId(id); pageNodes.put(id, pageNode); } - + // init action info for (Element action : actions) { int source = Integer.parseInt(action.attributeValue("source")); int target = Integer.parseInt(action.attributeValue("target")); @@ -50,15 +57,37 @@ public PageNode parserXMLToPageNode(String xmlFilePath) { continue; } int actionId = Integer.parseInt(action.attributeValue("id")); + //link action to page pageNodes.get(source).getActionInfoList().add(parserAction(action)); + //link page to page pageNodes.get(source).getChildPageNodeMap().put(actionId, pageNodes.get(target)); } - return pageNodes.get(0); } + /** + * explore all path of page node + * @param pageNode + * @param nodePath + * @param action + * @param explorePaths + */ + public void explorePageNodePath(PageNode pageNode, String nodePath, String action, List explorePaths) { + if (pageNode.getChildPageNodeMap().isEmpty()) { + explorePaths.add(new PageNode.ExplorePath(nodePath + "_" + pageNode.getId(), action)); + return; + } + + for (Map.Entry entry : pageNode.getChildPageNodeMap().entrySet()) { + explorePageNodePath(entry.getValue(), + StringUtils.isEmpty(nodePath) ? String.valueOf(pageNode.getId()) : nodePath + "_" + pageNode.getId(), + StringUtils.isEmpty(action) ? String.valueOf(entry.getKey()) : action + "," + entry.getKey(), explorePaths); + } + } + private PageNode.ActionInfo parserAction(Element element) { PageNode.ActionInfo actionInfo = new PageNode.ActionInfo(); + Map arguments = new HashMap<>(); actionInfo.setId(Integer.parseInt(element.attributeValue("id"))); actionInfo.setActionType("click"); @@ -69,6 +98,12 @@ private PageNode.ActionInfo parserAction(Element element) { elementInfo.setClickable(Boolean.parseBoolean(extractElementAttr("Clickable", sourceCode))); elementInfo.setResourceId(extractElementAttr("ResourceID", sourceCode)); actionInfo.setTestElement(elementInfo); + if (!StringUtils.isEmpty(elementInfo.getText())) { + arguments.put("defaultValue", elementInfo.getText()); + } else if (!StringUtils.isEmpty(elementInfo.getResourceId())) { + arguments.put("id", elementInfo.getResourceId()); + } + actionInfo.setArguments(arguments); return actionInfo; } @@ -79,4 +114,85 @@ private static String extractElementAttr(String attrName, String elementStr) { } return ""; } + + /** + * generate maestro case files and zip them + * @param pageNode + * @param explorePaths + * @return + */ + public File generateMaestroCases(PageNode pageNode, List explorePaths) { + // create temp folder to store case files + File tempFolder = new File(CenterConstant.CENTER_TEMP_FILE_DIR, DateUtil.fileNameDateFormat.format(new Date())); + if (!tempFolder.exists()) { + tempFolder.mkdirs(); + } + // generate case files + for (PageNode.ExplorePath explorePath : explorePaths) { + generateMaestroCase(pageNode, explorePath, tempFolder); + } + if (tempFolder.listFiles().length == 0) { + return null; + } + // zip temp folder + File zipFile = new File(tempFolder.getParent() + "/" + tempFolder.getName() + ".zip"); + FileUtil.zipFile(tempFolder.getAbsolutePath(), zipFile.getAbsolutePath()); + FileUtil.deleteFile(tempFolder); + return zipFile; + } + + private File generateMaestroCase(PageNode pageNode, PageNode.ExplorePath explorePath, File caseFolder) { + File maestroCaseFile = new File(caseFolder, explorePath.getPath() + ".yaml"); + String caseContent = buildConfigSection(pageNode.getPageName()); + caseContent += buildDelimiter(); + caseContent += buildCommandSection("launch", null); + String[] actionIds = explorePath.getActions().split(","); + PageNode pageNodeCopy = pageNode; + for (String actionId : actionIds) { + PageNode.ActionInfo action = pageNodeCopy.getActionInfoList().stream().filter(actionInfo -> actionInfo.getId() == Integer.parseInt(actionId)).findFirst().get(); + caseContent += buildCommandSection(action.getActionType(), action.getArguments()); + pageNodeCopy = pageNodeCopy.getChildPageNodeMap().get(Integer.parseInt(actionId)); + } + caseContent += buildCommandSection("stop", null); + FileUtil.writeToFile(caseContent, maestroCaseFile.getAbsolutePath()); + return maestroCaseFile; + } + + private String buildConfigSection(String appId) { + return "appId: " + appId + "\n"; + } + + private String buildDelimiter() { + return "---\n"; + } + + public String buildCommandSection(String actionType, Map arguments) { + String command = "-"; + switch (actionType) { + case "launch": + command = command + " launchApp\n"; + break; + case "click": + command = command + " tapOn:"; + if (arguments.size() == 0) { + throw new HydraLabRuntimeException("arguments is empty"); + } + if (arguments.containsKey("defaultValue")) { + command = command + " " + arguments.get("defaultValue") + "\n"; + break; + } + command = command + "\n"; + for (String key : arguments.keySet()) { + command = command + " " + key + ": \"" + arguments.get(key) + "\"\n"; + } + break; + case "stop": + command = command + " stopApp\n"; + break; + default: + throw new HydraLabRuntimeException("Unsupported action type: " + actionType); + } + return command; + } + } diff --git a/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java b/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java index 97b00eb7c..47a11800e 100644 --- a/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java +++ b/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java @@ -2,9 +2,13 @@ import com.microsoft.hydralab.center.test.BaseTest; import com.microsoft.hydralab.common.util.PageNode; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import javax.annotation.Resource; +import java.io.File; +import java.util.ArrayList; +import java.util.List; class TestCaseGenerationServiceTest extends BaseTest { @@ -14,6 +18,12 @@ class TestCaseGenerationServiceTest extends BaseTest { @Test void testParserXMLToPageNode() { PageNode rootNode = caseGenerationService.parserXMLToPageNode("src/test/resources/test_route_map.xml"); - System.out.println(rootNode); + Assertions.assertNotNull(rootNode, "parser xml to page node failed"); + rootNode.setPageName("com.microsoft.appmanager"); + List explorePaths = new ArrayList<>(); + Assertions.assertEquals(explorePaths.size(), 16, "explore path size is not correct"); + caseGenerationService.explorePageNodePath(rootNode, "", "", explorePaths); + File caseZipFile = caseGenerationService.generateMaestroCases(rootNode, explorePaths); + Assertions.assertTrue(caseZipFile.exists()); } } \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java index b854db540..65ef1633b 100644 --- a/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java +++ b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java @@ -18,6 +18,7 @@ @Data public class PageNode { int id; + String pageName; List actionInfoList = new ArrayList<>(); // key: action id, value: child page node Map childPageNodeMap = new HashMap<>(); @@ -33,7 +34,7 @@ public static class ElementInfo { @Data public static class ActionInfo { - Integer id; + int id; ElementInfo testElement; String actionType; String driverId; @@ -42,4 +43,15 @@ public static class ActionInfo { Map arguments; boolean isOptional; } + + @Data + public static class ExplorePath { + String path; + String actions; + + public ExplorePath(String path, String action) { + this.path = path; + this.actions = action; + } + } } From f967fb9c554e698c20b89c3edc99d5de0932e1db Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Thu, 20 Jul 2023 16:47:35 +0800 Subject: [PATCH 6/9] New Runner: Integrate with Maestro --- .../agent/config/TestRunnerConfig.java | 12 +- .../hydralab/agent/runner/XmlBuilder.java | 10 +- .../runner/espresso/XmlTestRunListener.java | 4 +- .../agent/runner/maestro/MaestroListener.java | 180 ++++++++++++++++++ .../runner/maestro/MaestroResultReceiver.java | 96 ++++++++++ .../agent/runner/maestro/MaestroRunner.java | 125 ++++++++++++ .../controller/PackageSetController.java | 2 +- .../common/entity/agent/EnvCapability.java | 1 + .../common/entity/common/TestTask.java | 1 + .../hydralab/common/util/FileUtil.java | 13 +- .../hydralab/common/util/PkgUtil.java | 37 +++- .../hydralab/common/util/StringUtilsTest.java | 18 +- react/src/component/RunnerView.jsx | 5 +- react/src/component/TasksView.jsx | 5 +- 14 files changed, 483 insertions(+), 26 deletions(-) create mode 100644 agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroListener.java create mode 100644 agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroResultReceiver.java create mode 100644 agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroRunner.java diff --git a/agent/src/main/java/com/microsoft/hydralab/agent/config/TestRunnerConfig.java b/agent/src/main/java/com/microsoft/hydralab/agent/config/TestRunnerConfig.java index 5a4744043..c4d8033a1 100644 --- a/agent/src/main/java/com/microsoft/hydralab/agent/config/TestRunnerConfig.java +++ b/agent/src/main/java/com/microsoft/hydralab/agent/config/TestRunnerConfig.java @@ -8,6 +8,7 @@ import com.microsoft.hydralab.agent.runner.appium.AppiumCrossRunner; import com.microsoft.hydralab.agent.runner.appium.AppiumRunner; import com.microsoft.hydralab.agent.runner.espresso.EspressoRunner; +import com.microsoft.hydralab.agent.runner.maestro.MaestroRunner; import com.microsoft.hydralab.agent.runner.monkey.AdbMonkeyRunner; import com.microsoft.hydralab.agent.runner.monkey.AppiumMonkeyRunner; import com.microsoft.hydralab.agent.runner.smart.SmartRunner; @@ -41,7 +42,8 @@ public class TestRunnerConfig { TestTask.TestRunningType.MONKEY_TEST, "adbMonkeyRunner", TestTask.TestRunningType.APPIUM_MONKEY_TEST, "appiumMonkeyRunner", TestTask.TestRunningType.T2C_JSON_TEST, "t2cRunner", - TestTask.TestRunningType.XCTEST, "xctestRunner" + TestTask.TestRunningType.XCTEST, "xctestRunner", + TestTask.TestRunningType.MAESTRO, "maestroRunner" ); @Bean @@ -125,6 +127,14 @@ public XCTestRunner xctestRunner(AgentManagementService agentManagementService, return new XCTestRunner(agentManagementService, testTaskEngineService, testRunDeviceOrchestrator, performanceTestManagementService); } + @Bean + public MaestroRunner maestroRunner(AgentManagementService agentManagementService, + TestTaskEngineService testTaskEngineService, + TestRunDeviceOrchestrator testRunDeviceOrchestrator, + PerformanceTestManagementService performanceTestManagementService) { + return new MaestroRunner(agentManagementService, testTaskEngineService, testRunDeviceOrchestrator, performanceTestManagementService); + } + @ConfigurationProperties(prefix = "app.device-script.commands") @Bean(name = "DeviceCommandProperty") public List deviceCommandProperty() { diff --git a/agent/src/main/java/com/microsoft/hydralab/agent/runner/XmlBuilder.java b/agent/src/main/java/com/microsoft/hydralab/agent/runner/XmlBuilder.java index a61316c4e..d0fc948ad 100644 --- a/agent/src/main/java/com/microsoft/hydralab/agent/runner/XmlBuilder.java +++ b/agent/src/main/java/com/microsoft/hydralab/agent/runner/XmlBuilder.java @@ -31,7 +31,7 @@ */ public class XmlBuilder { private static final String TEST_RESULT_FILE_SUFFIX = ".xml"; - private static final String TEST_RESULT_FILE_PREFIX = "hydra_result_"; + public static final String TEST_RESULT_FILE_PREFIX = "hydra_result_"; private static final String TEST_RESULT_FILE_ENCODE_PROPERTY = "encoding"; private static final String TEST_RESULT_FILE_ENCODE_VALUE = "UTF-8"; private static final String TEST_RESULT_FILE_OUTPUT_VALUE = "xml"; @@ -52,6 +52,14 @@ public class XmlBuilder { private static final String HOSTNAME = "hostname"; public String buildTestResultXml(TestTask testTask, TestRun testRun) throws Exception { + File resultFolder = testRun.getResultFolder(); + File[] files = resultFolder.listFiles(); + for (File tempfile : files) { + if (tempfile.getName().startsWith(TEST_RESULT_FILE_PREFIX) && tempfile.getName().endsWith(TEST_RESULT_FILE_SUFFIX)) { + testRun.getLogger().info("find test result file: " + tempfile.getAbsolutePath()); + return tempfile.getAbsolutePath(); + } + } DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder db = factory.newDocumentBuilder(); Document document = db.newDocument(); diff --git a/agent/src/main/java/com/microsoft/hydralab/agent/runner/espresso/XmlTestRunListener.java b/agent/src/main/java/com/microsoft/hydralab/agent/runner/espresso/XmlTestRunListener.java index dcb8c9be4..db3db466c 100644 --- a/agent/src/main/java/com/microsoft/hydralab/agent/runner/espresso/XmlTestRunListener.java +++ b/agent/src/main/java/com/microsoft/hydralab/agent/runner/espresso/XmlTestRunListener.java @@ -24,6 +24,7 @@ import com.android.ddmlib.testrunner.TestResult.TestStatus; import com.android.ddmlib.testrunner.TestRunResult; import com.google.common.collect.ImmutableMap; +import com.microsoft.hydralab.agent.runner.XmlBuilder; import com.microsoft.hydralab.common.util.FileUtil; import org.jetbrains.annotations.NotNull; import org.kxml2.io.KXmlSerializer; @@ -53,7 +54,6 @@ public class XmlTestRunListener implements ITestRunListener { private static final String LOG_TAG = "XmlResultReporter"; private static final String TEST_RESULT_FILE_SUFFIX = ".xml"; - private static final String TEST_RESULT_FILE_PREFIX = "test_result_"; private static final String TESTSUITE = "testsuite"; private static final String TESTCASE = "testcase"; @@ -233,7 +233,7 @@ public void addSystemError(String systemError) { * @throws IOException */ protected File getResultFile(File reportDir) throws IOException { - File reportFile = File.createTempFile(TEST_RESULT_FILE_PREFIX, TEST_RESULT_FILE_SUFFIX, + File reportFile = File.createTempFile(XmlBuilder.TEST_RESULT_FILE_PREFIX, TEST_RESULT_FILE_SUFFIX, reportDir); Log.i(LOG_TAG, String.format("Created xml report file at %s", reportFile.getAbsolutePath())); diff --git a/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroListener.java b/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroListener.java new file mode 100644 index 000000000..01715c545 --- /dev/null +++ b/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroListener.java @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.agent.runner.maestro; + +import com.microsoft.hydralab.agent.runner.TestRunDeviceOrchestrator; +import com.microsoft.hydralab.common.entity.common.AndroidTestUnit; +import com.microsoft.hydralab.common.entity.common.TestRun; +import com.microsoft.hydralab.common.entity.common.TestRunDevice; +import com.microsoft.hydralab.common.entity.common.TestTask; +import com.microsoft.hydralab.common.management.AgentManagementService; +import com.microsoft.hydralab.common.util.FileUtil; +import com.microsoft.hydralab.performance.PerformanceTestListener; +import org.slf4j.Logger; + +import java.io.File; + +/** + * @author zhoule + * @date 07/19/2023 + */ + +public class MaestroListener { + private final TestRunDevice testRunDevice; + private final TestRun testRun; + private final TestTask testTask; + private final Logger logger; + private final String pkgName; + private final AgentManagementService agentManagementService; + private final PerformanceTestListener performanceTestListener; + private TestRunDeviceOrchestrator testRunDeviceOrchestrator; + private long recordingStartTimeMillis; + private int index; + private boolean alreadyEnd = false; + private AndroidTestUnit ongoingTestUnit; + private int numTests; + + public MaestroListener(AgentManagementService agentManagementService, + TestRunDevice testRunDevice, TestRun testRun, TestTask testTask, + TestRunDeviceOrchestrator testRunDeviceOrchestrator, + PerformanceTestListener performanceTestListener) { + this.testRunDevice = testRunDevice; + this.testRunDeviceOrchestrator = testRunDeviceOrchestrator; + this.agentManagementService = agentManagementService; + this.testRun = testRun; + this.testTask = testTask; + this.logger = testRun.getLogger(); + this.pkgName = testTask.getPkgName(); + this.performanceTestListener = performanceTestListener; + } + + public void testRunStarted() { + infoLogEnter("testRunStarted", "maestro test"); + + testRun.setTestStartTimeMillis(System.currentTimeMillis()); + testRun.addNewTimeTag("testRunStarted", System.currentTimeMillis() - recordingStartTimeMillis); + testRunDeviceOrchestrator.setRunningTestName(testRunDevice, "MaestroTest.testRunStarted"); + performanceTestListener.testRunStarted(); + performanceTestListener.testStarted("MaestroTestCase" + index); + testRunDeviceOrchestrator.addGifFrameAsyncDelay(testRunDevice, agentManagementService.getScreenshotDir(), 5, logger); + } + + private void initUnitCase(String caseName, int testSeconds) { + final int unitIndex = index; + ongoingTestUnit = new AndroidTestUnit(); + ongoingTestUnit.setNumtests(index); + ongoingTestUnit.setStartTimeMillis(System.currentTimeMillis() - testSeconds * 1000); + ongoingTestUnit.setRelStartTimeInVideo(ongoingTestUnit.getStartTimeMillis() - testSeconds * 1000 - recordingStartTimeMillis); + ongoingTestUnit.setCurrentIndexNum(unitIndex); + ongoingTestUnit.setTestName(caseName); + ongoingTestUnit.setTestedClass("MaestroTest"); + ongoingTestUnit.setDeviceTestResultId(testRun.getId()); + ongoingTestUnit.setTestTaskId(testRun.getTestTaskId()); + + testRun.addNewTestUnit(ongoingTestUnit); + testRun.addNewTimeTag(unitIndex + ". " + ongoingTestUnit.getTitle(), + System.currentTimeMillis() - testSeconds * 1000 - recordingStartTimeMillis); + testRunDeviceOrchestrator.setRunningTestName(testRunDevice, ongoingTestUnit.getTitle()); + testRunDeviceOrchestrator.addGifFrameAsyncDelay(testRunDevice, agentManagementService.getScreenshotDir(), 5, logger); + performanceTestListener.testStarted("MaestroTestCase" + index); + } + + public void startRecording(int maxTime) { + logger.info("Start record screen"); + if (!testTask.isDisableRecording()) { + testRunDeviceOrchestrator.startScreenRecorder(testRunDevice, testRun.getResultFolder(), maxTime <= 0 ? 30 * 60 : maxTime, logger); + } + logger.info("Start gif frames collection"); + testRunDeviceOrchestrator.startGifEncoder(testRunDevice, testRun.getResultFolder(), pkgName + ".gif"); + logger.info("Start logcat collection"); + testRunDeviceOrchestrator.startLogCollector(testRunDevice, pkgName, testRun, logger); + testRun.setLogcatPath(agentManagementService.getTestBaseRelPathInUrl(new File(testRunDevice.getLogPath()))); + recordingStartTimeMillis = System.currentTimeMillis(); + final String initializing = "Initializing"; + testRunDeviceOrchestrator.setRunningTestName(testRunDevice, initializing); + testRun.addNewTimeTag(initializing, 0); + } + + public void testFailed(String caseName, int testSeconds, String error) { + errorLogEnter("testFailed", caseName); + performanceTestListener.testFailure("MaestroTestCase" + index); + index++; + initUnitCase(caseName, testSeconds); + ongoingTestUnit.setStack(error); + ongoingTestUnit.setStatusCode(AndroidTestUnit.StatusCodes.FAILURE); + testRun.addNewTimeTag(ongoingTestUnit.getTitle() + ".fail", System.currentTimeMillis() - recordingStartTimeMillis); + testRun.addNewTimeTag(ongoingTestUnit.getTitle() + ".end", System.currentTimeMillis() - recordingStartTimeMillis); + testRun.oneMoreFailure(); + ongoingTestUnit.setSuccess(false); + ongoingTestUnit.setEndTimeMillis(System.currentTimeMillis()); + ongoingTestUnit.setRelEndTimeInVideo(ongoingTestUnit.getEndTimeMillis() - recordingStartTimeMillis); + numTests++; + } + + public void testEnded(String caseName, int testSeconds) { + infoLogEnter("testEnded", caseName); + performanceTestListener.testSuccess("MaestroTestCase" + index); + index++; + initUnitCase(caseName, testSeconds); + testRun.addNewTimeTag(ongoingTestUnit.getTitle() + ".end", + System.currentTimeMillis() - recordingStartTimeMillis); + ongoingTestUnit.setStatusCode(AndroidTestUnit.StatusCodes.OK); + ongoingTestUnit.setSuccess(true); + ongoingTestUnit.setEndTimeMillis(System.currentTimeMillis()); + ongoingTestUnit.setRelEndTimeInVideo(ongoingTestUnit.getEndTimeMillis() - recordingStartTimeMillis); + numTests++; + } + + public void testRunFailed(String outputPath) { + errorLogEnter("testRunFailed", "Start to copy output files", outputPath); + File file = new File(outputPath); + if (!file.exists()) { + logger.info("testRunFailed: " + outputPath + " not exist"); + return; + } + File copiedFile = new File(testRun.getResultFolder(), file.getName()); + copiedFile.mkdir(); + FileUtil.copyFile(outputPath, copiedFile.getAbsolutePath()); + } + + public void testRunEnded() { + testRun.setTotalCount(numTests); + infoLogEnter("testRunEnded", Thread.currentThread().getName()); + synchronized (this) { + if (alreadyEnd) { + return; + } + performanceTestListener.testRunFinished(); + testRun.addNewTimeTag("testRunEnded", System.currentTimeMillis() - recordingStartTimeMillis); + testRun.onTestEnded(); + testRunDeviceOrchestrator.setRunningTestName(testRunDevice, null); + testRunDeviceOrchestrator.stopGitEncoder(testRunDevice, agentManagementService.getScreenshotDir(), logger); + if (!testTask.isDisableRecording()) { + testRunDeviceOrchestrator.stopScreenRecorder(testRunDevice, testRun.getResultFolder(), logger); + } + testRunDeviceOrchestrator.stopLogCollector(testRunDevice); + alreadyEnd = true; + } + } + + private void infoLogEnter(Object... args) { + StringBuilder builder = new StringBuilder(); + for (Object arg : args) { + builder.append(" >").append(arg); + } + logger.info("TestRunListener: {}", builder); + } + + private void errorLogEnter(Object... args) { + StringBuilder builder = new StringBuilder(); + for (Object arg : args) { + builder.append(" >").append(arg); + } + logger.error("TestRunListener: {}", builder); + } + + public File getGifFile() { + return testRunDevice.getGifFile(); + } +} diff --git a/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroResultReceiver.java b/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroResultReceiver.java new file mode 100644 index 000000000..98ed2a39d --- /dev/null +++ b/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroResultReceiver.java @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.agent.runner.maestro; + +import com.microsoft.hydralab.common.util.Const; +import com.microsoft.hydralab.common.util.LogUtils; +import org.slf4j.Logger; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.Map; + +/** + * @author zhoule + * @date 07/11/2023 + */ + +public class MaestroResultReceiver extends Thread { + private final InputStream inputStream; + private final Logger logger; + private final MaestroListener listener; + private static final String KEY_CASE_NAME = "caseName"; + private static final String KEY_TEST_SECONDS = "testSeconds"; + private static final String KEY_ERROR = "error"; + private boolean isTestRunFailed = false; + + public MaestroResultReceiver(InputStream inputStream, MaestroListener listener, Logger logger) { + this.inputStream = inputStream; + this.logger = logger; + this.listener = listener; + } + + public void run() { + listener.testRunStarted(); + try { + InputStreamReader isr = new InputStreamReader(inputStream, "UTF-8"); + BufferedReader bufferedReader = new BufferedReader(isr); + String line; + while ((line = bufferedReader.readLine()) != null) { + logger.info(line); + if (line.startsWith("[Passed]")) { + Map caseInfo = analysisCaseInfo(line); + listener.testEnded(caseInfo.get(KEY_CASE_NAME), Integer.parseInt(caseInfo.get(KEY_TEST_SECONDS))); + } else if (line.startsWith("[Failed]")) { + Map caseInfo = analysisCaseInfo(line); + listener.testFailed(caseInfo.get(KEY_CASE_NAME), Integer.parseInt(caseInfo.get(KEY_TEST_SECONDS)), caseInfo.get(KEY_ERROR)); + } else if (line.contains("Debug output")) { + isTestRunFailed = true; + logger.info("Start to analysis debug output"); + } + if (isTestRunFailed) { + if (LogUtils.isLegalStr(line, Const.RegexString.LINUX_ABSOLUTE_PATH, false) + || LogUtils.isLegalStr(line, Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)) { + listener.testRunFailed(line); + } + } + } + listener.testRunEnded(); + isr.close(); + bufferedReader.close(); + } catch (IOException e) { + logger.info("Exception:" + e); + e.printStackTrace(); + } finally { + synchronized (this) { + notify(); + } + } + } + + private Map analysisCaseInfo(String line) { + Map infoMap = new HashMap<>(); + String[] msg = line.split(" "); + if (msg.length < 3) { + infoMap.put(KEY_CASE_NAME, "caseParseError"); + infoMap.put(KEY_TEST_SECONDS, "0"); + } + infoMap.put(KEY_CASE_NAME, msg[1]); + String testSeconds = msg[2].replace("s", "").replace("(", "").replace(")", ""); + try { + Integer.parseInt(testSeconds); + } catch (NumberFormatException e) { + logger.info("Exception:" + e); + testSeconds = "0"; + } + infoMap.put(KEY_TEST_SECONDS, testSeconds); + if (msg.length >= 4) { + infoMap.put(KEY_ERROR, line.substring(line.indexOf(msg[3]))); + } + return infoMap; + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroRunner.java b/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroRunner.java new file mode 100644 index 000000000..0ce5769b7 --- /dev/null +++ b/agent/src/main/java/com/microsoft/hydralab/agent/runner/maestro/MaestroRunner.java @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.agent.runner.maestro; + +import com.microsoft.hydralab.agent.runner.TestRunDeviceOrchestrator; +import com.microsoft.hydralab.agent.runner.TestRunner; +import com.microsoft.hydralab.agent.runner.TestTaskRunCallback; +import com.microsoft.hydralab.agent.runner.XmlBuilder; +import com.microsoft.hydralab.common.entity.agent.EnvCapability; +import com.microsoft.hydralab.common.entity.agent.EnvCapabilityRequirement; +import com.microsoft.hydralab.common.entity.common.TestRun; +import com.microsoft.hydralab.common.entity.common.TestRunDevice; +import com.microsoft.hydralab.common.entity.common.TestTask; +import com.microsoft.hydralab.common.management.AgentManagementService; +import com.microsoft.hydralab.common.util.FileUtil; +import com.microsoft.hydralab.common.util.HydraLabRuntimeException; +import com.microsoft.hydralab.common.util.LogUtils; +import com.microsoft.hydralab.performance.PerformanceTestManagementService; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * @author zhoule + * @date 07/10/2023 + */ + +public class MaestroRunner extends TestRunner { + private static final String TEST_RUN_NAME = "Maestro test"; + private static final String TEST_CASE_FOLDER = "caseFolder"; + + private static final int MAJOR_MAESTRO_VERSION = 1; + private static final int MINOR_MAESTRO_VERSION = -1; + private Logger logger; + + public MaestroRunner(AgentManagementService agentManagementService, TestTaskRunCallback testTaskRunCallback, TestRunDeviceOrchestrator testRunDeviceOrchestrator, + PerformanceTestManagementService performanceTestManagementService) { + super(agentManagementService, testTaskRunCallback, testRunDeviceOrchestrator, performanceTestManagementService); + } + + @Override + protected List getEnvCapabilityRequirements() { + return List.of(new EnvCapabilityRequirement(EnvCapability.CapabilityKeyword.maestro, MAJOR_MAESTRO_VERSION, MINOR_MAESTRO_VERSION)); + } + + @Override + protected void run(TestRunDevice testRunDevice, TestTask testTask, TestRun testRun) throws Exception { + testRun.setTotalCount(testTask.getDeviceTestCount()); + logger = testRun.getLogger(); + + /** run the test */ + logger.info("Start Maestro test"); + checkTestTaskCancel(testTask); + testRun.setTestStartTimeMillis(System.currentTimeMillis()); + performanceTestManagementService.testRunStarted(); + + loadCaseFiles(testRun.getResultFolder(), testTask.getTestAppFile()); + MaestroListener maestroListener = new MaestroListener(agentManagementService, testRunDevice, + testRun, testTask, testRunDeviceOrchestrator, performanceTestManagementService); + maestroListener.startRecording(testTask.getTimeOutSecond()); + File xmlFile = generateResultXMLFile(testRun); + String command = buildCommand(testRunDevice, testRun, testTask.getInstrumentationArgs(), xmlFile); + checkTestTaskCancel(testTask); + try { + Process process = Runtime.getRuntime().exec(command); + MaestroResultReceiver resultReceiver = new MaestroResultReceiver(process.getInputStream(), maestroListener, logger); + resultReceiver.run(); + process.waitFor(); + /** set paths */ + testRun.setTestXmlReportPath( + agentManagementService.getTestBaseRelPathInUrl(xmlFile)); + File gifFile = maestroListener.getGifFile(); + if (gifFile.exists() && gifFile.length() > 0) { + testRun.setTestGifPath(agentManagementService.getTestBaseRelPathInUrl(gifFile)); + } + } catch (Exception e) { + logger.error("Maestro test failed", e); + testRun.setTestErrorMessage(e.getMessage()); + } + checkTestTaskCancel(testTask); + } + + private void loadCaseFiles(File resultFolder, File testAppFile) { + File caseFolder = new File(resultFolder, TEST_CASE_FOLDER); + if (!caseFolder.exists()) { + caseFolder.mkdirs(); + } + FileUtil.unzipFile(testAppFile.getAbsolutePath(), caseFolder.getAbsolutePath()); + } + + private File generateResultXMLFile(TestRun testRun) { + File xmlFile; + try { + xmlFile = File.createTempFile(XmlBuilder.TEST_RESULT_FILE_PREFIX, ".xml", testRun.getResultFolder()); + } catch (IOException e) { + throw new HydraLabRuntimeException("Failed to create xml result file", e); + } + return xmlFile; + } + + private String buildCommand(TestRunDevice testRunDevice, TestRun testRun, Map instrumentationArgs, File xmlFile) { + StringBuilder argString = new StringBuilder(); + if (instrumentationArgs != null && !instrumentationArgs.isEmpty()) { + instrumentationArgs.forEach((k, v) -> argString.append(" -e ").append(k.replaceAll("\\s|\"", "")).append("=").append(v.replaceAll("\\s|\"", ""))); + } + String commFormat; + if (StringUtils.isBlank(argString.toString())) { + commFormat = "maestro --device %s test --format junit --output %s %s/"; + } else { + commFormat = "maestro --device %s test " + argString + " --format junit --output %s %s/"; + } + + File caseFolder = new File(testRun.getResultFolder(), TEST_CASE_FOLDER); + + String command = String.format(commFormat, testRunDevice.getDeviceInfo().getSerialNum(), xmlFile.getAbsolutePath(), caseFolder.getAbsolutePath()); + logger.info("Maestro command: " + LogUtils.scrubSensitiveArgs(command)); + + return command; + } +} diff --git a/center/src/main/java/com/microsoft/hydralab/center/controller/PackageSetController.java b/center/src/main/java/com/microsoft/hydralab/center/controller/PackageSetController.java index 802d229c8..80b94fc72 100644 --- a/center/src/main/java/com/microsoft/hydralab/center/controller/PackageSetController.java +++ b/center/src/main/java/com/microsoft/hydralab/center/controller/PackageSetController.java @@ -151,7 +151,7 @@ public Result add(@CurrentSecurityContext SysUser requestor, //Save test app file to server if exist if (testAppFile != null && !testAppFile.isEmpty()) { File tempTestAppFile = attachmentService.verifyAndSaveFile(testAppFile, CENTER_FILE_BASE_DIR + relativeParent, false, null, - new String[]{FILE_SUFFIX.APK_FILE, FILE_SUFFIX.JAR_FILE, FILE_SUFFIX.JSON_FILE}); + new String[]{FILE_SUFFIX.APK_FILE, FILE_SUFFIX.JAR_FILE, FILE_SUFFIX.JSON_FILE, FILE_SUFFIX.ZIP_FILE}); StorageFileInfo testAppFileInfo = new StorageFileInfo(tempTestAppFile, relativeParent, StorageFileInfo.FileType.TEST_APP_FILE); //Upload app file diff --git a/common/src/main/java/com/microsoft/hydralab/common/entity/agent/EnvCapability.java b/common/src/main/java/com/microsoft/hydralab/common/entity/agent/EnvCapability.java index cfbe51ae8..0772cdd56 100644 --- a/common/src/main/java/com/microsoft/hydralab/common/entity/agent/EnvCapability.java +++ b/common/src/main/java/com/microsoft/hydralab/common/entity/agent/EnvCapability.java @@ -29,6 +29,7 @@ public enum CapabilityKeyword { // maven("--version"), gradle("--version"), // xcode("--version"), + maestro("--version"), appium("--version"); CapabilityKeyword(String fetchVersionParam, String... brokenIndicatorMessageParts) { diff --git a/common/src/main/java/com/microsoft/hydralab/common/entity/common/TestTask.java b/common/src/main/java/com/microsoft/hydralab/common/entity/common/TestTask.java index ddb365848..2a45d33a3 100644 --- a/common/src/main/java/com/microsoft/hydralab/common/entity/common/TestTask.java +++ b/common/src/main/java/com/microsoft/hydralab/common/entity/common/TestTask.java @@ -327,6 +327,7 @@ public interface TestRunningType { String APPIUM_MONKEY_TEST = "APPIUM_MONKEY"; String T2C_JSON_TEST = "T2C_JSON"; String XCTEST = "XCTEST"; + String MAESTRO = "MAESTRO"; } public interface TestFrameworkType { diff --git a/common/src/main/java/com/microsoft/hydralab/common/util/FileUtil.java b/common/src/main/java/com/microsoft/hydralab/common/util/FileUtil.java index f28792812..7c5bc9a44 100644 --- a/common/src/main/java/com/microsoft/hydralab/common/util/FileUtil.java +++ b/common/src/main/java/com/microsoft/hydralab/common/util/FileUtil.java @@ -11,7 +11,14 @@ import org.springframework.http.HttpStatus; import org.springframework.util.StringUtils; -import java.io.*; +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -225,4 +232,8 @@ public static void downloadFileUsingStream(String urlStr, String file) throws IO fis.close(); bis.close(); } + + public static void copyFile(String sourcePath, String targetPath) { + cn.hutool.core.io.FileUtil.copyFile(sourcePath, targetPath); + } } diff --git a/common/src/main/java/com/microsoft/hydralab/common/util/PkgUtil.java b/common/src/main/java/com/microsoft/hydralab/common/util/PkgUtil.java index 93b51673c..545daf849 100644 --- a/common/src/main/java/com/microsoft/hydralab/common/util/PkgUtil.java +++ b/common/src/main/java/com/microsoft/hydralab/common/util/PkgUtil.java @@ -3,7 +3,6 @@ package com.microsoft.hydralab.common.util; import cn.hutool.core.util.ZipUtil; - import com.alibaba.fastjson.JSONObject; import com.dd.plist.NSDictionary; import com.dd.plist.NSString; @@ -11,18 +10,23 @@ import com.microsoft.hydralab.common.entity.common.AgentUpdateTask.TaskConst; import com.microsoft.hydralab.common.entity.common.EntityType; import com.microsoft.hydralab.common.entity.common.StorageFileInfo.ParserKey; - import net.dongliu.apk.parser.ApkFile; import net.dongliu.apk.parser.bean.ApkMeta; - import org.apache.commons.io.FileUtils; import org.springframework.util.Assert; -import java.io.*; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.nio.BufferUnderflowException; import java.nio.file.Files; +import java.util.ArrayList; import java.util.Collection; import java.util.Enumeration; +import java.util.List; import java.util.Properties; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -123,10 +127,15 @@ private static JSONObject analysisZipFile(File zip) { + "/" + zip.getName().substring(0, zip.getName().lastIndexOf('.')); FileUtil.unzipFile(zip.getAbsolutePath(), unzippedFolderPath); File unzippedFolder = new File(unzippedFolderPath); + // for XCTest package File plistFile = getPlistFromFolder(unzippedFolder); - Assert.notNull(plistFile, "Analysis .app file failed."); - analysisPlist(plistFile, res); - + // for maestro case + List yamlFiles = getYamlFromFolder(unzippedFolder); + if (plistFile != null) { + analysisPlist(plistFile, res); + } else if (yamlFiles.size() == 0) { + throw new HydraLabRuntimeException("Analysis .zip file failed. It's not a valid XCTEST package or maestro case."); + } FileUtil.deleteFile(unzippedFolder); } catch (Exception e) { e.printStackTrace(); @@ -231,12 +240,24 @@ private static File getPlistFromFolder(File rootFolder) { for (File file : files) { if (file.getAbsolutePath().endsWith(".app/Info.plist") && !file.getAbsolutePath().contains("-Runner") - && !file.getAbsolutePath().contains("Watch")) + && !file.getAbsolutePath().contains("Watch")) { return file; + } } return null; } + private static List getYamlFromFolder(File rootFolder) { + Collection files = FileUtils.listFiles(rootFolder, null, true); + List yamlFiles = new ArrayList<>(); + for (File file : files) { + if (file.getAbsolutePath().endsWith(".yaml")) { + yamlFiles.add(file); + } + } + return yamlFiles; + } + private static File convertToZipFile(File file, String suffix) { try { int bytes = 0; diff --git a/common/src/test/java/com/microsoft/hydralab/common/util/StringUtilsTest.java b/common/src/test/java/com/microsoft/hydralab/common/util/StringUtilsTest.java index ca602aa51..ed67d3ca6 100644 --- a/common/src/test/java/com/microsoft/hydralab/common/util/StringUtilsTest.java +++ b/common/src/test/java/com/microsoft/hydralab/common/util/StringUtilsTest.java @@ -14,14 +14,16 @@ public void testPathVerification(){ Assertions.assertTrue(LogUtils.isLegalStr("/path/.git/.jdk/to/ab-c/abc 2/12.3?", Const.RegexString.LINUX_ABSOLUTE_PATH,false)); Assertions.assertFalse(LogUtils.isLegalStr("0", Const.RegexString.LINUX_ABSOLUTE_PATH,false)); Assertions.assertFalse(LogUtils.isLegalStr("abc", Const.RegexString.LINUX_ABSOLUTE_PATH,false)); - Assertions.assertFalse(LogUtils.isLegalStr("~", Const.RegexString.LINUX_ABSOLUTE_PATH,false)); + Assertions.assertFalse(LogUtils.isLegalStr("~", Const.RegexString.LINUX_ABSOLUTE_PATH, false)); + Assertions.assertTrue(LogUtils.isLegalStr("/Users/username/.maestro/tests/2023-07-19_160946", Const.RegexString.LINUX_ABSOLUTE_PATH, false)); - Assertions.assertTrue(LogUtils.isLegalStr("C:\\123.5\\AndroidSDK\\a", Const.RegexString.WINDOWS_ABSOLUTE_PATH,false)); - Assertions.assertTrue(LogUtils.isLegalStr("C:\\Program Files (x86)\\Common Files", Const.RegexString.WINDOWS_ABSOLUTE_PATH,false)); - Assertions.assertTrue(LogUtils.isLegalStr("C:\\Program Files (x86)\\安卓SDK", Const.RegexString.WINDOWS_ABSOLUTE_PATH,false)); - Assertions.assertTrue(LogUtils.isLegalStr("C:\\Program Files (x86)\\Common Files\\Microsoft Shared\\Phone Tools\\15.0", Const.RegexString.WINDOWS_ABSOLUTE_PATH,false)); - Assertions.assertFalse(LogUtils.isLegalStr("~", Const.RegexString.WINDOWS_ABSOLUTE_PATH,false)); - Assertions.assertFalse(LogUtils.isLegalStr("Common Files\\Microsoft Shared\\Phone Tools", Const.RegexString.WINDOWS_ABSOLUTE_PATH,false)); - Assertions.assertFalse(LogUtils.isLegalStr("\\Program Files (x86)", Const.RegexString.WINDOWS_ABSOLUTE_PATH,false)); + Assertions.assertTrue(LogUtils.isLegalStr("C:\\123.5\\AndroidSDK\\a", Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)); + Assertions.assertTrue(LogUtils.isLegalStr("C:\\Program Files (x86)\\Common Files", Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)); + Assertions.assertTrue(LogUtils.isLegalStr("C:\\Program Files (x86)\\安卓SDK", Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)); + Assertions.assertTrue(LogUtils.isLegalStr("C:\\Program Files (x86)\\Common Files\\Microsoft Shared\\Phone Tools\\15.0", Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)); + Assertions.assertFalse(LogUtils.isLegalStr("~", Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)); + Assertions.assertFalse(LogUtils.isLegalStr("Common Files\\Microsoft Shared\\Phone Tools", Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)); + Assertions.assertFalse(LogUtils.isLegalStr("\\Program Files (x86)", Const.RegexString.WINDOWS_ABSOLUTE_PATH, false)); } + } diff --git a/react/src/component/RunnerView.jsx b/react/src/component/RunnerView.jsx index 70366668b..671100d0e 100644 --- a/react/src/component/RunnerView.jsx +++ b/react/src/component/RunnerView.jsx @@ -279,10 +279,10 @@ export default class RunnerView extends BaseView { variant="outlined" startIcon={} > - {this.state.uploadTestPackageFile ? this.state.uploadTestPackageFile.name : 'Test APK/JAR/JSON file'} + {this.state.uploadTestPackageFile ? this.state.uploadTestPackageFile.name : 'Test APK/JAR/JSON/ZIP file'} @@ -577,6 +577,7 @@ export default class RunnerView extends BaseView { Appium E2E JSON-Described Test XCTest + Maestro
diff --git a/react/src/component/TasksView.jsx b/react/src/component/TasksView.jsx index a7f081c45..c8438c617 100644 --- a/react/src/component/TasksView.jsx +++ b/react/src/component/TasksView.jsx @@ -55,12 +55,13 @@ const TestType = { "APPIUM_CROSS": "Appium E2E", "T2C_JSON": "JSON-Described Test", "XCTEST": "XCTest", + "MAESTRO":"Maestro", "All": "All" } let params = { Timestamp: ["Last 24 Hours", "Last 7 Days", "Last 30 Days", "All"], - TestType: ["INSTRUMENTATION", "APPIUM", "SMART", "MONKEY", "APPIUM_MONKEY", "APPIUM_CROSS", "T2C_JSON", "XCTEST"], + TestType: ["INSTRUMENTATION", "APPIUM", "SMART", "MONKEY", "APPIUM_MONKEY", "APPIUM_CROSS", "T2C_JSON", "XCTEST","MAESTRO"], Result: ["Passed", "Failed"], TriggerType: ["PullRequest", "IndividualCI", "API"] }; @@ -68,7 +69,7 @@ let params = { let defaultSelectedParams = { time: "Last 24 Hours", suite: '', - TestType: ["INSTRUMENTATION", "APPIUM", "SMART", "MONKEY", "APPIUM_MONKEY", "APPIUM_CROSS", "T2C_JSON", "XCTEST"], + TestType: ["INSTRUMENTATION", "APPIUM", "SMART", "MONKEY", "APPIUM_MONKEY", "APPIUM_CROSS", "T2C_JSON", "XCTEST","MAESTRO"], Result: ["Passed", "Failed"], TriggerType: ["PullRequest", "IndividualCI", "API"] } From ddf93f04392a298c51fd7e5bb3e37e8161ddaa8b Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Mon, 24 Jul 2023 15:31:22 +0800 Subject: [PATCH 7/9] rebuild case generation code --- .../center/service/CaseGenerationService.java | 198 ------------------ .../generation/AbstractCaseGeneration.java | 112 ++++++++++ .../MaestroCaseGenerationService.java | 107 ++++++++++ .../generation/T2CCaseGenerationService.java | 28 +++ .../TestCaseGenerationServiceTest.java | 8 +- 5 files changed, 252 insertions(+), 201 deletions(-) delete mode 100644 center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java create mode 100644 center/src/main/java/com/microsoft/hydralab/center/service/generation/AbstractCaseGeneration.java create mode 100644 center/src/main/java/com/microsoft/hydralab/center/service/generation/MaestroCaseGenerationService.java create mode 100644 center/src/main/java/com/microsoft/hydralab/center/service/generation/T2CCaseGenerationService.java diff --git a/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java b/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java deleted file mode 100644 index e443264b8..000000000 --- a/center/src/main/java/com/microsoft/hydralab/center/service/CaseGenerationService.java +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package com.microsoft.hydralab.center.service; - -import com.microsoft.hydralab.center.util.CenterConstant; -import com.microsoft.hydralab.common.util.DateUtil; -import com.microsoft.hydralab.common.util.FileUtil; -import com.microsoft.hydralab.common.util.HydraLabRuntimeException; -import com.microsoft.hydralab.common.util.PageNode; -import org.dom4j.Document; -import org.dom4j.DocumentException; -import org.dom4j.Element; -import org.dom4j.io.SAXReader; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.io.File; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * @author zhoule - * @date 07/14/2023 - */ - -@Service -public class CaseGenerationService { - - public PageNode parserXMLToPageNode(String xmlFilePath) { - // read xml file, get page node and action info - Document document = null; - SAXReader saxReader = new SAXReader(); - try { - document = saxReader.read(xmlFilePath); - } catch (DocumentException e) { - throw new RuntimeException(e); - } - List pages = document.getRootElement().element("graph").element("nodes").elements("node"); - List actions = document.getRootElement().element("graph").element("edges").elements("edge"); - - Map pageNodes = new HashMap<>(); - // init page node - for (Element page : pages) { - PageNode pageNode = new PageNode(); - int id = Integer.parseInt(page.attributeValue("id")); - pageNode.setId(id); - pageNodes.put(id, pageNode); - } - // init action info - for (Element action : actions) { - int source = Integer.parseInt(action.attributeValue("source")); - int target = Integer.parseInt(action.attributeValue("target")); - if(source == target) { - continue; - } - int actionId = Integer.parseInt(action.attributeValue("id")); - //link action to page - pageNodes.get(source).getActionInfoList().add(parserAction(action)); - //link page to page - pageNodes.get(source).getChildPageNodeMap().put(actionId, pageNodes.get(target)); - } - return pageNodes.get(0); - } - - /** - * explore all path of page node - * @param pageNode - * @param nodePath - * @param action - * @param explorePaths - */ - public void explorePageNodePath(PageNode pageNode, String nodePath, String action, List explorePaths) { - if (pageNode.getChildPageNodeMap().isEmpty()) { - explorePaths.add(new PageNode.ExplorePath(nodePath + "_" + pageNode.getId(), action)); - return; - } - - for (Map.Entry entry : pageNode.getChildPageNodeMap().entrySet()) { - explorePageNodePath(entry.getValue(), - StringUtils.isEmpty(nodePath) ? String.valueOf(pageNode.getId()) : nodePath + "_" + pageNode.getId(), - StringUtils.isEmpty(action) ? String.valueOf(entry.getKey()) : action + "," + entry.getKey(), explorePaths); - } - } - - private PageNode.ActionInfo parserAction(Element element) { - PageNode.ActionInfo actionInfo = new PageNode.ActionInfo(); - Map arguments = new HashMap<>(); - actionInfo.setId(Integer.parseInt(element.attributeValue("id"))); - actionInfo.setActionType("click"); - - PageNode.ElementInfo elementInfo = new PageNode.ElementInfo(); - String sourceCode = element.element("attvalues").element("attvalue").attributeValue("value"); - elementInfo.setText(extractElementAttr("Text", sourceCode)); - elementInfo.setClassName(extractElementAttr("Class", sourceCode)); - elementInfo.setClickable(Boolean.parseBoolean(extractElementAttr("Clickable", sourceCode))); - elementInfo.setResourceId(extractElementAttr("ResourceID", sourceCode)); - actionInfo.setTestElement(elementInfo); - if (!StringUtils.isEmpty(elementInfo.getText())) { - arguments.put("defaultValue", elementInfo.getText()); - } else if (!StringUtils.isEmpty(elementInfo.getResourceId())) { - arguments.put("id", elementInfo.getResourceId()); - } - actionInfo.setArguments(arguments); - return actionInfo; - } - - private static String extractElementAttr(String attrName, String elementStr) { - String[] attrs = elementStr.split(attrName + ": "); - if (attrs.length > 1 && !attrs[1].startsWith(",")) { - return attrs[1].split(",")[0]; - } - return ""; - } - - /** - * generate maestro case files and zip them - * @param pageNode - * @param explorePaths - * @return - */ - public File generateMaestroCases(PageNode pageNode, List explorePaths) { - // create temp folder to store case files - File tempFolder = new File(CenterConstant.CENTER_TEMP_FILE_DIR, DateUtil.fileNameDateFormat.format(new Date())); - if (!tempFolder.exists()) { - tempFolder.mkdirs(); - } - // generate case files - for (PageNode.ExplorePath explorePath : explorePaths) { - generateMaestroCase(pageNode, explorePath, tempFolder); - } - if (tempFolder.listFiles().length == 0) { - return null; - } - // zip temp folder - File zipFile = new File(tempFolder.getParent() + "/" + tempFolder.getName() + ".zip"); - FileUtil.zipFile(tempFolder.getAbsolutePath(), zipFile.getAbsolutePath()); - FileUtil.deleteFile(tempFolder); - return zipFile; - } - - private File generateMaestroCase(PageNode pageNode, PageNode.ExplorePath explorePath, File caseFolder) { - File maestroCaseFile = new File(caseFolder, explorePath.getPath() + ".yaml"); - String caseContent = buildConfigSection(pageNode.getPageName()); - caseContent += buildDelimiter(); - caseContent += buildCommandSection("launch", null); - String[] actionIds = explorePath.getActions().split(","); - PageNode pageNodeCopy = pageNode; - for (String actionId : actionIds) { - PageNode.ActionInfo action = pageNodeCopy.getActionInfoList().stream().filter(actionInfo -> actionInfo.getId() == Integer.parseInt(actionId)).findFirst().get(); - caseContent += buildCommandSection(action.getActionType(), action.getArguments()); - pageNodeCopy = pageNodeCopy.getChildPageNodeMap().get(Integer.parseInt(actionId)); - } - caseContent += buildCommandSection("stop", null); - FileUtil.writeToFile(caseContent, maestroCaseFile.getAbsolutePath()); - return maestroCaseFile; - } - - private String buildConfigSection(String appId) { - return "appId: " + appId + "\n"; - } - - private String buildDelimiter() { - return "---\n"; - } - - public String buildCommandSection(String actionType, Map arguments) { - String command = "-"; - switch (actionType) { - case "launch": - command = command + " launchApp\n"; - break; - case "click": - command = command + " tapOn:"; - if (arguments.size() == 0) { - throw new HydraLabRuntimeException("arguments is empty"); - } - if (arguments.containsKey("defaultValue")) { - command = command + " " + arguments.get("defaultValue") + "\n"; - break; - } - command = command + "\n"; - for (String key : arguments.keySet()) { - command = command + " " + key + ": \"" + arguments.get(key) + "\"\n"; - } - break; - case "stop": - command = command + " stopApp\n"; - break; - default: - throw new HydraLabRuntimeException("Unsupported action type: " + actionType); - } - return command; - } - -} diff --git a/center/src/main/java/com/microsoft/hydralab/center/service/generation/AbstractCaseGeneration.java b/center/src/main/java/com/microsoft/hydralab/center/service/generation/AbstractCaseGeneration.java new file mode 100644 index 000000000..838640aab --- /dev/null +++ b/center/src/main/java/com/microsoft/hydralab/center/service/generation/AbstractCaseGeneration.java @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.center.service.generation; + +import com.microsoft.hydralab.common.util.PageNode; +import org.dom4j.Document; +import org.dom4j.DocumentException; +import org.dom4j.Element; +import org.dom4j.io.SAXReader; +import org.springframework.util.StringUtils; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author zhoule + * @date 07/21/2023 + */ + +public abstract class AbstractCaseGeneration { + public PageNode parserXMLToPageNode(String xmlFilePath) { + // read xml file, get page node and action info + Document document = null; + SAXReader saxReader = new SAXReader(); + try { + document = saxReader.read(xmlFilePath); + } catch (DocumentException e) { + throw new RuntimeException(e); + } + List pages = document.getRootElement().element("graph").element("nodes").elements("node"); + List actions = document.getRootElement().element("graph").element("edges").elements("edge"); + + Map pageNodes = new HashMap<>(); + // init page node + for (Element page : pages) { + PageNode pageNode = new PageNode(); + int id = Integer.parseInt(page.attributeValue("id")); + pageNode.setId(id); + pageNodes.put(id, pageNode); + } + // init action info + for (Element action : actions) { + int source = Integer.parseInt(action.attributeValue("source")); + int target = Integer.parseInt(action.attributeValue("target")); + if (source == target) { + continue; + } + int actionId = Integer.parseInt(action.attributeValue("id")); + //link action to page + pageNodes.get(source).getActionInfoList().add(parserAction(action)); + //link page to page + pageNodes.get(source).getChildPageNodeMap().put(actionId, pageNodes.get(target)); + } + return pageNodes.get(0); + } + + private PageNode.ActionInfo parserAction(Element element) { + PageNode.ActionInfo actionInfo = new PageNode.ActionInfo(); + Map arguments = new HashMap<>(); + actionInfo.setId(Integer.parseInt(element.attributeValue("id"))); + actionInfo.setActionType("click"); + + PageNode.ElementInfo elementInfo = new PageNode.ElementInfo(); + String sourceCode = element.element("attvalues").element("attvalue").attributeValue("value"); + elementInfo.setText(extractElementAttr("Text", sourceCode)); + elementInfo.setClassName(extractElementAttr("Class", sourceCode)); + elementInfo.setClickable(Boolean.parseBoolean(extractElementAttr("Clickable", sourceCode))); + elementInfo.setResourceId(extractElementAttr("ResourceID", sourceCode)); + actionInfo.setTestElement(elementInfo); + if (!StringUtils.isEmpty(elementInfo.getText())) { + arguments.put("defaultValue", elementInfo.getText()); + } else if (!StringUtils.isEmpty(elementInfo.getResourceId())) { + arguments.put("id", elementInfo.getResourceId()); + } + actionInfo.setArguments(arguments); + return actionInfo; + } + + private String extractElementAttr(String attrName, String elementStr) { + String[] attrs = elementStr.split(attrName + ": "); + if (attrs.length > 1 && !attrs[1].startsWith(",")) { + return attrs[1].split(",")[0]; + } + return ""; + } + + /** + * explore all path of page node + * + * @param pageNode + * @param nodePath + * @param action + * @param explorePaths + */ + public void explorePageNodePath(PageNode pageNode, String nodePath, String action, List explorePaths) { + if (pageNode.getChildPageNodeMap().isEmpty()) { + explorePaths.add(new PageNode.ExplorePath(nodePath + "_" + pageNode.getId(), action)); + return; + } + for (Map.Entry entry : pageNode.getChildPageNodeMap().entrySet()) { + explorePageNodePath(entry.getValue(), StringUtils.isEmpty(nodePath) ? String.valueOf(pageNode.getId()) : nodePath + "_" + pageNode.getId(), + StringUtils.isEmpty(action) ? String.valueOf(entry.getKey()) : action + "," + entry.getKey(), explorePaths); + } + } + + public abstract File generateCaseFile(PageNode pageNode, List explorePaths); + + public abstract File generateCaseFile(PageNode pageNode, PageNode.ExplorePath explorePaths, File caseFolder); +} diff --git a/center/src/main/java/com/microsoft/hydralab/center/service/generation/MaestroCaseGenerationService.java b/center/src/main/java/com/microsoft/hydralab/center/service/generation/MaestroCaseGenerationService.java new file mode 100644 index 000000000..cf22a7e71 --- /dev/null +++ b/center/src/main/java/com/microsoft/hydralab/center/service/generation/MaestroCaseGenerationService.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.center.service.generation; + +import com.microsoft.hydralab.center.util.CenterConstant; +import com.microsoft.hydralab.common.util.DateUtil; +import com.microsoft.hydralab.common.util.FileUtil; +import com.microsoft.hydralab.common.util.HydraLabRuntimeException; +import com.microsoft.hydralab.common.util.PageNode; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * @author zhoule + * @date 07/14/2023 + */ + +@Service +public class MaestroCaseGenerationService extends AbstractCaseGeneration { + /** + * generate maestro case files and zip them + * + * @param pageNode + * @param explorePaths + * @return + */ + @Override + public File generateCaseFile(PageNode pageNode, List explorePaths) { + // create temp folder to store case files + File tempFolder = new File(CenterConstant.CENTER_TEMP_FILE_DIR, DateUtil.fileNameDateFormat.format(new Date())); + if (!tempFolder.exists()) { + tempFolder.mkdirs(); + } + // generate case files + for (PageNode.ExplorePath explorePath : explorePaths) { + generateCaseFile(pageNode, explorePath, tempFolder); + } + if (tempFolder.listFiles().length == 0) { + return null; + } + // zip temp folder + File zipFile = new File(tempFolder.getParent() + "/" + tempFolder.getName() + ".zip"); + FileUtil.zipFile(tempFolder.getAbsolutePath(), zipFile.getAbsolutePath()); + FileUtil.deleteFile(tempFolder); + return zipFile; + } + + @Override + public File generateCaseFile(PageNode pageNode, PageNode.ExplorePath explorePath, File caseFolder) { + File maestroCaseFile = new File(caseFolder, explorePath.getPath() + ".yaml"); + String caseContent = buildConfigSection(pageNode.getPageName()); + caseContent += buildDelimiter(); + caseContent += buildCommandSection("launch", null); + String[] actionIds = explorePath.getActions().split(","); + PageNode pageNodeCopy = pageNode; + for (String actionId : actionIds) { + PageNode.ActionInfo action = pageNodeCopy.getActionInfoList().stream().filter(actionInfo -> actionInfo.getId() == Integer.parseInt(actionId)).findFirst().get(); + caseContent += buildCommandSection(action.getActionType(), action.getArguments()); + pageNodeCopy = pageNodeCopy.getChildPageNodeMap().get(Integer.parseInt(actionId)); + } + caseContent += buildCommandSection("stop", null); + FileUtil.writeToFile(caseContent, maestroCaseFile.getAbsolutePath()); + return maestroCaseFile; + } + + private String buildConfigSection(String appId) { + return "appId: " + appId + "\n"; + } + + private String buildDelimiter() { + return "---\n"; + } + + private String buildCommandSection(String actionType, Map arguments) { + String command = "-"; + switch (actionType) { + case "launch": + command = command + " launchApp\n"; + break; + case "click": + command = command + " tapOn:"; + if (arguments.size() == 0) { + throw new HydraLabRuntimeException("arguments is empty"); + } + if (arguments.containsKey("defaultValue")) { + command = command + " " + arguments.get("defaultValue") + "\n"; + break; + } + command = command + "\n"; + for (String key : arguments.keySet()) { + command = command + " " + key + ": \"" + arguments.get(key) + "\"\n"; + } + break; + case "stop": + command = command + " stopApp\n"; + break; + default: + throw new HydraLabRuntimeException("Unsupported action type: " + actionType); + } + return command; + } +} diff --git a/center/src/main/java/com/microsoft/hydralab/center/service/generation/T2CCaseGenerationService.java b/center/src/main/java/com/microsoft/hydralab/center/service/generation/T2CCaseGenerationService.java new file mode 100644 index 000000000..91f91d2c4 --- /dev/null +++ b/center/src/main/java/com/microsoft/hydralab/center/service/generation/T2CCaseGenerationService.java @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package com.microsoft.hydralab.center.service.generation; + +import com.microsoft.hydralab.common.util.PageNode; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.util.List; + +/** + * @author zhoule + * @date 07/21/2023 + */ + +@Service +public class T2CCaseGenerationService extends AbstractCaseGeneration { + @Override + public File generateCaseFile(PageNode pageNode, List explorePaths) { + return null; + } + + @Override + public File generateCaseFile(PageNode pageNode, PageNode.ExplorePath explorePaths, File caseFolder) { + return null; + } +} diff --git a/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java b/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java index 47a11800e..4ed2b43c1 100644 --- a/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java +++ b/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java @@ -1,5 +1,6 @@ package com.microsoft.hydralab.center.service; +import com.microsoft.hydralab.center.service.generation.MaestroCaseGenerationService; import com.microsoft.hydralab.center.test.BaseTest; import com.microsoft.hydralab.common.util.PageNode; import org.junit.jupiter.api.Assertions; @@ -13,17 +14,18 @@ class TestCaseGenerationServiceTest extends BaseTest { @Resource - CaseGenerationService caseGenerationService; + MaestroCaseGenerationService caseGenerationService; @Test void testParserXMLToPageNode() { PageNode rootNode = caseGenerationService.parserXMLToPageNode("src/test/resources/test_route_map.xml"); Assertions.assertNotNull(rootNode, "parser xml to page node failed"); rootNode.setPageName("com.microsoft.appmanager"); + System.out.println(rootNode); List explorePaths = new ArrayList<>(); - Assertions.assertEquals(explorePaths.size(), 16, "explore path size is not correct"); caseGenerationService.explorePageNodePath(rootNode, "", "", explorePaths); - File caseZipFile = caseGenerationService.generateMaestroCases(rootNode, explorePaths); + Assertions.assertEquals(explorePaths.size(), 16, "explore path size is not correct"); + File caseZipFile = caseGenerationService.generateCaseFile(rootNode, explorePaths); Assertions.assertTrue(caseZipFile.exists()); } } \ No newline at end of file From 2146fdb9ff44a7bd4b7c76d868ed27ef90a0c9f7 Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Wed, 26 Jul 2023 11:46:35 +0800 Subject: [PATCH 8/9] Update build.gradle --- center/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/center/build.gradle b/center/build.gradle index c9e4798d6..b73a69c93 100644 --- a/center/build.gradle +++ b/center/build.gradle @@ -27,8 +27,8 @@ tasks.withType(JavaCompile) { } dependencies { - implementation 'dev.langchain4j:langchain4j:0.11.0' - implementation 'dev.langchain4j:langchain4j-pinecone:0.11.0' + compileOnly 'dev.langchain4j:langchain4j:0.11.0' + compileOnly 'dev.langchain4j:langchain4j-pinecone:0.11.0' implementation 'org.dom4j:dom4j:2.1.4' testCompile 'org.mockito:mockito-core:3.12.4' testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: springBootWebVersion From af3ce99661f55a940eb03d09fd55a752a4b0129c Mon Sep 17 00:00:00 2001 From: Le Zhou <2428499107@qq.com> Date: Wed, 26 Jul 2023 17:32:39 +0800 Subject: [PATCH 9/9] add an api to download cases --- .../controller/TestDetailController.java | 55 ++++++++++++ react/src/component/TestReportView.jsx | 90 +++++++++++++------ 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/center/src/main/java/com/microsoft/hydralab/center/controller/TestDetailController.java b/center/src/main/java/com/microsoft/hydralab/center/controller/TestDetailController.java index 0cdbd1b4e..0ae79721e 100644 --- a/center/src/main/java/com/microsoft/hydralab/center/controller/TestDetailController.java +++ b/center/src/main/java/com/microsoft/hydralab/center/controller/TestDetailController.java @@ -8,6 +8,7 @@ import com.alibaba.fastjson.JSONObject; import com.microsoft.hydralab.center.service.StorageTokenManageService; import com.microsoft.hydralab.center.service.TestDataService; +import com.microsoft.hydralab.center.service.generation.MaestroCaseGenerationService; import com.microsoft.hydralab.common.entity.agent.Result; import com.microsoft.hydralab.common.entity.center.SysUser; import com.microsoft.hydralab.common.entity.common.AndroidTestUnit; @@ -26,9 +27,11 @@ import com.microsoft.hydralab.common.util.FileUtil; import com.microsoft.hydralab.common.util.HydraLabRuntimeException; import com.microsoft.hydralab.common.util.LogUtils; +import com.microsoft.hydralab.common.util.PageNode; import com.microsoft.hydralab.t2c.runner.T2CJsonGenerator; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Assertions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -47,11 +50,13 @@ import javax.servlet.http.HttpServletResponse; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import static com.microsoft.hydralab.center.util.CenterConstant.CENTER_TEMP_FILE_DIR; @@ -72,6 +77,8 @@ public class TestDetailController { AttachmentService attachmentService; @Resource StorageServiceClientProxy storageServiceClientProxy; + @Resource + MaestroCaseGenerationService maestroCaseGenerationService; /** * Authenticated USER: @@ -412,4 +419,52 @@ public Result generateT2CJsonFromSmartTest(@CurrentSecurityContext SysUs return Result.ok(t2cJson); } + @GetMapping(value = {"/api/test/generateMaestro/{fileId}"}, produces = MediaType.APPLICATION_JSON_VALUE) + public Result generateMaestroFromSmartTest(@CurrentSecurityContext SysUser requestor, + @PathVariable(value = "fileId") String fileId, + @RequestParam(value = "testRunId") String testRunId, + HttpServletResponse response) throws IOException { + if (requestor == null) { + return Result.error(HttpStatus.UNAUTHORIZED.value(), "unauthorized"); + } + + File graphZipFile = loadGraphFile(fileId); + File graphFile = new File(graphZipFile.getParentFile().getAbsolutePath(), Const.SmartTestConfig.GRAPH_FILE_NAME); + TestRun testRun = testDataService.findTestRunById(testRunId); + TestTask testTask = testDataService.getTestTaskDetail(testRun.getTestTaskId()); + + PageNode rootNode = maestroCaseGenerationService.parserXMLToPageNode(graphFile.getAbsolutePath()); + Assertions.assertNotNull(rootNode, "parser xml to page node failed"); + rootNode.setPageName(testTask.getPkgName()); + System.out.println(rootNode); + List explorePaths = new ArrayList<>(); + maestroCaseGenerationService.explorePageNodePath(rootNode, "", "", explorePaths); + File caseZipFile = maestroCaseGenerationService.generateCaseFile(rootNode, explorePaths); + + if (caseZipFile == null) { + return Result.error(HttpStatus.BAD_REQUEST.value(), "The file was not downloaded"); + } + try { + FileInputStream in = new FileInputStream(caseZipFile); + ServletOutputStream out = response.getOutputStream(); + response.setContentType("application/octet-stream;charset=UTF-8"); + response.setHeader("Content-Disposition", "attachment;filename=" + caseZipFile.getName()); + int len; + byte[] buffer = new byte[1024 * 10]; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + out.flush(); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + response.flushBuffer(); + caseZipFile.delete(); + } + + return Result.ok(); + } + } diff --git a/react/src/component/TestReportView.jsx b/react/src/component/TestReportView.jsx index dc59d8ed6..e88d98749 100644 --- a/react/src/component/TestReportView.jsx +++ b/react/src/component/TestReportView.jsx @@ -631,35 +631,69 @@ export default class TestReportView extends BaseView { generateJSON() { if (this.state.selectedPath.length === 0) { - this.setState({ - snackbarIsShown: true, - snackbarSeverity: "error", - snackbarMessage: "Please select a path!" - }) - return + axios({ + url: `/api/test/generateMaestro/` + this.state.graphFileId + "?testRunId=" + this.state.task.deviceTestResults[0].id, + method: 'GET', + responseType: 'blob' + }).then((res) => { + if (res.data.type.includes('application/json')) { + let reader = new FileReader() + reader.onload = function () { + let result = JSON.parse(reader.result) + if (result.code !== 200) { + this.setState({ + snackbarIsShown: true, + snackbarSeverity: "error", + snackbarMessage: "The file could not be downloaded" + }) + } + } + reader.readAsText(res.data) + } else { + const href = URL.createObjectURL(res.data); + const link = document.createElement('a'); + link.href = href; + const filename = res.headers["content-disposition"].replace('attachment;filename=', ''); + link.setAttribute('download', 'maestro_case_'+filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(href); + + if (res.data.code === 200) { + this.setState({ + snackbarIsShown: true, + snackbarSeverity: "success", + snackbarMessage: "Maestro case file downloaded" + }) + } + } + }).catch(this.snackBarError); + }else{ + axios({ + url: `/api/test/generateT2C/` + this.state.graphFileId + "?testRunId=" + this.state.task.deviceTestResults[0].id + "&path=" + this.state.selectedPath.join(','), + method: 'GET', + }).then((res) => { + var blob = new Blob([res.data.content]); + const href = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = href; + link.setAttribute('download', this.state.task.pkgName + '_t2c.json'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(href); + + if (res.data.code === 200) { + this.setState({ + snackbarIsShown: true, + snackbarSeverity: "success", + snackbarMessage: "T2C JSON file downloaded" + }) + } + }).catch(this.snackBarError); } - axios({ - url: `/api/test/generateT2C/` + this.state.graphFileId + "?testRunId=" + this.state.task.deviceTestResults[0].id + "&path=" + this.state.selectedPath.join(','), - method: 'GET', - }).then((res) => { - var blob = new Blob([res.data.content]); - const href = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = href; - link.setAttribute('download', this.state.task.pkgName + '_t2c.json'); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(href); - - if (res.data.code === 200) { - this.setState({ - snackbarIsShown: true, - snackbarSeverity: "success", - snackbarMessage: "T2C JSON file downloaded" - }) - } - }).catch(this.snackBarError); + } getDeviceLabel = (name) => {