Skip to content
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

Implement setTimeout/clearTimeout in Maestro's JavaScript Environment #2254

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
12 changes: 9 additions & 3 deletions maestro-client/src/main/java/maestro/js/GraalJsEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.graalvm.polyglot.Context
import org.graalvm.polyglot.Source
import org.graalvm.polyglot.Value
import org.graalvm.polyglot.proxy.ProxyObject
import org.graalvm.polyglot.proxy.ProxyExecutable
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
import java.util.logging.Handler
Expand All @@ -15,9 +16,7 @@ import kotlin.time.Duration.Companion.minutes

private val NULL_HANDLER = object : Handler() {
override fun publish(record: LogRecord?) {}

override fun flush() {}

override fun close() {}
}

Expand All @@ -32,18 +31,19 @@ class GraalJsEngine(
) : JsEngine {

private val openContexts = HashSet<Context>()

private val httpBinding = GraalJsHttp(httpClient)
private val outputBinding = HashMap<String, Any>()
private val maestroBinding = HashMap<String, Any?>()
private val envBinding = HashMap<String, String>()
private val timer = GraalJsTimer()

private var onLogMessage: (String) -> Unit = {}

private var platform = platform

override fun close() {
openContexts.forEach { it.close() }
timer.close()
}

override fun onLogMessage(callback: (String) -> Unit) {
Expand All @@ -62,6 +62,10 @@ class GraalJsEngine(
this.maestroBinding["copiedText"] = text
}

fun waitForActiveTimers() {
timer.waitForActiveTimers()
}

override fun evaluateScript(
script: String,
env: Map<String, String>,
Expand Down Expand Up @@ -96,6 +100,8 @@ class GraalJsEngine(
context.getBindings("js").putMember("http", httpBinding)
context.getBindings("js").putMember("output", ProxyObject.fromMap(outputBinding))
context.getBindings("js").putMember("maestro", ProxyObject.fromMap(maestroBinding))
context.getBindings("js").putMember("setTimeout", timer.setTimeout)
context.getBindings("js").putMember("clearTimeout", timer.clearTimeout)

maestroBinding["platform"] = platform

Expand Down
84 changes: 84 additions & 0 deletions maestro-client/src/main/java/maestro/js/GraalJsTimer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package maestro.js

import org.graalvm.polyglot.Value
import org.graalvm.polyglot.proxy.ProxyExecutable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

class GraalJsTimer {
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private val timeouts = ConcurrentHashMap<Int, ScheduledFuture<*>>()
private var timeoutCounter = 0
private var activeTimersCount = AtomicInteger(0)
private var activeTimers = CountDownLatch(0)

val setTimeout = ProxyExecutable { args ->
val callback = args[0] as Value
val delay = (args[1] as Value).asLong()
val restArgs = args.drop(2).toTypedArray()
setTimeout(callback, delay, *restArgs)
}

val clearTimeout = ProxyExecutable { args ->
val timeoutId = (args[0] as Value).asInt()
clearTimeout(timeoutId)
null
}

private fun setTimeout(callback: Value, delay: Long, vararg args: Any?): Int {
val timeoutId = timeoutCounter++

synchronized(this) {
if (activeTimersCount.getAndIncrement() == 0) {
activeTimers = CountDownLatch(1)
}
}

val future = scheduler.schedule({
try {
callback.executeVoid(*args)
} catch (e: Exception) {
e.printStackTrace()
} finally {
timeouts.remove(timeoutId)
if (activeTimersCount.decrementAndGet() == 0) {
activeTimers.countDown()
}
}
}, delay, TimeUnit.MILLISECONDS)

timeouts[timeoutId] = future
return timeoutId
}

private fun clearTimeout(timeoutId: Int) {
timeouts.remove(timeoutId)?.let { future ->
future.cancel(false)
if (activeTimersCount.decrementAndGet() == 0) {
activeTimers.countDown()
}
}
}

fun waitForActiveTimers(timeout: Long = 30_000) {
if (activeTimersCount.get() > 0) {
activeTimers.await(timeout, TimeUnit.MILLISECONDS)
}
}

fun close() {
scheduler.shutdown()
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow()
}
} catch (e: InterruptedException) {
scheduler.shutdownNow()
}
}
}
35 changes: 22 additions & 13 deletions maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt
Original file line number Diff line number Diff line change
Expand Up @@ -435,33 +435,42 @@ class Orchestra(

private fun evalScriptCommand(command: EvalScriptCommand): Boolean {
command.scriptString.evaluateScripts(jsEngine)

waitForTimersIfGraal()
// We do not actually know if there were any mutations, but we assume there were
return true
}

private fun runScriptCommand(command: RunScriptCommand): Boolean {
return if (evaluateCondition(command.condition, commandOptional = command.optional)) {
jsEngine.evaluateScript(
script = command.script,
env = command.env,
sourceName = command.sourceDescription,
runInSubScope = true,
)

// We do not actually know if there were any mutations, but we assume there were
true
} else {
if (!evaluateCondition(command.condition, command.optional)) {
throw CommandSkipped
}

jsEngine.evaluateScript(
script = command.script,
env = command.env,
sourceName = command.sourceDescription,
runInSubScope = true,
)

waitForTimersIfGraal()

// We do not actually know if there were any mutations, but we assume there were
return true
}

private fun waitForAnimationToEndCommand(command: WaitForAnimationToEndCommand): Boolean {
maestro.waitForAnimationToEnd(command.timeout)

return true
}

private fun waitForTimersIfGraal() {
val engine = jsEngine
if (engine is GraalJsEngine) {
engine.waitForActiveTimers()
}
}

private fun defineVariablesCommand(command: DefineVariablesCommand): Boolean {
command.env.forEach { (name, value) ->
jsEngine.putEnv(name, value)
Expand Down
87 changes: 87 additions & 0 deletions maestro-test/src/test/kotlin/maestro/test/GraalJsTimerTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package maestro.test

import com.google.common.truth.Truth.assertThat
import maestro.js.GraalJsEngine
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeEach

class GraalJsTimerTest {
private lateinit var engine: GraalJsEngine

@BeforeEach
fun setUp() {
engine = GraalJsEngine()
}

@Test
fun `setTimeout executes callback after delay`() {
engine.evaluateScript("""
setTimeout(() => {
output.executed = true;
}, 100);
""".trimIndent())
engine.waitForActiveTimers()

val result = engine.evaluateScript("output.executed")
assertThat(result.asBoolean()).isTrue()
}

@Test
fun `setTimeout with multiple timers executes all callbacks`() {
engine.evaluateScript("""
setTimeout(() => { output.first = true; }, 100);
setTimeout(() => { output.second = true; }, 200);
""".trimIndent())
engine.waitForActiveTimers()

val first = engine.evaluateScript("output.first")
val second = engine.evaluateScript("output.second")
assertThat(first.asBoolean()).isTrue()
assertThat(second.asBoolean()).isTrue()
}

@Test
fun `clearTimeout cancels timer execution`() {
engine.evaluateScript("""
output.executed = false;
const id = setTimeout(() => {
output.executed = true;
}, 1000);
clearTimeout(id);
""".trimIndent())
engine.waitForActiveTimers()

val result = engine.evaluateScript("output.executed")
assertThat(result.asBoolean()).isFalse()
}

@Test
fun `setTimeout passes arguments to callback`() {
engine.evaluateScript("""
setTimeout((a, b) => {
output.sum = a + b;
}, 100, 1, 2);
""".trimIndent())
engine.waitForActiveTimers()

val result = engine.evaluateScript("output.sum")
assertThat(result.asInt()).isEqualTo(3)
}

@Test
fun `setTimeout actually waits for specified duration`() {
val startTime = System.currentTimeMillis()
engine.evaluateScript("""
setTimeout(() => {
output.done = true;
}, 5000);
""".trimIndent())
engine.waitForActiveTimers()
val endTime = System.currentTimeMillis()
val elapsedTime = endTime - startTime

val result = engine.evaluateScript("output.done")
assertThat(result.asBoolean()).isTrue()
assertThat(elapsedTime).isAtLeast(5000L)
}
}
29 changes: 29 additions & 0 deletions maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3218,6 +3218,35 @@ class IntegrationTest {
)
}

@Test
fun `Case 120 - setTimeout executes after specified delay`() {
// Given
val commands = readCommands("120_set_time_out")
val driver = driver { }
val receivedLogs = mutableListOf<String>()

// When
Maestro(driver).use { maestro ->
orchestra(
maestro,
onCommandMetadataUpdate = { _, metadata ->
metadata.logMessages.forEach { log ->
receivedLogs.add(log)
}
}
).runFlow(commands)
}

// Then
assertThat(receivedLogs).containsExactly(
"Start",
"First timeout",
"Second timeout",
"Third timeout",
"End"
).inOrder()
}

private fun orchestra(
maestro: Maestro,
) = Orchestra(
Expand Down
3 changes: 3 additions & 0 deletions maestro-test/src/test/resources/120_set_time_out.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
setTimeout(() => {
console.log("First timeout");
}, 5000);
9 changes: 9 additions & 0 deletions maestro-test/src/test/resources/120_set_time_out.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
appId: com.other.app
jsEngine: graaljs
---
- evalScript: ${console.log("Start");}
- runScript:
file: 120_set_time_out.js
- evalScript: ${setTimeout(() => { console.log("Second timeout"); }, 1000);}
- evalScript: ${setTimeout(() => { console.log("Third timeout"); }, 2000);}
- evalScript: ${console.log("End");}
Loading