Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shell-over-http project caused some more files being created in this folder. Ignoring this entire folder is recommended anyway:

Do not commit the .kotlin directory to version control. For example, if you are using Git, add .kotlin to your project's .gitignore file.

7 changes: 7 additions & 0 deletions maestro/conventions/mark-successful.yml
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}
40 changes: 40 additions & 0 deletions maestro/record/scripts/android.js
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 👏

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm not sure, is the /sdcard/ folder always guaranteed to exist? Not sure if we should record in a documents folder or something like...


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
};
12 changes: 12 additions & 0 deletions maestro/record/start.yml
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)}
13 changes: 13 additions & 0 deletions maestro/record/stop.yml
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)}"
46 changes: 46 additions & 0 deletions maestro/shell/shell.js
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' };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 shell-over-http in the circleci job/fastlane lane before running the tests?

}
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
};
13 changes: 13 additions & 0 deletions maestro/test_paywall_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -33,3 +45,4 @@ onFlowStart:
- copyTextFrom:
id: "Original App User ID"
- evalScript: ${output.rcApiV1.revokeGooglePlaySubscription(maestro.copiedText, PRODUCT_ID)}
- runFlow: conventions/mark-successful.yml
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ include(":examples:web-purchase-redemption-sample")
include(":dokka-hide-internal")
include(":baselineprofile")
include(":test-apps:e2etests")
include(":shell-over-http")
9 changes: 9 additions & 0 deletions shell-over-http/build.gradle.kts
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")
}

196 changes: 196 additions & 0 deletions shell-over-http/src/main/kotlin/ShellServer.kt
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") + "\""
}