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/build.gradle b/center/build.gradle index c2f4c4172..b73a69c93 100644 --- a/center/build.gradle +++ b/center/build.gradle @@ -27,6 +27,9 @@ tasks.withType(JavaCompile) { } dependencies { + 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 testCompile 'me.paulschwarz:spring-dotenv:2.3.0' 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/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/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/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/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 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 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..4ed2b43c1 --- /dev/null +++ b/center/src/test/java/com/microsoft/hydralab/center/service/TestCaseGenerationServiceTest.java @@ -0,0 +1,31 @@ +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; +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 { + + @Resource + 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<>(); + caseGenerationService.explorePageNodePath(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 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/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 78cabdffb..451c6fc21 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 @@ -328,6 +328,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/PageNode.java b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java new file mode 100644 index 000000000..65ef1633b --- /dev/null +++ b/common/src/main/java/com/microsoft/hydralab/common/util/PageNode.java @@ -0,0 +1,57 @@ +// 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; + String pageName; + List actionInfoList = new ArrayList<>(); + // key: action id, value: child page node + Map childPageNodeMap = new HashMap<>(); + + @Data + public static class ElementInfo { + int index; + String className; + String text; + boolean clickable; + String resourceId; + } + + @Data + public static class ActionInfo { + int id; + ElementInfo testElement; + String actionType; + String driverId; + + String description; + 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; + } + } +} 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 d62a2fef3..829f9abf3 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"] } 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) => {