Skip to content

Commit

Permalink
Block UiController#loopMainThreadUntilIdle on registered idling res…
Browse files Browse the repository at this point in the history
…ources being idle.

When using the paused looper collect the registered idling resources and wait for them to become idle before returning from `loopMainThreadUntilIdle`. Because Robolectric runs on the same thread as the main looper we need to continually loop the main looper until all idling resources transition to idle state.

To do this we'll first drain the looper of currently scheduled tasks and then collect all of the idling resources that are not reporting idle. While this list is not empty (we need to loop as one idling resource becoming idle may cause another idling resource to become not idle, we need to observe them all idle at once) wait on the message queue to receive new messages up to the error timeout.

Issue: #4807
PiperOrigin-RevId: 436334339
  • Loading branch information
Googler authored and hoisie committed Mar 22, 2022
1 parent c278347 commit 614744d
Show file tree
Hide file tree
Showing 8 changed files with 533 additions and 4 deletions.
2 changes: 1 addition & 1 deletion gradle.properties
Expand Up @@ -7,7 +7,7 @@ errorproneJavacVersion=9+181-r4173-1

# AndroidX test versions
axtVersion=1.4.0
espressoVersion=3.5.0-alpha04
espressoVersion=3.5.0-alpha05
axtJunitVersion=1.1.3

# AndroidX versions
Expand Down
@@ -0,0 +1,181 @@
package org.robolectric.integrationtests.axt;

import static androidx.test.espresso.Espresso.onIdle;
import static com.google.common.truth.Truth.assertThat;

import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import androidx.test.espresso.IdlingRegistry;
import androidx.test.espresso.IdlingResource;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/** Test Espresso IdlingResource support. */
@RunWith(AndroidJUnit4.class)
public final class EspressoIdlingResourceTest {
private final IdlingRegistry idlingRegistry = IdlingRegistry.getInstance();

private ExecutorService executor;

@Before
public void setup() {
executor = Executors.newSingleThreadExecutor();
}

@After
public void teardown() {
for (IdlingResource resource : idlingRegistry.getResources()) {
idlingRegistry.unregister(resource);
}
for (Looper looper : idlingRegistry.getLoopers()) {
idlingRegistry.unregisterLooperAsIdlingResource(looper);
}
executor.shutdown();
}

@Test
public void onIdle_idlingResourceIsIdle_doesntBlock() {
AtomicBoolean didCheckIdle = new AtomicBoolean();
idlingRegistry.register(
new NamedIdleResource("Test", /* isIdle= */ true) {
@Override
public boolean isIdleNow() {
didCheckIdle.set(true);
return super.isIdleNow();
}
});

onIdle();

assertThat(didCheckIdle.get()).isTrue();
}

@SuppressWarnings("FutureReturnValueIgnored")
@Test
public void onIdle_postToMainThread() {
idlingRegistry.register(
new NamedIdleResource("Test", /* isIdle= */ false) {
boolean submitted;

@Override
public boolean isIdleNow() {
if (!submitted) {
submitted = true;
executor.submit(this::postToMainLooper);
}
return super.isIdleNow();
}

void postToMainLooper() {
new Handler(Looper.getMainLooper()).post(() -> setIdle(true));
}
});

onIdle();
}

@SuppressWarnings("FutureReturnValueIgnored")
@Test
public void onIdle_cooperativeResources() {
NamedIdleResource a = new NamedIdleResource("A", /* isIdle= */ true);
NamedIdleResource b = new NamedIdleResource("B", /* isIdle= */ false);
NamedIdleResource c = new NamedIdleResource("C", /* isIdle= */ false);
idlingRegistry.register(a, b);
executor.submit(
() -> {
a.setIdle(false);
b.setIdle(true);
c.setIdle(false);
executor.submit(
() -> {
a.setIdle(true);
b.setIdle(false);
c.setIdle(false);
executor.submit(
() -> {
a.setIdle(true);
b.setIdle(true);
c.setIdle(true);
});
});
});

onIdle();

assertThat(a.isIdleNow()).isTrue();
assertThat(b.isIdleNow()).isTrue();
assertThat(c.isIdleNow()).isTrue();
}

@SuppressWarnings("FutureReturnValueIgnored")
@Test
public void onIdle_looperIsIdle() throws Exception {
HandlerThread handlerThread = new HandlerThread("Test");
try {
handlerThread.start();
Handler handler = new Handler(handlerThread.getLooper());
CountDownLatch handlerStarted = new CountDownLatch(1);
CountDownLatch releaseHandler = new CountDownLatch(1);
handler.post(
() -> {
handlerStarted.countDown();
try {
releaseHandler.await();
} catch (InterruptedException e) {
// ignore
}
});
handlerStarted.await();
idlingRegistry.registerLooperAsIdlingResource(handlerThread.getLooper());

executor.submit(releaseHandler::countDown);
onIdle();

// onIdle should have blocked on the looper waiting on the release latch
assertThat(releaseHandler.getCount()).isEqualTo(0);
} finally {
handlerThread.quit();
}
}

private static class NamedIdleResource implements IdlingResource {
final String name;
final AtomicBoolean isIdle;
ResourceCallback callback;

NamedIdleResource(String name, boolean isIdle) {
this.name = name;
this.isIdle = new AtomicBoolean(isIdle);
}

@Override
public String getName() {
return name;
}

@Override
public boolean isIdleNow() {
return isIdle.get();
}

void setIdle(boolean isIdle) {
this.isIdle.set(isIdle);
if (isIdle && callback != null) {
callback.onTransitionToIdle();
}
}

@Override
public void registerIdleTransitionCallback(ResourceCallback callback) {
this.callback = callback;
}
}
}
1 change: 1 addition & 0 deletions robolectric/build.gradle
Expand Up @@ -44,6 +44,7 @@ dependencies {
compileOnly AndroidSdk.MAX_SDK.coordinates
compileOnly "junit:junit:${junitVersion}"
implementation "androidx.test:monitor:$axtVersion"
implementation "androidx.test.espresso:espresso-idling-resource:3.4.0"

testImplementation "junit:junit:${junitVersion}"
testImplementation "com.google.truth:truth:${truthVersion}"
Expand Down
@@ -0,0 +1,24 @@
package org.robolectric.android.internal;

import static com.google.common.base.Preconditions.checkNotNull;

import com.google.common.annotations.Beta;
import java.util.List;
import java.util.Locale;

/**
* Timeout exception thrown when idling resources are not idle for longer than the configured
* timeout.
*
* <p>See {@link androidx.test.espresso.IdlingResourceTimeoutException}.
*
* <p>Note: This API may be removed in the future in favor of using espresso's exception directly.
*/
@Beta
public final class IdlingResourceTimeoutException extends RuntimeException {
public IdlingResourceTimeoutException(List<String> resourceNames) {
super(
String.format(
Locale.ROOT, "Wait for %s to become idle timed out", checkNotNull(resourceNames)));
}
}

0 comments on commit 614744d

Please sign in to comment.