Injecting Mouse and Keyboard — performMouseInput, performKeyInput, performMultiModalInput
Mouse and keyboard input are first-class modalities in Compose tests. Each has its own injection scope and entry point, plus a shared performMultiModalInput that combines all of them inside one batched gesture (touch, mouse, key, rotary, trackpad, indirect pointer). Coordinates remain node-local — the same rules as touch — and the entire injection state survives across perform.*Input blocks on the same node.
When to use this skill
- The test exercises a desktop or Compose Multiplatform UI that responds to clicks, hover, right-click, scroll wheel, or modifier keys.
- The test must drive a keyboard shortcut (e.g.
Ctrl+S, Shift+Tab, arrow-key navigation).
- The test must verify hover-only behaviour: tooltip pop, color change on
Modifier.hoverable, mouse-only ripple state.
- The test simulates a drag-and-drop with a mouse pointer.
- The test must combine modalities — for example "hover at (x, y) then click and hold while pressing
Shift".
When NOT to use this skill
- The interaction is a finger tap or drag — use
../injecting-touch-gestures/SKILL.md. Mouse is not interchangeable with touch on Android device tests.
- A simple
performClick() already routes through the right modality per platform — use ../clicking-and-scrolling/SKILL.md.
- The test types into a
TextField — use ../entering-text/SKILL.md. Key input here is for shortcuts, not text entry.
- The test waits for a hover-driven animation to settle — pause the clock first per
../../synchronization/testing-animations-deterministically/SKILL.md.
Prerequisites
androidx.compose.ui:ui-test-junit4 (or ui-test for runComposeUiTest) configured per ../../setup/configuring-test-dependencies/SKILL.md.
- For mouse hover events on Android: API level that supports hover (typically API 23+) — Robolectric host tests work but check
@Config(minSdk = …).
- For key input that targets a focusable node: the node must be focusable (e.g. wrapped in
Modifier.focusable() or Modifier.focusRequester(...)). Otherwise key events route to the focused root.
Workflow
Mouse: performMouseInput
Open a mouse scope — performMouseInput(block: MouseInjectionScope.() -> Unit) (Actions.kt:448-461). Like touch, events are batched and flushed when the block returns; recomposition cannot interleave inside the block.
Use node-local coordinates. (0, 0) is the node's top-left, identical to TouchInjectionScope. MouseInjectionScope extends InjectionScope so center, topLeft, bottomRight, percentOffset(...) are all available (InjectionScope.kt:34-206).
Pick the right helper. All in MouseInjectionScope.kt:
| Helper |
Default |
What it does |
click(position = center, button = MouseButton.Primary) |
primary click at center |
press → 60 ms wait → release |
rightClick(position = center) |
secondary click at center |
shorthand for click(position, MouseButton.Secondary) |
doubleClick(position = center, button = Primary) |
midway between min/max double-tap window |
click(); advanceEventTime(delay); click() |
tripleClick(position = center, button = Primary) |
same delays as double |
three sequential clicks |
longClick(position = center, button = Primary) |
longPressTimeoutMillis + 100 ms hold |
press, hold, release |
animateMoveTo(position, durationMillis = 300) |
300 ms |
streams move events along a linear path |
animateMoveBy(delta, durationMillis = 300) |
relative form |
— |
animateMoveAlong(curve, durationMillis = 300) |
arbitrary curve |
— |
dragAndDrop(start, end, button = Primary, durationMillis = 300) |
primary drag |
updatePointerTo(start); press; animateMoveTo(end); release |
smoothScroll(scrollAmount, durationMillis = 300, scrollWheel = Vertical) |
vertical |
streams scroll events |
Drop to low-level events when needed:
press(button: MouseButton = Primary) / release(button: MouseButton = Primary).
moveTo(position, delayMillis = eventPeriodMillis) / moveBy(delta, delayMillis = eventPeriodMillis).
updatePointerTo(position) / updatePointerBy(delta) — adjust position without sending an event.
enter(position) / exit(position) — explicit hover-enter / hover-exit.
scroll(delta, scrollWheel = Vertical) — single wheel tick.
cancel(delayMillis) — emit an ACTION_CANCEL.
currentPosition: Offset — last known mouse position.
Reference MouseButton and ScrollWheel from Mouse.kt:
MouseButton.Primary, MouseButton.Secondary, MouseButton.Tertiary — expect value class MouseButton.
ScrollWheel.Vertical, ScrollWheel.Horizontal — value class ScrollWheel.
Keyboard: performKeyInput
Open a key scope — performKeyInput(block: KeyInjectionScope.() -> Unit) (Actions.kt:532-545).
Send key events. Core surface (KeyInjectionScope.kt:55-107):
keyDown(key: Key) / keyUp(key: Key) — primitives. Throw IllegalStateException if state is invalid (already-down, already-up).
isKeyDown(key: Key): Boolean — query injection state.
- Modifier-state vals:
isCtrlDown, isAltDown, isShiftDown, isMetaDown, isFnDown (KeyInjectionScope.kt:236-269), isCapsLockOn, isNumLockOn, isScrollLockOn (KeyInjectionScope.kt:64-81). These reflect the injected state, not the host machine.
Use the helpers (KeyInjectionScope.kt:146-229):
pressKey(key, pressDurationMillis = 50L) — keyDown(key); advanceEventTime(50); keyUp(key).
withKeyDown(key) { … } — runs block while key is held; auto-releases in finally. The held key MUST NOT be used inside block.
withKeysDown(listOf(key1, key2)) { … } — same but multiple keys held simultaneously.
withKeyToggled(key) { … } / withKeysToggled(keys) { … } — pressKey before and after block. Useful for CapsLock, NumLock, ScrollLock.
Repeat-key behavior. Holding a key down and advancing the event time (via advanceEventTime from InjectionScope) produces repeat events: the first repeat fires after 500 ms, then every 50 ms (KeyInjectionScope.kt:43-50). This is NOT triggered by MainTestClock.advanceTimeBy — that one advances the test clock, not the injection event time.
Direct KeyEvent injection — SemanticsNodeInteraction.performKeyPress(KeyEvent): Boolean from KeyInputHelpers.kt:27. Returns true if the event was consumed. Use it when you already have a KeyEvent (e.g. constructed via KeyEvent(NativeKeyEvent(...)) for fine-grained source/scancode control); otherwise prefer the DSL.
Multi-modal: performMultiModalInput
Open a multi-modal scope — performMultiModalInput(block: MultiModalInjectionScope.() -> Unit) (Actions.kt:582-594).
Dispatch into sub-scopes — touch { … }, mouse { … }, key { … }, rotary { … }, trackpad { … }, indirectPointer(...) (MultiModalInjectionScope.kt:51-90). All sub-scopes share the same injection state, so a finger left "down" in touch is still down when mouse runs next.
Pick this entry point when a single test step combines modalities — e.g. hover-while-shift-held — instead of stitching together two separate perform.*Input calls.
Patterns
Pattern: Right-click menu
// RIGHT
rule.onNodeWithTag(RowTag).performMouseInput {
rightClick(center)
}
rule.onNodeWithText("Delete").assertIsDisplayed()
rightClick is shorthand for click(position, MouseButton.Secondary) (MouseInjectionScope.kt:356-357). The label says "right" for familiarity, but it actually triggers the secondary button — correct on left-handed mice as well.
Pattern: Hover-driven tooltip
From CombinedClickableTest.kt:3415-3422:
rule.onNodeWithTag("myClickable").performMouseInput { enter(center) }
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
}
rule.onNodeWithTag("myClickable").performMouseInput { exit(Offset(-1f, -1f)) }
enter(position) emits a hover-enter; exit(position) emits a hover-exit. Pass an off-node Offset(-1f, -1f) to exit to mimic the cursor leaving the surface.
Pattern: Ctrl+S shortcut — verbose vs idiomatic
// WRONG
rule.onNodeWithTag(EditorTag).performKeyInput {
keyDown(Key.CtrlLeft)
pressKey(Key.S)
keyUp(Key.CtrlLeft)
}
// WRONG because: a thrown assertion or early-return inside the block leaks Key.CtrlLeft
// in the "down" state, polluting subsequent tests. The release is not in a finally.
// RIGHT
rule.onNodeWithTag(EditorTag).performKeyInput {
withKeyDown(Key.CtrlLeft) {
pressKey(Key.S)
}
}
withKeyDown releases the key in a finally block (KeyInjectionScope.kt:164-171). For multi-modifier shortcuts use withKeysDown(listOf(Key.CtrlLeft, Key.ShiftLeft)) { pressKey(Key.S) }.
Pattern: Repeat-key autorepeat
rule.onNodeWithTag(InputTag).performKeyInput {
keyDown(Key.DirectionDown)
advanceEventTime(800) // 500 ms initial delay + 6 repeats at 50 ms
keyUp(Key.DirectionDown)
}
advanceEventTime is the input-dispatcher clock — it triggers repeat events at the documented cadence (first repeat at 500 ms, then every 50 ms — KeyInjectionScope.kt:43-50). MUST NOT substitute rule.mainClock.advanceTimeBy(…): the main test clock advances Compose's frame clock, not the input dispatcher's event time, and no repeat events will be enqueued.
Pattern: Drag-and-drop with mouse
rule.onNodeWithTag(SourceTag).performMouseInput {
dragAndDrop(
start = center,
end = Offset(center.x + 200f, center.y),
durationMillis = 250,
)
}
dragAndDrop updates the pointer to start, presses, animates a move to end, then releases (MouseInjectionScope.kt:523-533). Default button is MouseButton.Primary; pass button = MouseButton.Tertiary for middle-click drag.
Pattern: Smooth scroll
rule.onNodeWithTag(ScrollAreaTag).performMouseInput {
smoothScroll(
scrollAmount = -10f, // negative scrollAmount = scroll back; new content appears at the top (MouseInjectionScope.kt:537-542)
durationMillis = 200,
scrollWheel = ScrollWheel.Vertical,
)
}
Positive scrollAmount reveals content from the bottom of a vertical column or from the end of a horizontal row (MouseInjectionScope.kt:537-542). For a single-tick wheel event, drop to scroll(delta, scrollWheel).
Pattern: Multi-modal — hover then click while Shift held
MultiModalInjectionScope exposes mouse { }, key { }, touch { } etc. as sub-scopes; modifier-key state from one key { withKeyDown(...) { } } invocation persists across the rest of the surrounding performMultiModalInput, so a subsequent mouse { } block is dispatched while Shift is still down. The withKeyDown block itself has KeyInjectionScope as its receiver — calling mouse { } from inside it does not compile.
rule.onNodeWithTag(NodeTag).performMultiModalInput {
mouse { enter(center) }
key { keyDown(Key.ShiftLeft) }
mouse { click() } // dispatched with Shift still held
key { keyUp(Key.ShiftLeft) }
}
A single performMultiModalInput keeps every modality in one batched flush; the modifier-key state, pointer position, and pressed buttons are shared across sub-scopes (MultiModalInjectionScope.kt). Use it when interleaving matters; otherwise two separate performMouseInput / performKeyInput calls are clearer.
Pattern: Direct KeyEvent injection for precise control
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.performKeyPress
val event = KeyEvent(NativeKeyEvent(NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_TAB))
rule.onRoot().performKeyPress(event)
performKeyPress returns whether the event was consumed (KeyInputHelpers.kt:27). It bypasses the DSL — useful for replaying a captured KeyEvent or testing IME-derived keystrokes.
Mandatory rules
- MUST wrap modifier-key shortcuts in
withKeyDown / withKeysDown so the auto-release finally cleans up after a thrown assertion. Manual keyDown / keyUp pairs leak modifier state across tests.
- MUST use
advanceEventTime (the InjectionScope clock) — NOT MainTestClock.advanceTimeBy — when expecting key autorepeat events. Repeats are produced by the input dispatcher's event-time stream (KeyInjectionScope.kt:43-50).
- MUST keep coordinates node-local using
center, topLeft, bottomRight, percentOffset(...). Hardcoded screen coordinates make the test device-shaped — see ../injecting-touch-gestures/SKILL.md.
- MUST funnel post-action assertions through
rule.runOnIdle { … } (skydoves hot take #5).
- MUST NOT use
performMouseInput to test a finger tap on Android device tests — performClick() resolves to a touch tap on Android and a mouse click on desktop, automatically.
- MUST NOT use
Thread.sleep to wait between key or mouse events. Use advanceEventTime inside the block (event-time delay) or mainClock.advanceTimeBy outside it (frame clock delay).
- PREFERRED: select
performMultiModalInput only when modalities truly interleave; otherwise separate performMouseInput and performKeyInput calls read better.
Verification
References
- Compose testing overview: https://developer.android.com/develop/ui/compose/testing
- Compose Multiplatform testing: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
- Compose testing cheat sheet: https://developer.android.com/develop/ui/compose/testing-cheatsheet
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt — performMouseInput (Actions.kt:448), performKeyInput (Actions.kt:532), performMultiModalInput (Actions.kt:582), performTrackpadInput, performRotaryScrollInput.
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MouseInjectionScope.kt — press, release, moveTo, moveBy, enter, exit, scroll, cancel, plus extensions click, rightClick, doubleClick, tripleClick, longClick, animateMoveTo, animateMoveBy, animateMoveAlong, dragAndDrop, smoothScroll.
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInjectionScope.kt — keyDown, keyUp, isKeyDown, modifier-state vals, pressKey, withKeyDown, withKeysDown, withKeyToggled, withKeysToggled, repeat-key contract.
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInputHelpers.kt — performKeyPress(KeyEvent): Boolean (KeyInputHelpers.kt:27).
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Mouse.kt — MouseButton.Primary / Secondary / Tertiary, ScrollWheel.Horizontal / Vertical.
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MultiModalInjectionScope.kt — touch, mouse, key, rotary, trackpad, indirectPointer.
compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt — performMouseInput { enter(center) }, performMouseInput { exit(Offset(-1f, -1f)) } (CombinedClickableTest.kt:3415-3422).
- skydoves — compose-performance-skills: https://github.com/skydoves/compose-performance-skills
1---2name: injecting-mouse-and-keyboard3description: Use this skill to drive Jetpack Compose UI from tests with non-touch input — performMouseInput (click, rightClick, doubleClick, tripleClick, longClick, animateMoveTo, dragAndDrop, smoothScroll, enter / exit, press / release / scroll), performKeyInput (keyDown, keyUp, isKeyDown, modifier-state vals isCtrlDown / isShiftDown / isAltDown / isMetaDown / isFnDown / isCapsLockOn / isNumLockOn / isScrollLockOn, helpers pressKey, withKeyDown, withKeysDown, withKeyToggled, withKeysToggled), performKeyPress for direct KeyEvent injection, and performMultiModalInput for hover-then-click flows. Covers MouseButton (Primary, Secondary, Tertiary), ScrollWheel (Horizontal, Vertical), and the repeat-key behaviour driven by advanceEventTime. Use when the developer asks "how do I right-click in a Compose test", "test a Ctrl+S shortcut", "send a keyboard shortcut", "simulate hover", "test a tooltip", "scroll the mouse wheel", or "drag and drop with a mouse" on desktop or Compose Multiplatform.4license: Apache-2.0. See LICENSE for complete terms.5---67# Injecting Mouse and Keyboard — performMouseInput, performKeyInput, performMultiModalInput89Mouse and keyboard input are first-class modalities in Compose tests. Each has its own injection scope and entry point, plus a shared `performMultiModalInput` that combines all of them inside one batched gesture (touch, mouse, key, rotary, trackpad, indirect pointer). Coordinates remain node-local — the same rules as touch — and the entire injection state survives across `perform.*Input` blocks on the same node.1011## When to use this skill1213- The test exercises a desktop or Compose Multiplatform UI that responds to clicks, hover, right-click, scroll wheel, or modifier keys.14- The test must drive a keyboard shortcut (e.g. `Ctrl+S`, `Shift+Tab`, arrow-key navigation).15- The test must verify hover-only behaviour: tooltip pop, color change on `Modifier.hoverable`, mouse-only ripple state.16- The test simulates a drag-and-drop with a mouse pointer.17- The test must combine modalities — for example "hover at (x, y) then click and hold while pressing `Shift`".1819## When NOT to use this skill2021- The interaction is a finger tap or drag — use `../injecting-touch-gestures/SKILL.md`. Mouse is not interchangeable with touch on Android device tests.22- A simple `performClick()` already routes through the right modality per platform — use `../clicking-and-scrolling/SKILL.md`.23- The test types into a `TextField` — use `../entering-text/SKILL.md`. Key input here is for shortcuts, not text entry.24- The test waits for a hover-driven animation to settle — pause the clock first per `../../synchronization/testing-animations-deterministically/SKILL.md`.2526## Prerequisites2728- `androidx.compose.ui:ui-test-junit4` (or `ui-test` for `runComposeUiTest`) configured per `../../setup/configuring-test-dependencies/SKILL.md`.29- For mouse hover events on Android: API level that supports hover (typically API 23+) — Robolectric host tests work but check `@Config(minSdk = …)`.30- For key input that targets a focusable node: the node must be focusable (e.g. wrapped in `Modifier.focusable()` or `Modifier.focusRequester(...)`). Otherwise key events route to the focused root.3132## Workflow3334### Mouse: performMouseInput35361. **Open a mouse scope** — `performMouseInput(block: MouseInjectionScope.() -> Unit)` (Actions.kt:448-461). Like touch, events are batched and flushed when the block returns; recomposition cannot interleave inside the block.37382. **Use node-local coordinates.** `(0, 0)` is the node's top-left, identical to `TouchInjectionScope`. `MouseInjectionScope` extends `InjectionScope` so `center`, `topLeft`, `bottomRight`, `percentOffset(...)` are all available (InjectionScope.kt:34-206).39403. **Pick the right helper.** All in `MouseInjectionScope.kt`:4142| Helper | Default | What it does |43|---|---|---|44| `click(position = center, button = MouseButton.Primary)` | primary click at center | press → 60 ms wait → release |45| `rightClick(position = center)` | secondary click at center | shorthand for `click(position, MouseButton.Secondary)` |46| `doubleClick(position = center, button = Primary)` | midway between min/max double-tap window | `click(); advanceEventTime(delay); click()` |47| `tripleClick(position = center, button = Primary)` | same delays as double | three sequential clicks |48| `longClick(position = center, button = Primary)` | `longPressTimeoutMillis + 100` ms hold | press, hold, release |49| `animateMoveTo(position, durationMillis = 300)` | 300 ms | streams move events along a linear path |50| `animateMoveBy(delta, durationMillis = 300)` | relative form | — |51| `animateMoveAlong(curve, durationMillis = 300)` | arbitrary curve | — |52| `dragAndDrop(start, end, button = Primary, durationMillis = 300)` | primary drag | `updatePointerTo(start); press; animateMoveTo(end); release` |53| `smoothScroll(scrollAmount, durationMillis = 300, scrollWheel = Vertical)` | vertical | streams scroll events |54554. **Drop to low-level events** when needed:56 - `press(button: MouseButton = Primary)` / `release(button: MouseButton = Primary)`.57 - `moveTo(position, delayMillis = eventPeriodMillis)` / `moveBy(delta, delayMillis = eventPeriodMillis)`.58 - `updatePointerTo(position)` / `updatePointerBy(delta)` — adjust position without sending an event.59 - `enter(position)` / `exit(position)` — explicit hover-enter / hover-exit.60 - `scroll(delta, scrollWheel = Vertical)` — single wheel tick.61 - `cancel(delayMillis)` — emit an `ACTION_CANCEL`.62 - `currentPosition: Offset` — last known mouse position.63645. **Reference `MouseButton` and `ScrollWheel`** from `Mouse.kt`:65 - `MouseButton.Primary`, `MouseButton.Secondary`, `MouseButton.Tertiary` — `expect value class MouseButton`.66 - `ScrollWheel.Vertical`, `ScrollWheel.Horizontal` — `value class ScrollWheel`.6768### Keyboard: performKeyInput69701. **Open a key scope** — `performKeyInput(block: KeyInjectionScope.() -> Unit)` (Actions.kt:532-545).71722. **Send key events.** Core surface (KeyInjectionScope.kt:55-107):73 - `keyDown(key: Key)` / `keyUp(key: Key)` — primitives. Throw `IllegalStateException` if state is invalid (already-down, already-up).74 - `isKeyDown(key: Key): Boolean` — query injection state.75 - Modifier-state vals: `isCtrlDown`, `isAltDown`, `isShiftDown`, `isMetaDown`, `isFnDown` (KeyInjectionScope.kt:236-269), `isCapsLockOn`, `isNumLockOn`, `isScrollLockOn` (KeyInjectionScope.kt:64-81). These reflect the **injected** state, not the host machine.76773. **Use the helpers** (KeyInjectionScope.kt:146-229):78 - `pressKey(key, pressDurationMillis = 50L)` — `keyDown(key); advanceEventTime(50); keyUp(key)`.79 - `withKeyDown(key) { … }` — runs `block` while `key` is held; auto-releases in `finally`. The held key MUST NOT be used inside `block`.80 - `withKeysDown(listOf(key1, key2)) { … }` — same but multiple keys held simultaneously.81 - `withKeyToggled(key) { … }` / `withKeysToggled(keys) { … }` — `pressKey` before and after `block`. Useful for `CapsLock`, `NumLock`, `ScrollLock`.82834. **Repeat-key behavior.** Holding a key down and advancing the **event time** (via `advanceEventTime` from `InjectionScope`) produces repeat events: the first repeat fires after 500 ms, then every 50 ms (KeyInjectionScope.kt:43-50). This is **NOT** triggered by `MainTestClock.advanceTimeBy` — that one advances the test clock, not the injection event time.84855. **Direct KeyEvent injection** — `SemanticsNodeInteraction.performKeyPress(KeyEvent): Boolean` from `KeyInputHelpers.kt:27`. Returns `true` if the event was consumed. Use it when you already have a `KeyEvent` (e.g. constructed via `KeyEvent(NativeKeyEvent(...))` for fine-grained source/scancode control); otherwise prefer the DSL.8687### Multi-modal: performMultiModalInput88891. **Open a multi-modal scope** — `performMultiModalInput(block: MultiModalInjectionScope.() -> Unit)` (Actions.kt:582-594).90912. **Dispatch into sub-scopes** — `touch { … }`, `mouse { … }`, `key { … }`, `rotary { … }`, `trackpad { … }`, `indirectPointer(...)` (MultiModalInjectionScope.kt:51-90). All sub-scopes share the same injection state, so a finger left "down" in `touch` is still down when `mouse` runs next.92933. **Pick this entry point** when a single test step combines modalities — e.g. hover-while-shift-held — instead of stitching together two separate `perform.*Input` calls.9495## Patterns9697### Pattern: Right-click menu9899```kotlin100// RIGHT101rule.onNodeWithTag(RowTag).performMouseInput {102 rightClick(center)103}104rule.onNodeWithText("Delete").assertIsDisplayed()105```106107`rightClick` is shorthand for `click(position, MouseButton.Secondary)` (MouseInjectionScope.kt:356-357). The label says "right" for familiarity, but it actually triggers the secondary button — correct on left-handed mice as well.108109### Pattern: Hover-driven tooltip110111From `CombinedClickableTest.kt:3415-3422`:112113```kotlin114rule.onNodeWithTag("myClickable").performMouseInput { enter(center) }115116rule.runOnIdle {117 assertThat(interactions).hasSize(1)118 assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)119}120121rule.onNodeWithTag("myClickable").performMouseInput { exit(Offset(-1f, -1f)) }122```123124`enter(position)` emits a hover-enter; `exit(position)` emits a hover-exit. Pass an off-node `Offset(-1f, -1f)` to `exit` to mimic the cursor leaving the surface.125126### Pattern: Ctrl+S shortcut — verbose vs idiomatic127128```kotlin129// WRONG130rule.onNodeWithTag(EditorTag).performKeyInput {131 keyDown(Key.CtrlLeft)132 pressKey(Key.S)133 keyUp(Key.CtrlLeft)134}135// WRONG because: a thrown assertion or early-return inside the block leaks Key.CtrlLeft136// in the "down" state, polluting subsequent tests. The release is not in a finally.137```138139```kotlin140// RIGHT141rule.onNodeWithTag(EditorTag).performKeyInput {142 withKeyDown(Key.CtrlLeft) {143 pressKey(Key.S)144 }145}146```147148`withKeyDown` releases the key in a `finally` block (KeyInjectionScope.kt:164-171). For multi-modifier shortcuts use `withKeysDown(listOf(Key.CtrlLeft, Key.ShiftLeft)) { pressKey(Key.S) }`.149150### Pattern: Repeat-key autorepeat151152```kotlin153rule.onNodeWithTag(InputTag).performKeyInput {154 keyDown(Key.DirectionDown)155 advanceEventTime(800) // 500 ms initial delay + 6 repeats at 50 ms156 keyUp(Key.DirectionDown)157}158```159160`advanceEventTime` is the input-dispatcher clock — it triggers repeat events at the documented cadence (first repeat at 500 ms, then every 50 ms — KeyInjectionScope.kt:43-50). **MUST NOT** substitute `rule.mainClock.advanceTimeBy(…)`: the main test clock advances Compose's frame clock, not the input dispatcher's event time, and no repeat events will be enqueued.161162### Pattern: Drag-and-drop with mouse163164```kotlin165rule.onNodeWithTag(SourceTag).performMouseInput {166 dragAndDrop(167 start = center,168 end = Offset(center.x + 200f, center.y),169 durationMillis = 250,170 )171}172```173174`dragAndDrop` updates the pointer to `start`, presses, animates a move to `end`, then releases (MouseInjectionScope.kt:523-533). Default button is `MouseButton.Primary`; pass `button = MouseButton.Tertiary` for middle-click drag.175176### Pattern: Smooth scroll177178```kotlin179rule.onNodeWithTag(ScrollAreaTag).performMouseInput {180 smoothScroll(181 scrollAmount = -10f, // negative scrollAmount = scroll back; new content appears at the top (MouseInjectionScope.kt:537-542)182 durationMillis = 200,183 scrollWheel = ScrollWheel.Vertical,184 )185}186```187188Positive `scrollAmount` reveals content from the bottom of a vertical column or from the end of a horizontal row (MouseInjectionScope.kt:537-542). For a single-tick wheel event, drop to `scroll(delta, scrollWheel)`.189190### Pattern: Multi-modal — hover then click while Shift held191192`MultiModalInjectionScope` exposes `mouse { }`, `key { }`, `touch { }` etc. as sub-scopes; modifier-key state from one `key { withKeyDown(...) { } }` invocation persists across the rest of the surrounding `performMultiModalInput`, so a subsequent `mouse { }` block is dispatched while Shift is still down. The `withKeyDown` block itself has `KeyInjectionScope` as its receiver — calling `mouse { }` from inside it does not compile.193194```kotlin195rule.onNodeWithTag(NodeTag).performMultiModalInput {196 mouse { enter(center) }197 key { keyDown(Key.ShiftLeft) }198 mouse { click() } // dispatched with Shift still held199 key { keyUp(Key.ShiftLeft) }200}201```202203A single `performMultiModalInput` keeps every modality in one batched flush; the modifier-key state, pointer position, and pressed buttons are shared across sub-scopes (MultiModalInjectionScope.kt). Use it when interleaving matters; otherwise two separate `performMouseInput` / `performKeyInput` calls are clearer.204205### Pattern: Direct KeyEvent injection for precise control206207```kotlin208import androidx.compose.ui.input.key.KeyEvent209import androidx.compose.ui.test.performKeyPress210211val event = KeyEvent(NativeKeyEvent(NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_TAB))212rule.onRoot().performKeyPress(event)213```214215`performKeyPress` returns whether the event was consumed (`KeyInputHelpers.kt:27`). It bypasses the DSL — useful for replaying a captured `KeyEvent` or testing IME-derived keystrokes.216217## Mandatory rules218219- **MUST** wrap modifier-key shortcuts in `withKeyDown` / `withKeysDown` so the auto-release `finally` cleans up after a thrown assertion. Manual `keyDown` / `keyUp` pairs leak modifier state across tests.220- **MUST** use `advanceEventTime` (the `InjectionScope` clock) — **NOT** `MainTestClock.advanceTimeBy` — when expecting key autorepeat events. Repeats are produced by the input dispatcher's event-time stream (KeyInjectionScope.kt:43-50).221- **MUST** keep coordinates node-local using `center`, `topLeft`, `bottomRight`, `percentOffset(...)`. Hardcoded screen coordinates make the test device-shaped — see `../injecting-touch-gestures/SKILL.md`.222- **MUST** funnel post-action assertions through `rule.runOnIdle { … }` (skydoves hot take #5).223- **MUST NOT** use `performMouseInput` to test a finger tap on Android device tests — `performClick()` resolves to a touch tap on Android and a mouse click on desktop, automatically.224- **MUST NOT** use `Thread.sleep` to wait between key or mouse events. Use `advanceEventTime` inside the block (event-time delay) or `mainClock.advanceTimeBy` outside it (frame clock delay).225- **PREFERRED:** select `performMultiModalInput` only when modalities truly interleave; otherwise separate `performMouseInput` and `performKeyInput` calls read better.226227## Verification228229- [ ] Every modifier-key combo uses `withKeyDown` / `withKeysDown` rather than raw `keyDown`/`keyUp` pairs.230- [ ] No `Thread.sleep` between or inside `performMouseInput` / `performKeyInput` blocks.231- [ ] No hardcoded screen coordinates — every `Offset(...)` is derived from `center`, `top*`, `bottom*`, `percentOffset(...)`, or relative deltas.232- [ ] Tests asserting key autorepeat behaviour use `advanceEventTime`, not `mainClock.advanceTimeBy`.233- [ ] Assertions following an action run inside `rule.runOnIdle { … }`.234- [ ] `./gradlew :app:connectedDebugAndroidTest` (or the equivalent host / desktop task) passes for the test under change.235236## References237238- Compose testing overview: https://developer.android.com/develop/ui/compose/testing239- Compose Multiplatform testing: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html240- Compose testing cheat sheet: https://developer.android.com/develop/ui/compose/testing-cheatsheet241- `compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt` — `performMouseInput` (Actions.kt:448), `performKeyInput` (Actions.kt:532), `performMultiModalInput` (Actions.kt:582), `performTrackpadInput`, `performRotaryScrollInput`.242- `compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MouseInjectionScope.kt` — `press`, `release`, `moveTo`, `moveBy`, `enter`, `exit`, `scroll`, `cancel`, plus extensions `click`, `rightClick`, `doubleClick`, `tripleClick`, `longClick`, `animateMoveTo`, `animateMoveBy`, `animateMoveAlong`, `dragAndDrop`, `smoothScroll`.243- `compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInjectionScope.kt` — `keyDown`, `keyUp`, `isKeyDown`, modifier-state vals, `pressKey`, `withKeyDown`, `withKeysDown`, `withKeyToggled`, `withKeysToggled`, repeat-key contract.244- `compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInputHelpers.kt` — `performKeyPress(KeyEvent): Boolean` (KeyInputHelpers.kt:27).245- `compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Mouse.kt` — `MouseButton.Primary` / `Secondary` / `Tertiary`, `ScrollWheel.Horizontal` / `Vertical`.246- `compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MultiModalInjectionScope.kt` — `touch`, `mouse`, `key`, `rotary`, `trackpad`, `indirectPointer`.247- `compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt` — `performMouseInput { enter(center) }`, `performMouseInput { exit(Offset(-1f, -1f)) }` (CombinedClickableTest.kt:3415-3422).248- skydoves — compose-performance-skills: https://github.com/skydoves/compose-performance-skills