MAUI UI Test Writer
dotnet/maui
Writes UI tests that reproduce a GitHub issue in .NET MAUI and keeps iterating until the tests actually fail, proving they catch the bug.
A skill your agent uses to choose the right idle-synchronization primitive in Compose UI tests — waitForIdle, awaitIdle, waitUntil(conditionDescription, timeoutMillis, condition)…
$ npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install skydoves/android-testing-skills synchronizing-with-idle --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/skydoves/android-testing-skills.git skills-src && mkdir -p .claude/skills && cp -r skills-src/compose/synchronization/synchronizing-with-idle .claude/skills/synchronizing-with-idle && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "synchronizing-with-idle" agent skill from https://github.com/skydoves/android-testing-skills/tree/main/compose/synchronization/synchronizing-with-idle into .claude/skills/synchronizing-with-idle/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "synchronizing-with-idle", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/skydoves/android-testing-skills/tree/main/compose/synchronization/synchronizing-with-idleType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install skydoves/android-testing-skills synchronizing-with-idle --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/skydoves/android-testing-skills.git skills-src && mkdir -p .agents/skills && cp -r skills-src/compose/synchronization/synchronizing-with-idle .agents/skills/synchronizing-with-idle && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "synchronizing-with-idle" agent skill from https://github.com/skydoves/android-testing-skills/tree/main/compose/synchronization/synchronizing-with-idle into .agents/skills/synchronizing-with-idle/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "synchronizing-with-idle", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install skydoves/android-testing-skills synchronizing-with-idle --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/skydoves/android-testing-skills.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/compose/synchronization/synchronizing-with-idle .cursor/skills/synchronizing-with-idle && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "synchronizing-with-idle" agent skill from https://github.com/skydoves/android-testing-skills/tree/main/compose/synchronization/synchronizing-with-idle into .cursor/skills/synchronizing-with-idle/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "synchronizing-with-idle", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/skydoves/android-testing-skills.git --path compose/synchronization/synchronizing-with-idle--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install skydoves/android-testing-skills synchronizing-with-idle --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/skydoves/android-testing-skills.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/compose/synchronization/synchronizing-with-idle .gemini/skills/synchronizing-with-idle && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "synchronizing-with-idle" agent skill from https://github.com/skydoves/android-testing-skills/tree/main/compose/synchronization/synchronizing-with-idle into .gemini/skills/synchronizing-with-idle/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "synchronizing-with-idle", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install skydoves/android-testing-skills synchronizing-with-idleInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/skydoves/android-testing-skills.git skills-src && mkdir -p .github/skills && cp -r skills-src/compose/synchronization/synchronizing-with-idle .github/skills/synchronizing-with-idle && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "synchronizing-with-idle" agent skill from https://github.com/skydoves/android-testing-skills/tree/main/compose/synchronization/synchronizing-with-idle into .github/skills/synchronizing-with-idle/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "synchronizing-with-idle", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install skydoves/android-testing-skills synchronizing-with-idle --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/skydoves/android-testing-skills.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/compose/synchronization/synchronizing-with-idle .opencode/skills/synchronizing-with-idle && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "synchronizing-with-idle" agent skill from https://github.com/skydoves/android-testing-skills/tree/main/compose/synchronization/synchronizing-with-idle into .opencode/skills/synchronizing-with-idle/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "synchronizing-with-idle", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
synchronizing-with-idleA skill your agent uses to choose the right idle-synchronization primitive in Compose UI tests — waitForIdle, awaitIdle, waitUntil(conditionDescription, timeoutMillis, condition)…
Synchronizing With Idle is an agent skill from skydoves/android-testing-skills. Use this skill to choose the right idle-synchronization primitive in Compose UI tests — waitForIdle, awaitIdle, waitUntil(conditionDescription, timeoutMillis, condition), waitUntilNodeCount, waitUntilExactlyOneExists, waitUntilAtLeastOneExists, waitUntilDoesNotExist, runOnIdle, runOnUiThread, runWhenIdle, awaitAndRunWhenIdle, hasPendingWork, and IdlingResource. Explains what "idle" means (no pending recomposition or draw, every IdlingResource isIdleNow), the wall-clock vs test-clock timeout split, the…
Its SKILL.md is about 5.5k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Testing & QA, covering Mobile testing and debugging and Failing and flaky tests. The repository describes itself as: ⚡️ A set of skills for Android testing: Compose UI, AndroidX Test, JVM unit tests, and ADB. The licence is Apache-2.0.
5 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit 8665ed5. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
No scripts in the folder and no shell commands in SKILL.md (its code samples are kotlin).
From the folder's file list and the shell code blocks in SKILL.md.
Links to these hosts (documentation or services it may open):
developer.android.comFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Synchronizing With Idle loads about 5.5k tokens when it runs. Until then it costs about 236 tokens; SKILL.md has 1,567 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from skydoves/android-testing-skills at commit 8665ed5, republished under its Apache-2.0 licence (© skydoves). 1,567 words, ~5,550 tokens.
.claude/skills/synchronizing-with-idle/SKILL.md (or your agent's skills folder).A Compose test that waits on the wrong primitive is the #1 source of flakiness. This skill enumerates every idle/wait API on ComposeTestRule and ComposeUiTest, locks down what "idle" actually means, and gives a decision matrix for choosing among them. Animation-specific waits live in ../testing-animations-deterministically/SKILL.md; the underlying clock semantics live in ../controlling-the-test-clock/SKILL.md.
Thread.sleep(2000) to wait for a screen, a snackbar, a navigation transition, or an IO-backed state.ViewModel posts state from a coroutine and the test needs to wait until the UI reflects it.IdlingResource, the Espresso bridge, runOnIdle vs runOnUiThread, or whether runWhenIdle is faster.waitUntil, waitUntilExactlyOneExists, waitUntilNodeCount, AndroidComposeUiTestTimeoutException, or IdlingPolicies.setMasterPolicyTimeout.state.firstVisibleItemIndex) and asks why it sometimes returns stale data.../testing-animations-deterministically/SKILL.md and mainClock.advanceTimeUntil.MainTestClock itself (frames, rounding, dispatchers) are unclear. Read ../controlling-the-test-clock/SKILL.md first.../../debug/printing-the-semantics-tree/SKILL.md and ../../finders/finding-nodes-by-tag-text-content/SKILL.md.../../interop/testing-with-espresso-interop/SKILL.md (sibling skill, separate scope).androidx.compose.ui:ui-test and androidx.compose.ui:ui-test-junit4 (or runComposeUiTest).ComposeContestTestRule from createComposeRule() (PREFERRED v2: androidx.compose.ui.test.junit4.v2.createComposeRule).waitUntilNodeCount/waitUntilExactlyOneExists/waitUntilAtLeastOneExists/waitUntilDoesNotExist: @OptIn(ExperimentalTestApi::class) on the test class or method.Three conditions, all true at once (compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:174-186):
withFrameNanos awaiters on the test frame clock.MainTestClock.kt:36-41).IdlingResources report isIdleNow == true. This is how non-Compose async work (an OkHttp call, a Room query) participates in synchronization.Compose's own work registers automatically through ComposeIdlingResource (compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt), which drains the recomposer + snapshot + frame-clock awaiters in a loop capped at 100 frames per call (line 109). A test that needs more than 100 frames to settle is doing too much per waitForIdle.
Every entry below is from ComposeUiTest.kt / ComposeTestRule.jvmAndAndroid.kt.
| API | Effect | Timeout source |
|---|---|---|
waitForIdle() | Blocks current thread until the three idle conditions hold. With autoAdvance = true, also auto-advances the clock to drain pending work. | Wall clock — IdlingPolicies.getMasterIdlingPolicy() |
awaitIdle() | suspend variant of waitForIdle. | Same |
waitUntil(conditionDescription, timeoutMillis = 1_000, condition) | Blocks until condition() returns true. Each iteration calls mainClock.advanceTimeByFrame() (when autoAdvance == true) AND Thread.sleep(10) wall clock (ComposeUiTest.android.kt:899-902). The timeout is measured against System.nanoTime(), so it expires on wall-clock time even though the test clock also advances. | Wall clock — per-call timeoutMillis |
waitUntilNodeCount(matcher, count, timeoutMillis = 1_000L) | @ExperimentalTestApi. waitUntil over onAllNodes(matcher).fetchSemanticsNodes(atLeastOneRootRequired = false).size == count (ComposeUiTest.kt:275-285). | Wall clock |
waitUntilExactlyOneExists(matcher, timeoutMillis = 1_000L) | @ExperimentalTestApi. Sugar for waitUntilNodeCount(matcher, 1, timeoutMillis). | Wall clock |
waitUntilAtLeastOneExists(matcher, timeoutMillis = 1_000L) | @ExperimentalTestApi. (ComposeUiTest.kt:298-305) | Wall clock |
waitUntilDoesNotExist(matcher, timeoutMillis = 1_000L) | @ExperimentalTestApi. Sugar for waitUntilNodeCount(matcher, 0, timeoutMillis). | Wall clock |
runOnIdle { … } | waitForIdle() then runOnUiThread { … }. The default for state mutations and reads. | Wall clock (the inner waitForIdle) |
runOnUiThread { … } | Posts a FutureTask via Instrumentation.runOnMainSync (AndroidSynchronization.android.kt). If already on the UI thread, runs in-place. Does NOT wait for idle. | None |
runWhenIdle { … } | waitForIdle() then runOnUiThread { … }, but suppresses the implicit waitForIdle triggered by node queries inside the lambda. Faster for assert-only blocks. MUST NOT mutate state inside. | Wall clock |
awaitAndRunWhenIdle { … } | suspend variant of runWhenIdle. | Wall clock |
hasPendingWork(): Boolean | Passive snapshot: are there awaiters on the main clock, snapshot changes, or recomposer pending work? Reliable only when autoAdvance = false (ComposeUiTest.kt:251-256). | None |
IdlingResource registration | rule.registerIdlingResource(myResource) / unregisterIdlingResource. | Wall clock |
| API | Source | Default | Override |
|---|---|---|---|
waitForIdle / awaitIdle | Wall clock — Espresso IdlingPolicies master timeout | 26 s | IdlingPolicies.setMasterPolicyTimeout(...) in @Before |
waitUntil(…) family | Wall clock | 1000 ms per call | per-call timeoutMillis |
MainTestClock.advanceTimeUntil(…) | Test clock | 1000 ms per call | per-call timeoutMillis |
runComposeUiTest(testTimeout = …) | Wall clock | 60.seconds | runComposeUiTest(testTimeout = 5.minutes) { … }; throws AndroidComposeUiTestTimeoutException |
Skydoves hot take #4: waitUntil timeouts are wall clock; advanceTimeUntil is test clock. Always prefer mainClock.advanceTimeUntil when the awaited condition is observable through Compose state. Reserve waitUntil for conditions outside Compose's snapshot system — a Job.isCompleted, a counter incremented from a LaunchedEffect, an external service.
RobolectricIdlingStrategy reads the same Espresso IdlingPolicies.getMasterIdlingPolicy(), so a global setMasterPolicyTimeout lift applies to host (Robolectric) and device tests alike.
Espresso has its own androidx.test.espresso.IdlingResource interface, which is not the same as Compose's androidx.compose.ui.test.IdlingResource. The bridge is EspressoLink (compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt):
Espresso (instrumentation tests)
│ withStrategy { … }
▼
EspressoLink ──implements──> androidx.test.espresso.IdlingResource
│
│ delegates isIdleNow() to ──> IdlingResourceRegistry
▼
IdlingResourceRegistry — Compose's registry
│
├── ComposeIdlingResource (recomposer + snapshot + frame-clock awaiters)
└── any rule.registerIdlingResource(...)The same registry is read by RobolectricIdlingStrategy for host tests. The developer registers a Compose IdlingResource; the framework does the bridging.
interface IdlingResource {
val isIdleNow: Boolean
fun getDiagnosticMessageIfBusy(): String? = null
}(From compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResource.kt:37-52.) Override getDiagnosticMessageIfBusy() to surface a useful message when a wait times out — it is appended to the timeout exception text.
Need to wait for… | Use
------------------------------------------|----------------------------------------------
A node count to stabilize | waitUntilNodeCount / waitUntilExactlyOneExists
A node to appear | waitUntilAtLeastOneExists
A node to disappear | waitUntilDoesNotExist
A Compose state condition | mainClock.advanceTimeUntil { state }
A non-Compose condition (Job, counter) | waitUntil(conditionDescription) { condition }
"Just settle the UI" | waitForIdle() (or runOnIdle for read+act)
External async work (HTTP, DB) | rule.registerIdlingResource(MyIdlingResource)
A paused-clock animation frame | mainClock.advanceTimeBy(...) -- different skill
A particular state.value to read safely | rule.runOnIdle { state.value }runOnIdle for state interactionReading or mutating Compose state from the test thread races the recomposer. The recomposer applies snapshot writes on the main thread; the test thread can observe a half-applied state. runOnIdle solves this by waiting for idle then dispatching to the UI thread:
val firstVisibleIndex = rule.runOnIdle { state.firstVisibleItemIndex }(skydoves hot take #5 — funnel state mutations through runOnIdle or runOnUiThread.)
runWhenIdle for read-only assertion blocksrule.runWhenIdle {
val node = rule.onNodeWithTag("counter").fetchSemanticsNode()
assertEquals("3", node.config[SemanticsProperties.Text].first().text)
val rect = node.boundsInRoot
// ... more reads ...
}Each node query inside a normal runOnIdle re-runs waitForIdle(). Inside runWhenIdle, those implicit waits are suppressed because the block already entered with idle witnessed. This matters for tests that step frames manually — node queries inside runOnIdle would call waitForIdle() repeatedly, which (under autoAdvance = true) auto-advances the clock and undoes the test's manual control. MUST NOT mutate state inside runWhenIdle.
waitUntilExactlyOneExists / friends for "wait until N nodes match"@OptIn(ExperimentalTestApi::class)
@Test fun snackbarAppears() {
rule.setContent { /* … triggers a snackbar after 200 ms … */ }
rule.onNodeWithTag("trigger").performClick()
rule.waitUntilExactlyOneExists(hasTestTag("snackbar"), timeoutMillis = 2_000)
rule.onNodeWithTag("snackbar").assertTextEquals("Saved")
}The default 1000 ms timeout (ComposeUiTest.kt:278, ComposeUiTest.kt:301, ComposeUiTest.kt:321, ComposeUiTest.kt:334) is often too tight for snackbars and bottom-sheet animations. Lift it explicitly per call.
waitUntil { … } only for non-Compose conditions// SnackbarHostTest.kt:77-88
val job = scope.launch {
hostState.showSnackbar("1")
Truth.assertThat(resultedInvocation).isEqualTo("1")
hostState.showSnackbar("2")
Truth.assertThat(resultedInvocation).isEqualTo("12")
hostState.showSnackbar("3")
Truth.assertThat(resultedInvocation).isEqualTo("123")
}
rule.waitUntil { job.isCompleted }Job.isCompleted is not Compose state; mainClock.advanceTimeUntil { job.isCompleted } would loop and time out because no amount of clock advancement makes the Job complete on its own.
IdlingResource for genuine external async workclass HttpIdlingResource(private val client: OkHttpClient) : IdlingResource {
override val isIdleNow: Boolean
get() = client.dispatcher.runningCallsCount() == 0
override fun getDiagnosticMessageIfBusy(): String? =
"${client.dispatcher.runningCallsCount()} HTTP calls still in flight"
}
@Before fun setUp() { rule.registerIdlingResource(HttpIdlingResource(client)) }
@After fun tearDown() { rule.unregisterIdlingResource(HttpIdlingResource(client)) }After registration, every waitForIdle (and the implicit waitForIdle inside every node query) will block until isIdleNow == true. MUST keep isIdleNow lightweight — it is called from the main thread (IdlingResource.kt:38-46).
Thread.sleep to wait for a node// WRONG
@Test fun successScreen() {
rule.setContent { App() }
rule.onNodeWithTag("login").performClick()
Thread.sleep(2_000)
rule.onNodeWithTag("dashboard").assertExists()
}
// WRONG because: Thread.sleep is unrelated to Compose's synchronization. It either over-
// waits (slowing the test) or under-waits (flake when CI is slow). Compose's own
// IdlingResource is already wired; just wait on the node directly.// RIGHT
@OptIn(ExperimentalTestApi::class)
@Test fun successScreen() {
rule.setContent { App() }
rule.onNodeWithTag("login").performClick()
rule.waitUntilExactlyOneExists(hasTestTag("dashboard"), timeoutMillis = 2_000)
rule.onNodeWithTag("dashboard").assertExists()
}waitUntil for a Compose-state-observable condition// WRONG
rule.waitUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
// WRONG because: timeout is measured in wall clock and each iteration burns ~10 ms
// of real time on Thread.sleep(10) (ComposeUiTest.android.kt:899-902). For a Compose
// state condition, advanceTimeUntil drives the test clock and is deterministic.// RIGHT
rule.mainClock.advanceTimeUntil(timeoutMillis = 5_000) { state.value == Phase.Done }(skydoves hot take #4.)
// WRONG
val i = state.firstVisibleItemIndex
assertEquals(2, i)
// WRONG because: the test thread races the recomposer. The read may observe a half-applied
// snapshot, especially right after a click or scroll. Compose's docs are explicit: state
// reads from outside the UI thread are not synchronized.// RIGHT
val i = rule.runOnIdle { state.firstVisibleItemIndex }
assertEquals(2, i)(skydoves hot take #5.)
hasPendingWork() — passive check, paused-clock onlyrule.mainClock.autoAdvance = false
rule.setContent { /* … */ }
rule.mainClock.advanceTimeByFrame()
rule.runOnUiThread { trigger = true }
assertTrue(rule.hasPendingWork()) // recomposition queued, no frame yet
rule.mainClock.advanceTimeByFrame()
assertFalse(rule.hasPendingWork()) // frame appliedFrom ComposeUiTest.kt:251-256:
If
autoAdvanceistrue, the testing framework continuously processes pending work. In that scenario, calling this method acts as a momentary snapshot and will generally returnfalse. It may briefly returntrueif work is queued but the framework hasn't auto-advanced yet, making the result fleeting and unreliable for driving test logic.
MUST NOT drive test logic off hasPendingWork() while autoAdvance == true.
@Before fun setUp() {
IdlingPolicies.setMasterPolicyTimeout(60, TimeUnit.SECONDS)
}Apply when waitForIdle (not waitUntil) times out on CI but passes locally. Both EspressoLink and RobolectricIdlingStrategy honor this policy.
mainClock.advanceTimeUntil { state } over rule.waitUntil { state } whenever the awaited condition is a Compose-state read (skydoves hot take #4).runOnIdle { … } or runOnUiThread { … }. Direct state.value reads from the test thread race the recomposer (skydoves hot take #5).runOnUiThread (not runOnIdle) for state mutations under a paused clock (autoAdvance = false); runOnIdle's implicit waitForIdle is wrong for that mode. See ../testing-animations-deterministically/SKILL.md.runWhenIdle { … } for assert-only blocks that do many node reads — it suppresses the redundant per-query waitForIdle. MUST NOT mutate state inside.waitUntilExactlyOneExists / waitUntilAtLeastOneExists / waitUntilDoesNotExist over hand-rolled waitUntil { onAllNodes(...).fetchSemanticsNodes().isNotEmpty() }. They are clearer and cite a conditionDescription in the timeout message.hasPendingWork() as actionable while autoAdvance == true. It is a passive snapshot; reliable only with the clock paused (ComposeUiTest.kt:251-256).Thread.sleep to wait for state to settle. Replace with waitUntil*, mainClock.advanceTimeUntil, or an IdlingResource (skydoves hot take #7). The one legitimate Thread.sleep is on the RenderThread for ripple/screenshot tests.IdlingResource per genuine external async source (HTTP, DB, an external counter), registered in @Before and unregistered in @After. Override getDiagnosticMessageIfBusy() so timeout messages are useful.IdlingPolicies.setMasterPolicyTimeout(...) in @Before for CI flakes due to slow agents — uniformly lifts both Espresso and Robolectric.Thread.sleep exists in any test method except the documented RenderThread/screenshot exceptions.runOnIdle { … } (search: grep -nP 'state\.\w+' src/androidTest).waitUntil { state.value … } for a Compose-state condition has been migrated to mainClock.advanceTimeUntil { state.value … }.waitUntilExactlyOneExists / waitUntilAtLeastOneExists / waitUntilDoesNotExist rather than a hand-rolled waitUntil over fetchSemanticsNodes().IdlingResource, registered in @Before and unregistered in @After.hasPendingWork() while autoAdvance == true.AndroidComposeUiTestTimeoutException or ComposeTimeoutException flake.IdlingPolicies reference: https://developer.android.com/reference/androidx/test/espresso/IdlingPoliciescompose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:174-260 — waitForIdle, awaitIdle, waitUntil, runOnIdle, runOnUiThread, runWhenIdle, awaitAndRunWhenIdle, hasPendingWork.compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:275-335 — the experimental waitUntilNodeCount / waitUntilExactlyOneExists / waitUntilAtLeastOneExists / waitUntilDoesNotExist extensions.compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResource.kt — the public IdlingResource interface.compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResourceRegistry.jvmAndAndroid.kt — the registry that aggregates user-registered resources + Compose's own.compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt — Compose's automatically-registered resource; 100-frame cap at line 109.compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt — the androidx.test.espresso.IdlingResource bridge.compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:885-908 — waitUntil's 10 ms wall-clock poll loop.compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidSynchronization.android.kt — runOnUiThread posts a FutureTask via Instrumentation.runOnMainSync.compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SnackbarHostTest.kt:77-88 — canonical rule.waitUntil { job.isCompleted }.../controlling-the-test-clock/SKILL.md — MainTestClock, frame model, advanceTimeUntil.../testing-animations-deterministically/SKILL.md — paused-clock animation tests.../../patterns/structuring-a-compose-test/SKILL.md — JUnit lifecycle around idle waits.../../debug/printing-the-semantics-tree/SKILL.md — when "node not found" turns out to be a finder issue, not an async issue.© skydoves, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in compose/synchronization/synchronizing-with-idle of skydoves/android-testing-skills.
Open the folder on GitHubat commit 8665ed5
Synchronizing With Idle next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Synchronizing With Idle this skillskydoves/android-testing-skills | 333 | — | ~5.5k | Automated safety check: Pass | Apache-2.0 | |
| MAUI UI Test Writerdotnet/maui | 23k | — | ~3k | Automated safety check: Pass | MIT | |
| Mobile App Testingsecondsky/claude-skills | 227 | — | ~643 | Automated safety check: Pass | MIT | |
| iOS Testing Patternsconorluddy/xclaude-plugin | 183 | — | ~4.5k | Automated safety check: Pass | MIT | |
| BrowserStack Live Testinghandsontable/handsontable | 22k | — | ~950 | Automated safety check: Pass | Custom licence | |
| iOS Accessibility Testingconorluddy/xclaude-plugin | 183 | — | ~5k | Automated safety check: Pass | MIT |
dotnet/maui
Writes UI tests that reproduce a GitHub issue in .NET MAUI and keeps iterating until the tests actually fail, proving they catch the bug.
secondsky/claude-skills
Mobile app testing with unit tests, UI automation, performance testing.
conorluddy/xclaude-plugin
XCTest and XCUITest execution workflows and flaky test detection patterns
handsontable/handsontable
Opens a live BrowserStack session on a real Android, iOS or desktop browser for a local or public URL, tunneling localhost through Cloudflare when needed.
conorluddy/xclaude-plugin
Guides WCAG 2.1 and VoiceOver accessibility testing for iOS apps, working from the accessibility tree and not from screenshots.
alipay/SoloPi
Drives Android devices through SoloPi's typed command line to record, replay and verify app behavior, with device pools and signed on-device decision models.
skydoves/android-testing-skills
A skill your agent uses to verify Compose layout measurements from a UI test using assertWidthIsEqualTo, assertHeightIsEqualTo, assertWidthIsAtLeast, assertHeightIsAtLeast…
skydoves/android-testing-skills
A skill your agent uses to verify a Compose semantics node's properties from a UI test using assertExists, assertDoesNotExist, assertIsDisplayed, assertIsNotDisplayed, assertIsDeactivated…
skydoves/android-testing-skills
A skill your agent uses to render every Jetpack Compose @Preview as a screenshot on a real Android device or emulator and publish a browsable HTML catalog from CI.
skydoves/android-testing-skills
A skill your agent uses to capture visual artefacts from a device for test failures, golden image generation, QA repro, and demo videos.
skydoves/android-testing-skills
A skill your agent uses to pick the correct Compose UI test entry point.
skydoves/android-testing-skills
A skill your agent uses to pick which behaviors to cover in an Android test suite using Google's five-category state vocabulary plus the explicit "what NOT to test" list from…
Categories
A skill your agent uses to choose the right idle-synchronization primitive in Compose UI tests — waitForIdle, awaitIdle, waitUntil(conditionDescription, timeoutMillis, condition)…. Synchronizing With Idle is an agent skill from skydoves/android-testing-skills. Use this skill to choose the right idle-synchronization primitive in Compose UI tests — waitForIdle, awaitIdle, waitUntil(conditionDescription, timeoutMillis, condition), waitUntilNodeCount, waitUntilExactlyOneExists, waitUntilAtLeastOneExists, waitUntilDoesNotExist, runOnIdle, runOnUiThread, runWhenIdle, awaitAndRunWhenIdle, hasPendingWork, and IdlingResource.
Synchronizing With Idle fits situations like: choose the right idle-synchronization primitive in Compose UI tests — waitForIdle; waitUntil(conditionDescription; waitUntilNodeCount; waitUntilExactlyOneExists.
Run `npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a claude-code`. Or copy the skill folder (compose/synchronization/synchronizing-with-idle in skydoves/android-testing-skills) into .claude/skills/synchronizing-with-idle in your project. Claude Code loads it when a task matches its description.
Run `npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a codex`. Or copy the skill folder (compose/synchronization/synchronizing-with-idle in skydoves/android-testing-skills) into .agents/skills/synchronizing-with-idle in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add skydoves/android-testing-skills --skill synchronizing-with-idle -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/synchronizing-with-idle, .gemini/skills/synchronizing-with-idle, .github/skills/synchronizing-with-idle and .opencode/skills/synchronizing-with-idle in your project.
SKILL.md names no scripts, command-line tools or credentials: Synchronizing With Idle is instructions for the agent only.
SKILL.md names 1 domain. As links in the text: developer.android.com. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Synchronizing With Idle is published under the Apache-2.0 licence (declared in SKILL.md). It allows redistribution, so the full SKILL.md is shown on this page.
About 5.5k tokens (SKILL.md is roughly 22k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Synchronizing With Idle: MAUI UI Test Writer (dotnet/maui, 23k stars), Mobile App Testing (secondsky/claude-skills, 227 stars), iOS Testing Patterns (conorluddy/xclaude-plugin, 183 stars) and BrowserStack Live Testing (handsontable/handsontable, 22k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
skydoves (a GitHub user) maintains it in skydoves/android-testing-skills, which has 333 GitHub stars. The repository holds 50 skills in this directory. The repository was last updated on May 25, 2026.
Source: skydoves/android-testing-skills on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.