-
Notifications
You must be signed in to change notification settings - Fork 105
Adds infra to support recording Maestro tests #2876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: maestro-play-store-purchase
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # A convention to mark a flow as successful, to work around the fact that | ||
| # Maestro doesn't have onFlowSucceeded/onFlowFailed hooks. | ||
| # This should be called at the end of all regular steps in the flow. | ||
|
|
||
| appId: com.revenuecat.automatedsdktests | ||
| --- | ||
| - evalScript: ${output.flowSucceeded = true} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| function normalizeFilename(filename) { | ||
| return filename.replace(/\.[^.]+$/, '') + '.mp4'; | ||
| } | ||
|
|
||
| function start(filename) { | ||
| var name = normalizeFilename(filename); | ||
|
|
||
| var pid = output.shell.start('adb shell screenrecord /sdcard/' + name); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is nice to be able to run full commands through the webserver 👏
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm I'm not sure, is the |
||
|
|
||
| if (!output.recordingPids) { | ||
| output.recordingPids = {}; | ||
| } | ||
| output.recordingPids[name] = pid; | ||
|
|
||
| return pid; | ||
| } | ||
|
|
||
| function stop(filename, outputDir) { | ||
| var name = normalizeFilename(filename); | ||
|
|
||
| var pid = output.recordingPids ? output.recordingPids[name] : null; | ||
|
|
||
| if (pid && pid !== -1) { | ||
| output.shell.stop(pid); | ||
| delete output.recordingPids[name]; | ||
| } | ||
|
|
||
| if (outputDir) { | ||
| var destination = outputDir + '/' + name; | ||
| output.shell.run('adb pull /sdcard/' + name + ' ' + destination); | ||
| } | ||
|
|
||
| // Clean up the recording file from the emulator | ||
| output.shell.run('adb shell rm -f /sdcard/' + name); | ||
| } | ||
|
|
||
| output.record = { | ||
| start: start, | ||
| stop: stop | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| appId: com.revenuecat.automatedsdktests | ||
| env: | ||
| filename: ${filename} | ||
| --- | ||
| - runScript: | ||
| file: ../shell/shell.js | ||
|
|
||
| - runScript: | ||
| when: | ||
| platform: Android | ||
| file: scripts/android.js | ||
| - evalScript: ${output.record.start(filename)} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| appId: com.revenuecat.automatedsdktests | ||
| env: | ||
| filename: ${filename} | ||
| outputDir: ${outputDir} | ||
| --- | ||
| - runScript: | ||
| file: ../shell/shell.js | ||
| - runScript: | ||
| when: | ||
| platform: Android | ||
| file: scripts/android.js | ||
| # We only pull the video if the flow failed. | ||
| - evalScript: "${output.record.stop(filename, output.flowSucceeded ? null : outputDir)}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| var SHELL_SERVER_URL = 'http://localhost:8080'; | ||
|
|
||
| function isServerRunning() { | ||
| try { | ||
| var response = http.get(SHELL_SERVER_URL + '/run?cmd=true', { | ||
| connectTimeout: 500, | ||
| readTimeout: 500 | ||
| }); | ||
| return response.status === 200; | ||
| } catch (e) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function run(cmd) { | ||
| if (!isServerRunning()) { | ||
| return { exitCode: -1, stdout: '', stderr: 'shell-over-http server not running' }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm probably missing something... but do we need to start the server for |
||
| } | ||
| var response = http.get(SHELL_SERVER_URL + '/run?cmd=' + encodeURIComponent(cmd)); | ||
| var result = JSON.parse(response.body); | ||
| return result; | ||
| } | ||
|
|
||
| function start(cmd) { | ||
| if (!isServerRunning()) { | ||
| return -1; | ||
| } | ||
| var response = http.post(SHELL_SERVER_URL + '/start?cmd=' + encodeURIComponent(cmd), { body: '' }); | ||
| var pid = parseInt(response.body, 10); | ||
| return pid; | ||
| } | ||
|
|
||
| function stop(pid) { | ||
| if (!isServerRunning()) { | ||
| return { exitCode: -1, stdout: '', stderr: 'shell-over-http server not running' }; | ||
| } | ||
| var response = http.post(SHELL_SERVER_URL + '/stop?pid=' + pid, { body: '' }); | ||
| var result = JSON.parse(response.body); | ||
| return result; | ||
| } | ||
|
|
||
| output.shell = { | ||
| run: run, | ||
| start: start, | ||
| stop: stop | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| plugins { | ||
| alias(libs.plugins.kotlin.jvm) | ||
| application | ||
| } | ||
|
|
||
| application { | ||
| mainClass.set("ShellServerKt") | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| import com.sun.net.httpserver.HttpExchange | ||
| import com.sun.net.httpserver.HttpServer | ||
| import java.io.ByteArrayOutputStream | ||
| import java.net.InetSocketAddress | ||
| import java.net.URLDecoder | ||
| import java.util.concurrent.ConcurrentHashMap | ||
| import java.util.concurrent.Executors | ||
|
|
||
| fun main(args: Array<String>) { | ||
| val port = args.find { it.startsWith("--port=") } | ||
| ?.substringAfter("=") | ||
| ?.toIntOrNull() | ||
| ?: 8080 | ||
|
|
||
| with(HttpServer.create(InetSocketAddress(port), 0)) { | ||
| executor = Executors.newCachedThreadPool() | ||
|
|
||
| createContext("/run") { exchange -> | ||
| with(exchange) { | ||
| if (requestMethod != "GET") { | ||
| sendError(405, ErrorResponse("Method not allowed")) | ||
| return@createContext | ||
| } | ||
| val cmd = getQueryParam("cmd") | ||
| if (cmd == null) { | ||
| sendError(400, ErrorResponse("Missing 'cmd' parameter")) | ||
| return@createContext | ||
| } | ||
| sendJson(executeSync(cmd).toJson()) | ||
| } | ||
| } | ||
|
|
||
| createContext("/start") { exchange -> | ||
| with(exchange) { | ||
| if (requestMethod != "POST") { | ||
| sendError(405, ErrorResponse("Method not allowed")) | ||
| return@createContext | ||
| } | ||
| val cmd = getQueryParam("cmd") | ||
| if (cmd == null) { | ||
| sendError(400, ErrorResponse("Missing 'cmd' parameter")) | ||
| return@createContext | ||
| } | ||
| val pid = startAsync(cmd) | ||
| sendText(pid.toString()) | ||
| } | ||
| } | ||
|
|
||
| createContext("/stop") { exchange -> | ||
| with(exchange) { | ||
| if (requestMethod != "POST") { | ||
| sendError(405, ErrorResponse("Method not allowed")) | ||
| return@createContext | ||
| } | ||
| val pidStr = getQueryParam("pid") | ||
| if (pidStr == null) { | ||
| sendError(400, ErrorResponse("Missing 'pid' parameter")) | ||
| return@createContext | ||
| } | ||
| val pid = pidStr.toLongOrNull() | ||
| if (pid == null) { | ||
| sendError(400, ErrorResponse("Invalid 'pid' parameter: $pidStr")) | ||
| return@createContext | ||
| } | ||
| val result = stopProcess(pid) | ||
| if (result == null) { | ||
| sendError(404, ErrorResponse("Process not found: $pid")) | ||
| return@createContext | ||
| } | ||
| sendJson(result.toJson()) | ||
| } | ||
| } | ||
|
|
||
| start() | ||
| } | ||
| println("Shell server running on http://localhost:$port") | ||
| println("Endpoints:") | ||
| println(" GET /run?cmd=<command> - Run synchronously") | ||
| println(" POST /start?cmd=<command> - Start async, returns PID") | ||
| println(" POST /stop?pid=<pid> - Stop async process by PID") | ||
| } | ||
|
|
||
| private data class RunningProcess( | ||
| val process: Process, | ||
| val stdout: ByteArrayOutputStream, | ||
| val stderr: ByteArrayOutputStream, | ||
| ) | ||
|
|
||
| private val runningProcesses = ConcurrentHashMap<Long, RunningProcess>() | ||
|
|
||
| private data class CommandResponse( | ||
| val exitCode: Int, | ||
| val stdout: String, | ||
| val stderr: String, | ||
| ) { | ||
| fun toJson(): String = """{"exitCode":$exitCode,"stdout":${stdout.escapeJson()},"stderr":${stderr.escapeJson()}}""" | ||
| } | ||
|
|
||
| private data class ErrorResponse( | ||
| val error: String, | ||
| ) { | ||
| fun toJson(): String = """{"error":${error.escapeJson()}}""" | ||
| } | ||
|
|
||
| private fun executeSync(cmd: String): CommandResponse { | ||
| val process = ProcessBuilder("sh", "-c", cmd) | ||
| .start() | ||
| val stdout = process.inputStream.bufferedReader().readText() | ||
| val stderr = process.errorStream.bufferedReader().readText() | ||
| val exitCode = process.waitFor() | ||
| return CommandResponse(exitCode, stdout, stderr) | ||
| } | ||
|
|
||
| private fun startAsync(cmd: String): Long { | ||
| val stdout = ByteArrayOutputStream() | ||
| val stderr = ByteArrayOutputStream() | ||
|
|
||
| val process = ProcessBuilder("sh", "-c", cmd) | ||
| .start() | ||
|
|
||
| val pid = process.pid() | ||
|
|
||
| Thread { | ||
| process.inputStream.copyTo(stdout) | ||
| }.start() | ||
| Thread { | ||
| process.errorStream.copyTo(stderr) | ||
| }.start() | ||
|
|
||
| runningProcesses[pid] = RunningProcess(process, stdout, stderr) | ||
| return pid | ||
| } | ||
|
|
||
| private fun stopProcess(pid: Long): CommandResponse? { | ||
| val running = runningProcesses.remove(pid) ?: return null | ||
| running.process.destroy() | ||
| val exitCode = running.process.waitFor() | ||
| return CommandResponse(exitCode, running.stdout.toString(), running.stderr.toString()) | ||
| } | ||
|
|
||
| private fun HttpExchange.getQueryParam(name: String): String? { | ||
| val query = requestURI.query ?: return null | ||
| return query.split("&") | ||
| .map { it.split("=", limit = 2) } | ||
| .find { it[0] == name } | ||
| ?.getOrNull(1) | ||
| ?.let { URLDecoder.decode(it, "UTF-8") } | ||
| } | ||
|
|
||
| private fun HttpExchange.sendText(text: String) { | ||
| try { | ||
| requestBody.readBytes() | ||
|
|
||
| val bytes = text.toByteArray(Charsets.UTF_8) | ||
| responseHeaders["Content-Type"] = "text/plain" | ||
| sendResponseHeaders(200, bytes.size.toLong()) | ||
| responseBody.write(bytes) | ||
| } finally { | ||
| close() | ||
| } | ||
| } | ||
|
|
||
| private fun HttpExchange.sendJson(json: String) { | ||
| try { | ||
| requestBody.readBytes() | ||
|
|
||
| val bytes = json.toByteArray(Charsets.UTF_8) | ||
| responseHeaders["Content-Type"] = "application/json" | ||
| sendResponseHeaders(200, bytes.size.toLong()) | ||
| responseBody.write(bytes) | ||
| } finally { | ||
| close() | ||
| } | ||
| } | ||
|
|
||
| private fun HttpExchange.sendError(code: Int, error: ErrorResponse) { | ||
| try { | ||
| requestBody.readBytes() | ||
|
|
||
| val bytes = error.toJson().toByteArray(Charsets.UTF_8) | ||
| responseHeaders["Content-Type"] = "application/json" | ||
| sendResponseHeaders(code, bytes.size.toLong()) | ||
| responseBody.write(bytes) | ||
| } finally { | ||
| close() | ||
| } | ||
| } | ||
|
|
||
| private fun String.escapeJson(): String { | ||
| return "\"" + this | ||
| .replace("\\", "\\\\") | ||
| .replace("\"", "\\\"") | ||
| .replace("\n", "\\n") | ||
| .replace("\r", "\\r") | ||
| .replace("\t", "\\t") + "\"" | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
shell-over-httpproject caused some more files being created in this folder. Ignoring this entire folder is recommended anyway: