diff --git a/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt b/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt index 30dc310617..3e78e3527e 100644 --- a/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt +++ b/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt @@ -217,7 +217,7 @@ class CloudCommand : Callable { PrintUtils.message("Evaluating workspace...") WorkspaceExecutionPlanner .plan( - input = flowsFile.toPath().toAbsolutePath(), + input = setOf(flowsFile.toPath().toAbsolutePath()), includeTags = includeTags, excludeTags = excludeTags, config = configFile?.toPath()?.toAbsolutePath(), diff --git a/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt b/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt index c39edd6883..01e6fba438 100644 --- a/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt +++ b/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt @@ -59,6 +59,7 @@ import java.util.concurrent.CountDownLatch import kotlin.io.path.absolutePathString import kotlin.system.exitProcess import kotlin.time.Duration.Companion.seconds +import maestro.utils.isSingleFile import maestro.orchestra.util.Env.withDefaultEnvVars @CommandLine.Command( @@ -76,8 +77,8 @@ class TestCommand : Callable { @CommandLine.ParentCommand private val parent: App? = null - @CommandLine.Parameters - private lateinit var flowFile: File + @CommandLine.Parameters(description = ["One or more flow files or folders containing flow files"]) + private lateinit var flowFiles: Set @Option( names = ["--config"], @@ -159,8 +160,8 @@ class TestCommand : Callable { private val currentActiveDevices = ConcurrentSet() private fun isWebFlow(): Boolean { - if (!flowFile.isDirectory) { - val config = YamlCommandReader.readConfig(flowFile.toPath()) + if (flowFiles.isSingleFile) { + val config = YamlCommandReader.readConfig(flowFiles.first().toPath()) return Regex("http(s?)://").containsMatchIn(config.appId) } @@ -189,7 +190,7 @@ class TestCommand : Callable { val executionPlan = try { WorkspaceExecutionPlanner.plan( - input = flowFile.toPath().toAbsolutePath(), + input = flowFiles.map { it.toPath().toAbsolutePath() }.toSet(), includeTags = includeTags, excludeTags = excludeTags, config = configFile?.toPath()?.toAbsolutePath(), @@ -198,10 +199,6 @@ class TestCommand : Callable { throw CliError(e.message) } - env = env - .withInjectedShellEnvVars() - .withDefaultEnvVars(flowFile) - val debugOutputPath = TestDebugReporter.getDebugOutputPath() return handleSessions(debugOutputPath, executionPlan) @@ -336,12 +333,12 @@ class TestCommand : Callable { val maestro = session.maestro val device = session.device - if (flowFile.isDirectory || format != ReportFormat.NOOP) { + if (flowFiles.isSingleFile.not() || format != ReportFormat.NOOP) { // Run multiple flows if (continuous) { val error = if (format != ReportFormat.NOOP) "Format can not be different from NOOP in continuous mode. Passed format is $format." - else "Continuous mode is not supported for directories. $flowFile is a directory" + else "Continuous mode is only supported in case of a single flow file. ${flowFiles.joinToString(", ") { it.absolutePath } } has more that a single flow file." throw CommandLine.ParameterException(commandSpec.commandLine(), error) } @@ -363,6 +360,10 @@ class TestCommand : Callable { return@newSession Triple(suiteResult.passedCount, suiteResult.totalTests, suiteResult) } else { // Run a single flow + val flowFile = flowFiles.first() + env = env + .withInjectedShellEnvVars() + .withDefaultEnvVars(flowFile) if (continuous) { if (!flattenDebugOutput) { @@ -375,7 +376,12 @@ class TestCommand : Callable { if (DisableAnsiMixin.ansiEnabled && parent?.verbose == false) AnsiResultView() else PlainTextResultView() val resultSingle = TestRunner.runSingle( - maestro, device, flowFile, env, resultView, debugOutputPath + maestro, + device, + flowFile, + env, + resultView, + debugOutputPath ) if (resultSingle == 1) { printExitDebugMessage() diff --git a/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt b/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt index 7d6f5f1197..68f9721913 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt @@ -28,6 +28,8 @@ import java.io.File import java.nio.file.Path import kotlin.system.measureTimeMillis import kotlin.time.Duration.Companion.seconds +import maestro.orchestra.util.Env.withDefaultEnvVars +import maestro.orchestra.util.Env.withInjectedShellEnvVars /** * Similar to [TestRunner], but: @@ -65,7 +67,11 @@ class TestSuiteInteractor( // first run sequence of flows if present val flowSequence = executionPlan.sequence for (flow in flowSequence?.flows ?: emptyList()) { - val (result, aiOutput) = runFlow(flow.toFile(), env, maestro, debugOutputPath) + val flowFile = flow.toFile() + val updatedEnv = env + .withInjectedShellEnvVars() + .withDefaultEnvVars(flowFile) + val (result, aiOutput) = runFlow(flowFile, updatedEnv, maestro, debugOutputPath) flowResults.add(result) aiOutputs.add(aiOutput) diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/workspace/WorkspaceExecutionPlanner.kt b/maestro-orchestra/src/main/java/maestro/orchestra/workspace/WorkspaceExecutionPlanner.kt index f3a74de730..48b14574f0 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/workspace/WorkspaceExecutionPlanner.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/workspace/WorkspaceExecutionPlanner.kt @@ -8,86 +8,83 @@ import maestro.orchestra.yaml.YamlCommandReader import org.slf4j.LoggerFactory import java.nio.file.Files import java.nio.file.Path -import kotlin.io.path.absolute -import kotlin.io.path.absolutePathString -import kotlin.io.path.exists -import kotlin.io.path.isRegularFile -import kotlin.io.path.name -import kotlin.io.path.notExists -import kotlin.io.path.pathString +import kotlin.io.path.* import kotlin.streams.toList +import maestro.utils.isRegularFile object WorkspaceExecutionPlanner { private val logger = LoggerFactory.getLogger(WorkspaceExecutionPlanner::class.java) fun plan( - input: Path, + input: Set, includeTags: List, excludeTags: List, config: Path?, ): ExecutionPlan { - logger.info("start planning execution") - - if (input.notExists()) { - throw ValidationError( - """ - Flow path does not exist: ${input.absolutePathString()} - """.trimIndent() - ) + if (input.any { it.notExists() }) { + throw ValidationError(""" + Flow path does not exist: ${input.find { it.notExists() }?.absolutePathString()} + """.trimIndent()) } - if (input.isRegularFile()) { - validateFlowFile(input) + if (input.isRegularFile) { + validateFlowFile(input.first()) return ExecutionPlan( - flowsToRun = listOf(input), + flowsToRun = input.toList(), sequence = FlowSequence(emptyList()), ) } // retrieve all Flow files - val unfilteredFlowFiles = Files.walk(input).filter { isFlowFile(it) }.toList() - if (unfilteredFlowFiles.isEmpty()) { - throw ValidationError( - """ - Flow directory does not contain any Flow files: ${input.absolutePathString()} - """.trimIndent() - ) + val (files, directories) = input.partition { it.isRegularFile() } + + val flowFiles = files.filter { isFlowFile(it) } + val flowFilesInDirs: List = directories.flatMap { dir -> Files + .walk(dir) + .filter { isFlowFile(it) } + .toList() + } + if (flowFilesInDirs.isEmpty() && flowFiles.isEmpty()) { + throw ValidationError(""" + Flow directories do not contain any Flow files: ${directories.joinToString(", ") { it.absolutePathString() }} + """.trimIndent()) } // Filter flows based on flows config - val workspaceConfig = if (config != null) { - YamlCommandReader.readWorkspaceConfig(config.absolute()) - } else { - findConfigFile(input) + + val workspaceConfig = + if (config != null) YamlCommandReader.readWorkspaceConfig(config.absolute()) + else directories.firstNotNullOfOrNull { findConfigFile(it) } ?.let { YamlCommandReader.readWorkspaceConfig(it) } ?: WorkspaceConfig() - } val globs = workspaceConfig.flows ?: listOf("*") - val matchers = globs - .map { - input.fileSystem.getPathMatcher("glob:${input.pathString}/$it") - } + val matchers = globs.flatMap { glob -> + directories.map { it.fileSystem.getPathMatcher("glob:${it.pathString}/$glob") } + } - val unsortedFlowFiles = unfilteredFlowFiles - .filter { path -> matchers.any { matcher -> matcher.matches(path) } } - .toList() + val unsortedFlowFiles = flowFiles + flowFilesInDirs.filter { path -> + matchers.any { matcher -> matcher.matches(path) } + }.toList() if (unsortedFlowFiles.isEmpty()) { if ("*" == globs.singleOrNull()) { - throw ValidationError( - """ - Top-level directory does not contain any Flows: ${input.absolutePathString()} + val message = """ + Top-level directories do not contain any Flows: ${directories.joinToString(", ") { it.absolutePathString() }} To configure Maestro to run Flows in subdirectories, check out the following resources: * https://maestro.mobile.dev/cli/test-suites-and-reports#inclusion-patterns * https://blog.mobile.dev/maestro-best-practices-structuring-your-test-suite-54ec390c5c82 """.trimIndent() - ) + throw ValidationError(message) } else { - throw ValidationError("Flow inclusion pattern(s) did not match any Flow files:\n${toYamlListString(globs)}") + val message = """ + |Flow inclusion pattern(s) did not match any Flow files: + |${toYamlListString(globs)} + """.trimMargin() + throw ValidationError(message) } } @@ -105,17 +102,20 @@ object WorkspaceExecutionPlanner { val tags = config?.tags ?: emptyList() (allIncludeTags.isEmpty() || tags.any(allIncludeTags::contains)) - && (allExcludeTags.isEmpty() || !tags.any(allExcludeTags::contains)) + && (allExcludeTags.isEmpty() || !tags.any(allExcludeTags::contains)) } if (allFlows.isEmpty()) { - throw ValidationError( - "Include / Exclude tags did not match any Flows:\n\nInclude Tags:\n${ - toYamlListString( - allIncludeTags - ) - }\n\nExclude Tags:\n${toYamlListString(allExcludeTags)}" - ) + val message = """ + |Include / Exclude tags did not match any Flows: + | + |Include Tags: + |${toYamlListString(allIncludeTags)} + | + |Exclude Tags: + |${toYamlListString(allExcludeTags)} + """.trimMargin() + throw ValidationError(message) } // Handle sequential execution diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/workspace/YamlCommandsPathValidator.kt b/maestro-orchestra/src/main/java/maestro/orchestra/workspace/YamlCommandsPathValidator.kt index 2a17e2c739..0937e7bcba 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/workspace/YamlCommandsPathValidator.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/workspace/YamlCommandsPathValidator.kt @@ -7,7 +7,7 @@ import java.nio.file.Paths object YamlCommandsPathValidator { - fun validatePathsExistInWorkspace(input: Path, flowFile: Path, pathStrings: List) { + fun validatePathsExistInWorkspace(input: Set, flowFile: Path, pathStrings: List) { pathStrings.forEach { val exists = validateInsideWorkspace(input, it) if (!exists) { @@ -16,12 +16,8 @@ object YamlCommandsPathValidator { } } - private fun validateInsideWorkspace(workspace: Path, pathString: String): Boolean { - val mediaPath = workspace.resolve(workspace.fileSystem.getPath(pathString)) - val exists = Files.walk(workspace).anyMatch { path -> path.fileName == mediaPath.fileName } - if (!exists) { - return false - } - return true + private fun validateInsideWorkspace(workspace: Set, pathString: String): Boolean { + val mediaPath = workspace.firstNotNullOfOrNull { it.resolve(it.fileSystem.getPath(pathString)) } + return workspace.any { Files.walk(it).anyMatch { path -> path.fileName == mediaPath?.fileName } } } -} \ No newline at end of file +} diff --git a/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerErrorsTest.kt b/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerErrorsTest.kt index 57cc9c1b68..8a268a371f 100644 --- a/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerErrorsTest.kt +++ b/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerErrorsTest.kt @@ -49,7 +49,7 @@ internal class WorkspaceExecutionPlannerErrorsTest { try { val inputPath = singleFlowFilePath?.let { workspacePath.resolve(it) } ?: workspacePath WorkspaceExecutionPlanner.plan( - input = inputPath, + input = setOf(inputPath), includeTags = includeTags, excludeTags = excludeTags, config = null, diff --git a/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerTest.kt b/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerTest.kt index 791611efee..f66d1cf3e6 100644 --- a/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerTest.kt +++ b/maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerTest.kt @@ -11,7 +11,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `000 - Individual file`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/000_individual_file/flow.yaml"), + input = paths("/workspaces/000_individual_file/flow.yaml"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -27,7 +27,27 @@ internal class WorkspaceExecutionPlannerTest { internal fun `001 - Simple workspace`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/001_simple"), + input = paths("/workspaces/001_simple"), + includeTags = listOf(), + excludeTags = listOf(), + config = null, + ) + + // Then + assertThat(plan.flowsToRun).containsExactly( + path("/workspaces/001_simple/flowA.yaml"), + path("/workspaces/001_simple/flowB.yaml"), + ) + } + + @Test + internal fun `001 - Multiple files`() { + // When + val plan = WorkspaceExecutionPlanner.plan( + input = paths( + "/workspaces/001_simple/flowA.yaml", + "/workspaces/001_simple/flowB.yaml" + ), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -44,7 +64,27 @@ internal class WorkspaceExecutionPlannerTest { internal fun `002 - Workspace with subflows`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/002_subflows"), + input = paths("/workspaces/002_subflows"), + includeTags = listOf(), + excludeTags = listOf(), + config = null, + ) + + // Then + assertThat(plan.flowsToRun).containsExactly( + path("/workspaces/002_subflows/flowA.yaml"), + path("/workspaces/002_subflows/flowB.yaml"), + ) + } + + @Test + internal fun `002 - Multiple folders`() { + // When + val plan = WorkspaceExecutionPlanner.plan( + input = paths( + "/workspaces/001_simple", + "/workspaces/002_subflows" + ), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -52,8 +92,36 @@ internal class WorkspaceExecutionPlannerTest { // Then assertThat(plan.flowsToRun).containsExactly( + path("/workspaces/001_simple/flowA.yaml"), + path("/workspaces/001_simple/flowB.yaml"), + path("/workspaces/002_subflows/flowA.yaml"), + path("/workspaces/002_subflows/flowB.yaml"), + ) + } + + @Test + internal fun `002 - Multiple files and folders`() { + // When + val plan = WorkspaceExecutionPlanner.plan( + input = paths( + "/workspaces/000_individual_file/flow.yaml", + "/workspaces/001_simple", + "/workspaces/002_subflows", + "/workspaces/003_include_tags/flowC.yaml", + ), + includeTags = listOf(), + excludeTags = listOf(), + config = null, + ) + + // Then + assertThat(plan.flowsToRun).containsExactly( + path("/workspaces/000_individual_file/flow.yaml"), + path("/workspaces/001_simple/flowA.yaml"), + path("/workspaces/001_simple/flowB.yaml"), path("/workspaces/002_subflows/flowA.yaml"), path("/workspaces/002_subflows/flowB.yaml"), + path("/workspaces/003_include_tags/flowC.yaml"), ) } @@ -61,7 +129,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `003 - Include tags`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/003_include_tags"), + input = paths("/workspaces/003_include_tags"), includeTags = listOf("included"), excludeTags = listOf(), config = null, @@ -77,7 +145,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `004 - Exclude tags`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/004_exclude_tags"), + input = paths("/workspaces/004_exclude_tags"), includeTags = listOf(), excludeTags = listOf("excluded"), config = null, @@ -94,7 +162,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `005 - Custom include pattern`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/005_custom_include_pattern"), + input = paths("/workspaces/005_custom_include_pattern"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -111,7 +179,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `006 - Include subfolders`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/006_include_subfolders"), + input = paths("/workspaces/006_include_subfolders"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -130,7 +198,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `007 - Empty config`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/007_empty_config"), + input = paths("/workspaces/007_empty_config"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -147,7 +215,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `008 - Literal pattern`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/008_literal_pattern"), + input = paths("/workspaces/008_literal_pattern"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -163,7 +231,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `009 - Custom fields in config`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/009_custom_config_fields"), + input = paths("/workspaces/009_custom_config_fields"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -180,7 +248,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `010 - Global include tags`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/010_global_include_tags"), + input = paths("/workspaces/010_global_include_tags"), includeTags = listOf("featureB"), excludeTags = listOf(), config = null, @@ -198,7 +266,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `011 - Global exclude tags`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/011_global_exclude_tags"), + input = paths("/workspaces/011_global_exclude_tags"), includeTags = listOf(), excludeTags = listOf("featureA"), config = null, @@ -216,7 +284,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `012 - Deterministic order for local tests`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/012_local_deterministic_order"), + input = paths("/workspaces/012_local_deterministic_order"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -234,7 +302,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `013 - Execution order is respected`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/013_execution_order"), + input = paths("/workspaces/013_execution_order"), includeTags = listOf(), excludeTags = listOf(), config = null, @@ -247,7 +315,7 @@ internal class WorkspaceExecutionPlannerTest { // Then assertThat(plan.sequence).isNotNull() - assertThat(plan.sequence!!.flows).containsExactly( + assertThat(plan.sequence.flows).containsExactly( path("/workspaces/013_execution_order/flowB.yaml"), path("/workspaces/013_execution_order/flowCWithCustomName.yaml"), path("/workspaces/013_execution_order/flowD.yaml"), @@ -258,7 +326,7 @@ internal class WorkspaceExecutionPlannerTest { internal fun `014 - Config not null`() { // When val plan = WorkspaceExecutionPlanner.plan( - input = path("/workspaces/014_config_not_null"), + input = paths("/workspaces/014_config_not_null"), includeTags = listOf(), excludeTags = listOf(), config = path("/workspaces/014_config_not_null/config/another_config.yaml"), @@ -270,8 +338,11 @@ internal class WorkspaceExecutionPlannerTest { ) } - private fun path(pathStr: String): Path { - return Paths.get(WorkspaceExecutionPlannerTest::class.java.getResource(pathStr).toURI()) + private fun path(path: String): Path? { + val clazz = WorkspaceExecutionPlannerTest::class.java + val resource = clazz.getResource(path)?.toURI() + return resource?.let { Paths.get(it) } } + private fun paths(vararg paths: String): Set = paths.mapNotNull(::path).toSet() } diff --git a/maestro-orchestra/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/error.txt b/maestro-orchestra/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/error.txt index cf7e3e4b99..227af2096c 100644 --- a/maestro-orchestra/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/error.txt +++ b/maestro-orchestra/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/error.txt @@ -1 +1 @@ -Flow directory does not contain any Flow files: {PROJECT_DIR}/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/workspace \ No newline at end of file +Flow directories do not contain any Flow files: {PROJECT_DIR}/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/workspace \ No newline at end of file diff --git a/maestro-orchestra/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/error.txt b/maestro-orchestra/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/error.txt index 7846403c01..9f837339c4 100644 --- a/maestro-orchestra/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/error.txt +++ b/maestro-orchestra/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/error.txt @@ -1,4 +1,4 @@ -Top-level directory does not contain any Flows: {PROJECT_DIR}/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/workspace +Top-level directories do not contain any Flows: {PROJECT_DIR}/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/workspace To configure Maestro to run Flows in subdirectories, check out the following resources: * https://maestro.mobile.dev/cli/test-suites-and-reports#inclusion-patterns * https://blog.mobile.dev/maestro-best-practices-structuring-your-test-suite-54ec390c5c82 \ No newline at end of file diff --git a/maestro-utils/src/main/kotlin/Collections.kt b/maestro-utils/src/main/kotlin/Collections.kt new file mode 100644 index 0000000000..d94eb202aa --- /dev/null +++ b/maestro-utils/src/main/kotlin/Collections.kt @@ -0,0 +1,11 @@ +package maestro.utils + +import java.io.File +import java.nio.file.Path +import kotlin.io.path.isRegularFile + +val Collection.isSingleFile get() = + size == 1 && first().isDirectory().not() + +val Collection.isRegularFile get() = + size == 1 && first().isRegularFile()