From f19da705c49bcc5c3a11ced2750b7be99e729fa8 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:37:51 +0100 Subject: [PATCH 1/2] Adds shell-over-http --- .gitignore | 4 +- settings.gradle.kts | 1 + shell-over-http/build.gradle.kts | 9 + .../src/main/kotlin/ShellServer.kt | 196 ++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 shell-over-http/build.gradle.kts create mode 100644 shell-over-http/src/main/kotlin/ShellServer.kt diff --git a/.gitignore b/.gitignore index 36c724ce00..3ea0884a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -137,5 +137,5 @@ purchases-android-snapshots # Make sure we share the same Detekt IntelliJ plugin settings !.idea/detekt.xml -# Kotlin error logs -.kotlin/errors/ +# Kotlin compiler +.kotlin diff --git a/settings.gradle.kts b/settings.gradle.kts index 3ea15190ac..f2c3a3aff8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,3 +66,4 @@ include(":examples:web-purchase-redemption-sample") include(":dokka-hide-internal") include(":baselineprofile") include(":test-apps:e2etests") +include(":shell-over-http") diff --git a/shell-over-http/build.gradle.kts b/shell-over-http/build.gradle.kts new file mode 100644 index 0000000000..0218f8f25e --- /dev/null +++ b/shell-over-http/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + application +} + +application { + mainClass.set("ShellServerKt") +} + diff --git a/shell-over-http/src/main/kotlin/ShellServer.kt b/shell-over-http/src/main/kotlin/ShellServer.kt new file mode 100644 index 0000000000..7f1f0ac870 --- /dev/null +++ b/shell-over-http/src/main/kotlin/ShellServer.kt @@ -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) { + 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= - Run synchronously") + println(" POST /start?cmd= - Start async, returns PID") + println(" POST /stop?pid= - Stop async process by PID") +} + +private data class RunningProcess( + val process: Process, + val stdout: ByteArrayOutputStream, + val stderr: ByteArrayOutputStream, +) + +private val runningProcesses = ConcurrentHashMap() + +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") + "\"" +} From 179e76733aa3d5dd0c412f724b245730758337f8 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:13:42 +0100 Subject: [PATCH 2/2] Adds recording infra --- maestro/conventions/mark-successful.yml | 7 ++++ maestro/record/scripts/android.js | 40 +++++++++++++++++++++ maestro/record/start.yml | 12 +++++++ maestro/record/stop.yml | 13 +++++++ maestro/shell/shell.js | 46 +++++++++++++++++++++++++ maestro/test_paywall_v2.yaml | 13 +++++++ 6 files changed, 131 insertions(+) create mode 100644 maestro/conventions/mark-successful.yml create mode 100644 maestro/record/scripts/android.js create mode 100644 maestro/record/start.yml create mode 100644 maestro/record/stop.yml create mode 100644 maestro/shell/shell.js diff --git a/maestro/conventions/mark-successful.yml b/maestro/conventions/mark-successful.yml new file mode 100644 index 0000000000..c3f91de64a --- /dev/null +++ b/maestro/conventions/mark-successful.yml @@ -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} diff --git a/maestro/record/scripts/android.js b/maestro/record/scripts/android.js new file mode 100644 index 0000000000..8930c47533 --- /dev/null +++ b/maestro/record/scripts/android.js @@ -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); + + 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 +}; diff --git a/maestro/record/start.yml b/maestro/record/start.yml new file mode 100644 index 0000000000..8cba141620 --- /dev/null +++ b/maestro/record/start.yml @@ -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)} diff --git a/maestro/record/stop.yml b/maestro/record/stop.yml new file mode 100644 index 0000000000..a1a845a36b --- /dev/null +++ b/maestro/record/stop.yml @@ -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)}" diff --git a/maestro/shell/shell.js b/maestro/shell/shell.js new file mode 100644 index 0000000000..0f370bd8d4 --- /dev/null +++ b/maestro/shell/shell.js @@ -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' }; + } + 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 +}; diff --git a/maestro/test_paywall_v2.yaml b/maestro/test_paywall_v2.yaml index c15c2efdae..5f6945ce4f 100644 --- a/maestro/test_paywall_v2.yaml +++ b/maestro/test_paywall_v2.yaml @@ -2,6 +2,11 @@ appId: com.revenuecat.automatedsdktests env: PRODUCT_ID: subscription_monthly onFlowStart: + - runFlow: + label: "Start screen recording" + file: record/start.yml + env: + filename: ${MAESTRO_FILENAME} - runScript: when: platform: Android @@ -14,6 +19,13 @@ onFlowStart: platform: Android label: "Log in with a Google account" file: google-login/google-login.yml +onFlowComplete: + - runFlow: + label: "Stop screen recording" + file: record/stop.yml + env: + filename: ${MAESTRO_FILENAME} + outputDir: ${RECORDING_OUTPUT_DIR} --- - launchApp: appId: com.revenuecat.automatedsdktests @@ -33,3 +45,4 @@ onFlowStart: - copyTextFrom: id: "Original App User ID" - evalScript: ${output.rcApiV1.revokeGooglePlaySubscription(maestro.copiedText, PRODUCT_ID)} +- runFlow: conventions/mark-successful.yml