English · 日本語
Code generation (codegen)¶
A passing scenario generates a native test in a destination framework's own idiom, letting a team run the same flow in their existing CI (continuous integration) — with no bajutsu runtime and no AI at test time. bajutsu supports three targets: XCUITest (Swift) for the iOS backend, Playwright (TypeScript) for the web backend, and UI Automator (Kotlin) for the Android backend. The mapping is purely structural (AI-independent).
Implementation:
bajutsu/codegen/xcuitest.py(XCUITest),bajutsu/codegen/playwright.py(Playwright),bajutsu/codegen/uiautomator.py(UI Automator).
Related: scenarios · cli · drivers · the showcase UI-test target
Usage¶
bajutsu codegen <scenario.yaml> --target <name> [--emit xcuitest | playwright | uiautomator] [-o <out>]
--emit is xcuitest (default), playwright, or uiautomator. -o - (default) writes to stdout;
a file path writes to the file. --emit playwright requires the app to be a web target
(targets.<name>.baseUrl set) and --emit uiautomator an Android target
(targets.<name>.package set); without the matching target the command exits with code 2. Config's
launchEnv is carried into the generated test (cli) — app.launchEnvironment for
XCUITest, seeded localStorage for Playwright, and forwarded as intent extras for UI Automator.
XCUITest output shape¶
A group of scenarios becomes one XCTestCase subclass. 1 scenario = 1 test method.
// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import XCTest
final class ComponentsUITests: XCTestCase {
private let app = XCUIApplication()
private func el(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
}
private func byLabel(_ label: String) -> XCUIElement { ... }
private func matchingId(_ glob: String) -> XCUIElementQuery { ... }
func test_open_filter_shows_the_sheet() {
app.launchEnvironment["SHOWCASE_UITEST"] = "1"
app.launch()
byLabel("Log").tap()
XCTAssertFalse(el("log.sheet.title").exists)
el("log.openFilter").tap()
// expect
XCTAssertTrue(el("log.sheet.title").exists)
}
}
- The helpers
el(id)/byLabel(label)/matchingId(glob)bridge the three single-field selector forms (id / label / idMatches) to an XCUIElement. - Each method sets
launchEnvironmentthenapp.launch()at the top. The env is the merge of config'slaunchEnv< the scenario'spreconditions.launchEnv(the test side wins).
Selector mapping (XCUITest)¶
A single id / label / idMatches keeps its readable helper above. Any compound selector —
value, traits, index, or several fields together — composes one NSPredicate query instead
(BE-0026), so it generates structurally rather than dropping to a // TODO:
Selector field |
Generated XCUITest |
|---|---|
id / idMatches |
identifier == %@ / identifier LIKE %@ |
label |
label == %@ |
labelMatches (literal substring) |
label CONTAINS %@ |
value |
value == %@ |
traits: [button \| link] |
elementType == XCUIElement.ElementType.<case>.rawValue |
traits: [notEnabled] / [selected] |
enabled == NO / selected == YES |
index: n |
.element(boundBy: n) (a negative n counts from the end: .element(boundBy: query.count - k); else .firstMatch) |
All set fields are AND-ed into the predicate. A field with no faithful structural form keeps
the selector at el("UNSUPPORTED_SELECTOR") — an honest gap, not a wrong guess:
labelMatcheswith regex metacharacters — it is a Pythonre.searchpattern; only a metacharacter-free one is a plain substring (CONTAINS). A real regex (e.g.^Item) has no faithful NSPredicate form (ICUMATCHESis a full, differently-anchored match).within— a geometric frame-containment constraint (the candidate's frame must sit inside the container's; see selectors). XCUITest queries are tree-based, not geometric.- an unknown trait — outside the
button/link/notEnabled/selectedvocabulary.
Mapping table¶
| Scenario element | Generated XCUITest |
|---|---|
tap |
el(id).tap() / byLabel(...).tap() |
doubleTap |
.doubleTap() |
longPress |
.press(forDuration: <sec>) |
type (with into) |
el(id).tap() + .typeText(...) |
type (no into) |
app.typeText(...) |
clear |
.tap() + select-all (typeKey("a", modifierFlags: .command)) + delete — no XCUIElement "clear" primitive, so focus/select-all/delete is the faithful peer of the runner's own clear (BE-0265) |
delete { count } |
.tap() + count delete keypresses (typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count:)), BE-0265) |
select |
.tap() + select-all (BE-0265) |
copy |
app.typeKey("c", modifierFlags: .command) |
back |
taps the OS navigation back button — the same element (and shared constant) the XCUITest driver taps at run time (BE-0210) |
swipe { on, direction } |
.swipeUp/Down/Left/Right() |
swipe { from, to } |
coord(x1, y1).press(forDuration: 0.1, thenDragTo: coord(x2, y2)) — an XCUICoordinate drag (BE-0025) |
drag { on, direction } |
the same primitive as a directional swipe (.swipeUp/Down/Left/Right()) — a real drag both scrolls and moves handles on iOS (BE-0227) |
pinch |
.pinch(withScale: <scale>, velocity: <±1.0>) — the velocity sign matches the scale (scale ≥ 1 zooms in) |
rotate |
.rotate(<radians>, withVelocity: 1.0) |
scroll { to } |
// TODO — XCUITest has no single robust scroll-to-element primitive (swipeUp() scrolls a fixed amount without re-querying), so the faithful bounded, re-querying loop is left unemitted rather than faked (BE-0326) |
handleSystemAlert |
XCTAssertTrue(XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons["…"].waitForExistence(timeout:)) + .tap() — the native SpringBoard idiom, carrying the step's timeout (BE-0316). The prompt / choice form resolves its label from the run's locale (BE-0320), which a static translation has no access to, so it emits a labeled // TODO instead |
wait { for } |
XCTAssertTrue(el(...).waitForExistence(timeout:)) |
wait { until: gone } |
.waitForNonExistence(timeout:) |
wait { until: screenChanged/settled } |
a comment (XCUITest auto-waits for hittability) |
relaunch |
app.terminate() + app.launch() |
assert / expect kinds |
see below |
Assertion mapping¶
| Assertion | XCUITest |
|---|---|
exists |
XCTAssertTrue(el(...).exists) (XCTAssertFalse with negate) |
value (equals/contains/matches) |
XCTAssertEqual(.value ...) / .contains(...) / regex via range(of:options:.regularExpression) |
label (equals/contains/matches) |
XCTAssertEqual(.label, ...) / .contains(...) / regex |
enabled / disabled |
XCTAssertTrue/False(...isEnabled) |
selected |
XCTAssertTrue(...isSelected) |
count (equals/atLeast/atMost) |
matchingId(glob).count (a bare id uses exists ? 1 : 0) with XCTAssertEqual/GreaterThanOrEqual/LessThanOrEqual |
Unsupported constructs fall back to TODO comments¶
Unsupported constructs (simctl-level device control like setLocation / push, an unknown
trait, and coordinate swipes on the Playwright target) emit a // TODO line rather than
failing — device-control steps name the simctl command a reviewer would run. The output is
always reviewable and never fails generation. The generated file header also states "do not edit
by hand; re-generate." This fallback behavior holds for all three targets.
The same TODO rule covers a network request / requestSequence assertion on XCUITest and UI
Automator. Neither backend has a network-interception surface. Playwright is the exception: the
web backend intercepts network traffic, so the emitted test asserts against it for real, never
falling back to a TODO (see below).
One family of constructs is the exception: if, forEach, and extract each evaluate against a
live UI tree at run time — a branch on the current state, a loop over the live match set, a capture
of a resolved element's property — which a static generated test has no runtime to reproduce.
Rather than a silent no-op stub, all three targets raise a CodegenError at generation time and
name bajutsu run as the faithful path for a scenario that uses them (BE-0297).
Playwright (web) target¶
--emit playwright renders a scenario as a Playwright test in TypeScript (@playwright/test),
the handoff artifact for the web (Playwright) backend (drivers). A group of scenarios
becomes one test.describe block; 1 scenario = 1 test(...) (the parallel of one test method).
Unlike the run driver — which walks the DOM and coordinate-clicks the resolved frame center so
matching is byte-for-byte identical to iOS — the emitted test uses Playwright's semantic locators
(getByTestId / getByRole) and web-first assertions (expect(...).toBeVisible()). That is
deliberate: the destination framework is the runtime, so the test must speak Playwright's idiom,
and Playwright's auto-waiting owns determinism in the handoff artifact (web-first assertions
retry until the test timeout). iOS carries no such split: since BE-0290 retired idb, the XCUITest
backend already actuates by accessibility identifier at run time, the same idiom the emitted
el(id).tap() / waitForExistence speaks.
// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import { test, expect } from '@playwright/test';
const BASE_URL = 'http://localhost:3000';
test.describe('Components', () => {
test('long press reveals a label', async ({ page }) => {
await page.goto(BASE_URL);
await expect(page.getByTestId('comp.secret')).toBeHidden();
await page.getByTestId('comp.longpress').click({ delay: 600 });
// expect
await expect(page.getByTestId('comp.secret')).toBeVisible();
});
});
- Each test navigates to
BASE_URL(the app'sbaseUrl, the weblaunchequivalent ofpage.goto). Config'slaunchEnv< the scenario'spreconditions.launchEnvis seeded viapage.addInitScript(() => localStorage.setItem(...)); an app expecting another channel (query params / cookies) gets a// TODO. - All waiting uses Playwright's native auto-wait. The only fixed timing emitted is
longPress'sdelay— intrinsic to the gesture, the same honesty the iOS path applies topress(forDuration:). A directionalswipecarries no such timing: it wheels the page from the element's center instead of dragging it, matching the web driver's own scroll (BE-0227).
Selector mapping (Playwright)¶
Selector field |
Playwright locator |
|---|---|
id |
page.getByTestId('…') (the data-testid convention) |
label (alone) |
page.getByText('…', { exact: true }) |
label + traits |
page.getByRole(role, { name: '…', exact: true }) |
traits (alone) |
page.getByRole('button') |
idMatches (fnmatch glob) |
a data-testid CSS attribute selector: prefix* → […^="prefix"], *suffix → […$="suffix"], *sub* → […*="sub"]; an interior *, a ?, or a […] class → // TODO |
labelMatches |
page.getByText(/regex/) (a JS RegExp, matching the DSL's re.search semantics) |
index |
narrows the locator with .nth(<index>) |
value / within |
unsupported as an AND-constraint → // TODO |
Action mapping (Playwright)¶
| Scenario step | Playwright |
|---|---|
tap |
await loc.click() |
doubleTap |
await loc.dblclick() |
type (with into) |
await loc.fill('…') |
type (no into) |
await page.keyboard.type('…') |
longPress |
await loc.click({ delay: <ms> }) |
clear |
await loc.clear() — the faithful peer of the driver's own focus-then-backspace clear (BE-0265) |
delete { count } |
focus + count × page.keyboard.press('Backspace') (BE-0265) |
select |
await loc.selectText() — the web peer of select-all (BE-0265) |
copy |
await page.keyboard.press('Control+c') |
back |
await page.goBack() — browser history, the same primitive the driver's back() uses (BE-0210) |
swipe { on, direction } |
a page.mouse.wheel scroll from the element center in the direction (BE-0227) |
swipe { from, to } |
// TODO (coordinate swipes are not generated) |
drag { on, direction } |
a real pointer drag of the element (move → down → move → up) from its center, in the direction — the web driver drags for drag where it wheels for a directional swipe (BE-0227) |
scroll { to } |
await loc.scrollIntoViewIfNeeded() — a Playwright locator auto-scrolls into view before acting, so direction / within / maxScrolls / amount are subsumed by the browser's own scroll, which does its own stepping (BE-0326, BE-0400) |
wait { for } |
await expect(loc).toBeVisible({ timeout: <ms> }) |
wait { until: gone } |
await expect(loc).toBeHidden({ timeout: <ms> }) |
wait { until: screenChanged/settled } |
a comment (Playwright auto-waits) |
relaunch |
await page.goto(BASE_URL) |
pinch / rotate |
// TODO (multi-touch; the web backend does not drive it) |
handleSystemAlert |
// TODO (iOS-only; the web has no OS-level prompt) |
Assertion mapping (Playwright, web-first expect)¶
| Assertion | Playwright |
|---|---|
exists |
await expect(loc).toBeVisible() (.toBeHidden() with negate) |
value (equals/contains/matches) |
await expect(loc).toHaveValue('…' \| /regex/) |
label (equals/contains/matches) |
.toHaveText('…') / .toContainText('…') / .toHaveText(/regex/) |
enabled / disabled |
.toBeEnabled() / .toBeDisabled() |
selected |
.toBeChecked() |
count (equals/atLeast/atMost) |
.toHaveCount(n); atLeast/atMost compare await loc.count() |
Unlike XCUITest and UI Automator, the web backend intercepts network traffic, so a request /
requestSequence assertion (or an until: { request } wait) is not a TODO here. The emitted test
installs a page.on('requestfinished', ...) recorder before navigation. It then checks the
exchanges observed so far — a point-in-time check that mirrors the runner's own collector rather
than a waitForResponse that could stall on future traffic. A responseSchema assertion still
falls back to a // TODO: validating a JSON Schema needs a schema library the generated test
should not assume.
The describe block name is the -o filename stem (or the scenario filename) humanized; each
test(...) title is the scenario name verbatim (TypeScript test titles are plain strings, so no
identifier sanitizing is needed).
UI Automator (Android) target¶
--emit uiautomator renders a scenario as a UI Automator test in Kotlin (androidx.test.uiautomator
+ JUnit), the handoff artifact for the Android (adb) backend (drivers). A group of
scenarios becomes one instrumented test class; 1 scenario = 1 @Test method (the parallel of one
XCUITest method).
UI Automator is the closer twin of the adb backend: both take a cross-process, black-box view of the
app through resource-id / text / content-desc, so the emitted test is the faithful reverse of
the driver's own read of the tree — it drives UiDevice / UiObject2 and asserts with JUnit,
mirroring what the driver does at run time rather than an Espresso view-matcher idiom (which would
need R.id references the string-keyed scenario does not carry). Waiting uses device.wait(Until.…)
rather than a fixed sleep, the same determinism split the iOS and web targets make.
The listing below is a skeleton, not a transcript: the emitted file carries a paragraph of rationale
above each helper, abridged here to a short inline comment where one is worth keeping, and several
helpers are left out entirely — which is why uiAutomation(), accessibilityWindows(), and
windowSummary() are called below but defined nowhere in it, and why the gone halves of the
sliced wait, waitGone() and awaitGone(), appear neither called nor defined. Read the
checked-in
CodegenAndroidUITest.kt
for the emitter's actual output.
// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.SystemClock
import android.util.Log
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import java.io.File
import java.util.regex.Pattern
private const val PACKAGE = "com.example.app"
private const val LAUNCH_TIMEOUT_MS = 20000L
private const val LAUNCH_ATTEMPTS = 2
private const val ACT_TIMEOUT_MS = 15000L
private const val TRACKING_KICK_ATTEMPTS = 3
private const val CACHE_REREAD_SLICE_MS = 500L
private const val DIAGNOSTICS_DIR = "codegen-diagnostics"
private const val ADDITIONAL_OUTPUT_ARG = "additionalTestOutputDir"
private const val LOG_TAG = "BajutsuCodegen"
@RunWith(AndroidJUnit4::class)
class ComponentsUITest {
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@get:Rule
val diagnostics: TestRule = object : TestWatcher() {
override fun failed(error: Throwable, description: Description) {
// ?: because methodName is a platform type — see the generated test's comment
dumpDiagnostics(description.methodName ?: description.displayName)
}
}
private fun byId(id: String) =
By.res(Pattern.compile("(.*:id/)?" + Pattern.quote(id)))
private fun kickWindowTracking(reason: String) {
Log.w(LOG_TAG, "kicking accessibility window tracking with pressHome(): $reason")
runCatching {
if (!device.pressHome()) Log.w(LOG_TAG, "pressHome produced no window event")
}.onFailure { Log.w(LOG_TAG, "pressHome failed", it) }
}
// Never throws: getWindows() raises IllegalStateException when the connection is not established
private fun reportsWindows(): Boolean = runCatching {
accessibilityWindows().isNotEmpty()
}.getOrElse { Log.w(LOG_TAG, "could not read the window list", it); false }
private fun ensureWindowTracking() {
for (attempt in 1..TRACKING_KICK_ATTEMPTS) {
if (reportsWindows()) return
kickWindowTracking("no accessibility windows reported (pre-launch kick $attempt)")
}
// The last kick would otherwise go unchecked. This reads once more only so a failure
// leaves a line — a recovery is silent, and shows as a kick with no failure line after
// it. launch() is tried either way, since nothing here is reported back to it and
// starting the activity is the stronger stimulus.
if (!reportsWindows()) {
Log.w(
LOG_TAG,
"no usable window list after $TRACKING_KICK_ATTEMPTS kick(s); trying launch" +
" anyway; windows:\n" + windowSummary()
)
}
}
private fun launch(extras: Map<String, String>) {
val context = ApplicationProvider.getApplicationContext<Context>()
for (attempt in 1..LAUNCH_ATTEMPTS) {
ensureWindowTracking()
val intent = context.packageManager.getLaunchIntentForPackage(PACKAGE)!!
.apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) }
for ((k, v) in extras) intent.putExtra(k, v)
context.startActivity(intent)
val by = By.pkg(PACKAGE).depth(0)
if (waitPresent(by, LAUNCH_TIMEOUT_MS)) {
device.waitForIdle(LAUNCH_TIMEOUT_MS) // let the first frame settle
return
}
Log.w(LOG_TAG, "launch attempt $attempt saw no $PACKAGE window in "
+ "${LAUNCH_TIMEOUT_MS}ms; windows:\n" + windowSummary())
if (attempt < LAUNCH_ATTEMPTS) { // HOME after the last one would overwrite the evidence
kickWindowTracking("launch attempt $attempt timed out")
}
}
throw AssertionError(
"launch: no $PACKAGE window in the accessibility tree after $LAUNCH_ATTEMPTS attempt(s) " +
"of ${LAUNCH_TIMEOUT_MS}ms; windows:\n" + windowSummary()
)
}
private fun act(by: BySelector): UiObject2 {
if (!waitPresent(by, ACT_TIMEOUT_MS)) {
throw AssertionError(
"act: no element matched $by within ${ACT_TIMEOUT_MS}ms; windows:\n" + windowSummary()
)
}
return device.findObject(by)
}
// Never throws: clearCache() raises when the connection is not established, which is the
// very fault this runs to recover. UiAutomation.clearCache() arrived in API 34.
private fun clearAccessibilityCache() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
try {
uiAutomation().clearCache()
} catch (e: RuntimeException) {
Log.w(LOG_TAG, "could not clear the accessibility cache", e)
}
}
private fun waitSliced(timeoutMs: Long, poll: (Long) -> Boolean): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return poll(timeoutMs) // no lever below API 34, so slicing would change nothing
}
val deadline = SystemClock.uptimeMillis() + timeoutMs
while (true) {
// Poll before testing the deadline, so a 0ms budget still reads the tree once
val remaining = deadline - SystemClock.uptimeMillis()
if (poll(remaining.coerceIn(0L, CACHE_REREAD_SLICE_MS))) return true
if (SystemClock.uptimeMillis() >= deadline) return false
clearAccessibilityCache()
}
}
private fun waitPresent(by: BySelector, timeoutMs: Long): Boolean =
waitSliced(timeoutMs) { device.wait(Until.hasObject(by), it) }
private fun awaitPresent(by: BySelector, timeoutMs: Long) {
if (waitPresent(by, timeoutMs)) return
throw AssertionError(
"wait: no element matched $by within ${timeoutMs}ms; windows:\n" + windowSummary()
)
}
@Test
fun test_open_filter_shows_the_sheet() {
val extras = mutableMapOf<String, String>()
extras["SHOWCASE_UITEST"] = "1"
launch(extras)
assertFalse(device.hasObject(byId("log.sheet.title")))
act(byId("log.openFilter")).click()
// expect
assertTrue(device.hasObject(byId("log.sheet.title")))
}
}
- The
byIdhelper matches the local id whether or not the app namespaces it with a<package>:id/prefix — the reverse of the adb driver stripping that prefix, so a nativeandroid:idand a ComposetestTag(surfaced viatestTagsAsResourceId, which carries no prefix) both resolve. - The
acthelper waits for the element before returning it.findObjectalone is a single-shot query with no implicit wait. Acting right afterlaunch()or a UI transition could otherwise race the render. An element-targeting action routes throughact; awaitstep routes throughawaitPresent/awaitGoneinstead, since it targets a condition rather than an element to act on;relaunchstays direct. Bothactand the twoawait…helpers throw anAssertionErrornaming the selector and the timeout;relaunchemitslaunch(extras), whose failure names the package and the attempt budget instead.findObjectalone would instead throw a bareNullPointerExceptionthat names neither. A read-only assertion still callsdevice.findObject/device.findObjectsdirectly, unwaited. That matches the driver's own assertions. - Each method builds an
extrasmap (config'slaunchEnv< the scenario'spreconditions.launchEnv) and callslaunch(extras), which forwards the env as intent extras — the reverse of the adb backend'sam start --es. launchwaits for the app's first window, then for that window to settle (device.waitForIdle). The window wait proves some window from the package exists. It does not prove that window has finished drawing its first frame. On a loaded CI runner, the nextact()can otherwise race a screen still mid-layout.waitForIdlecloses that gap before the test's own per-action waits start their clock. The settle sits on the success path: running it after a wait that found no window settles nothing.launchchecks its window wait and re-issues the intent on a miss. Selectors are matched against the accessibility tree, and the wait proves the app's window reached it. A window that never arrives there is not a slow first frame, so waiting longer on the same launch cannot recover it. Dropping the wait's result instead would let a launch that never took fall through to the firstact. Thatactthen times out against a screen the app never reached, reporting the selector rather than the launch.- Every attempt waits the full
LAUNCH_TIMEOUT_MS, so a merely slow cold start is waited out rather than restarted.FLAG_ACTIVITY_CLEAR_TASKtears the activity down, so relaunching on a shorter per-attempt budget would send an app that was still on its way back to the beginning, and a sequence of such restarts could starve a launch a single long wait would have completed. Only a window absent for the whole timeout counts as stuck rather than slow. AfterLAUNCH_ATTEMPTSattemptslaunchfails, naming the windows it did see. The intent is rebuilt per attempt, which also keepslaunchEnvarriving throughonCreate; resuming the existing task instead would route the extras toonNewIntent. - A
waitspends its budget across several reads, dropping the accessibility cache between them.hasObject,findObject, and everyUntilcondition built on them resolve through the platform's per-connectionAccessibilityNodeInfocache, and only an accessibility event invalidates it. A dropped event therefore does not merely delay a read — it pins it, so every poll inside onedevice.waitre-reads the same stale tree and the timeout expires against a screen that changed before the wait began. Raising the timeout cannot recover a read that will not change on its own, the same shape as the wedged window list above.waitSlicedtherefore caps eachdevice.waitatCACHE_REREAD_SLICE_MSand callsclearAccessibilityCachebetween slices. The slices share the caller's single timeout rather than extending it, and a slice returns the instant its condition holds — and each slice polls before testing the deadline, so atimeout: 0step still reads the tree once, asdevice.wait(condition, 0)does on its own, so a healthy wait finishes exactly when it did before and pays only a couple of cache drops per second — this stays a condition wait, never a fixed sleep.
The cache drop is confined to API 34 and up, because that is where the lever exists.
androidx.test.uiautomator 2.3.0 reaches AccessibilityInteractionClient#clearCache() by
reflection and gives up past API 32, logging clearCache() reflection is not available on API >=
33; UiAutomation.clearCache(), the supported replacement, arrived in API 34. Below that
waitSliced spends the whole budget in one device.wait, since slicing would add re-reads that
change nothing.
One CI run on an API 34 emulator failed exactly this way: Until.gone polled a filtered-out row
for its whole 5 seconds, while the failure rule's screenshot and hierarchy dump, captured 100 ms
later, both showed the row already gone and the screen in the state the assertion wanted.
- Both failures name the windows they searched, because "no element matched" cannot distinguish an id that has not rendered from an app whose window is absent from the tree altogether. The two need opposite fixes.
- A launch whose window never reaches the accessibility tree is retried after a window change, not
waited on longer. UI Automator matches every selector against the windows the accessibility
framework reports, so an app whose window is missing from that list is unreachable however long the
wait runs.
kickWindowTrackingpresses HOME and the intent is re-issued. HOME is dispatched through the input pipeline rather than the accessibility one, so it lands no matter what the accessibility view currently says, and it dismisses whatever holds focus. The key press waits for the events it produces, so the recovery adds no sleep.LAUNCH_ATTEMPTSbounds this kick to one fewer than the number of attempts, so once at the emitted value of 2. It is not the only kick a launch can make, though: the pre-launch check in the next bullet carries its ownTRACKING_KICK_ATTEMPTSbudget per attempt, putting the worst case for onelaunchat `LAUNCH_ATTEMPTS × TRACKING_KICK_ATTEMPTS - (LAUNCH_ATTEMPTS - 1)
presses — seven as emitted, but only on a device whose list reads empty at every pre-launch check, since that check does not give up early when its own budget runs out (see the next bullet). On a device whose list is live — the run below — it presses once.pressHomereports a window event that never arrived by returning false rather than by throwing, so both outcomes are logged. The kick stays outsideUiAutomation.executeAndWaitForEvent, becausepressHome` already waits through that same call and a nested one would clear the event queue the outer wait is watching. - Two ways the window fails to arrive, both observed in CI, and one remedy.
ensureWindowTrackingreads the list before each attempt and catches the first: a list holding nothing at all, which one run logged asno accessibility windows reportedbefore the window change recovered it. The second is invisible to that check — the list is live and merely missing the app's window — so the kick after a failed attempt fires without consulting the list, which is what the run below needed. Reading the list first is worth it only because it turns the first case into one key press instead of a whole launch timeout.
A list that is still empty once the pre-launch budget is spent is logged rather than raised as a
failure: HOME before the app has started only reaches the launcher — the weakest stimulus this
file has — while starting
the activity adds a window outright. Throwing there would spend the whole kick budget on the weak
stimulus and abort before startActivity ever runs — on the one device that most needs the strong
one. launch's own retry loop already reports the failure, naming the window list, if starting the
activity does not help either.
- Every accessibility read goes through one Configurator-flagged accessor. windowSummary,
reportsWindows, and clearAccessibilityCache reach the connection through the flags UiDevice
itself uses, taken from
Configurator.getInstance(), rather than through the flag-less
Instrumentation.getUiAutomation() overload — which, on a target that sets non-default flags,
tears down and reconnects the same UiAutomation on every read, turning a read into the very
connection churn the evidence below exists to diagnose. The flagged overload only exists from API
24, so the accessor falls back to the flag-less read below that, exactly as UiDevice does.
- The last attempt does not kick once its wait fails, because after it there is no intent left to
re-issue and HOME would overwrite every piece of evidence the failure is about to collect. (The
pre-launch check ensureWindowTracking still presses HOME on that attempt, but before the relaunch, so
no evidence is at stake there.) The AssertionError's own window summary, the hierarchy dump, and
the screenshot would all describe the launcher, and a healthy launcher window list argues the exact
opposite of the failure they exist to explain.
Gradle's per-test logcat, which CI uploads alongside the evidence below, is what identified this failure, and the per-attempt window summary is the line that did it. One run logged this at the end of a 20-second launch wait:
W BajutsuCodegen: launch attempt 1 saw no com.bajutsu.showcase.android.compose window in 20000ms; windows:
W BajutsuCodegen: root=com.android.systemui AccessibilityWindowInfo[title=null, type=TYPE_SYSTEM, layer=1, …]
W BajutsuCodegen: root=android AccessibilityWindowInfo[title=Pixel Launcher isn't responding, type=TYPE_SYSTEM, focused=true, active=true, …]
W BajutsuCodegen: kicking accessibility window tracking with pressHome(): launch attempt 1 timed out
The list was live and correct: two windows, one of them a focused application-not-responding dialog
that had appeared during the wait. What it did not contain was the app's own window — 19 seconds
after ActivityTaskManager had reported that activity Displayed. A focused system window keeps
the app's window out of what UiAutomation reports, so the app is drawn and foreground while every
selector searches a list it is not in. HOME dismissed the dialog, the second attempt came up, and
the test passed.
That also explains the earlier evidence, which had suggested a channel that had stopped reporting
altogether. Run 30899952762 failed three separate CI attempts of that job (reruns, not launch's
own two), polling 153, 168, and 171 times across some 20 seconds with the activity both RESUMED
and Displayed; and across seven runs, every passing run logged UiDevice's transient null roots
during launch (Active window root not found, Skipping null root node for window) 2 to 7 times
within 10 to 24 polls while every failing run logged none. A missing app window accounts for that
correlation without a frozen channel: with the app's window never joining the list, there is no
launch transition for UiDevice to observe, so none of the churn a live launch produces appears.
Raising the wait from 5 to 15 and then to 20 seconds changed nothing, and could not: a list the
app's window never joins does not gain it by being waited on.
Failure evidence¶
A timed-out wait reports only that nothing matched, which leaves a run that fails once and passes on
a re-run undiagnosable. The generated test therefore carries a JUnit TestWatcher rule that, on any
failure, writes three pieces of evidence into a codegen-diagnostics directory:
| File | What it settles |
|---|---|
<test>-windows.txt |
Every accessibility window with its root's package, and every id By.res can currently match — so "the app's window is missing" and "the id is missing" are told apart |
<test>-hierarchy.xml |
device.dumpWindowHierarchy — the whole tree, with each node's class, text, bounds, and package |
<test>-screen.png |
device.takeScreenshot — what was actually on screen |
The window list also goes to logcat under the BajutsuCodegen tag, which Gradle already collects per
test, so it survives even when the directory is not.
The directory sits inside the path the Android Gradle Plugin passes as the additionalTestOutputDir
instrumentation argument. After the run the plugin copies that path off the device into
build/outputs/connected_android_test_additional_output/. The app's own external files directory
would strand the evidence instead: from Android 11 on, adb cannot read
/sdcard/Android/data/<package>. A run outside Gradle receives no such argument and falls back to
the app's external files directory, where a dump left on the device still beats none.
Each dump is written independently of the others, since a hierarchy dump that throws must not cost
the screenshot. A throw does not pass silently either: an artifact that is simply absent explains
nothing on the one path meant to explain a failure, so the file and the reason go to logcat.
device.takeScreenshot reports failure by returning false rather than throwing, so the generated
code turns that into a throw to reach the same log. In this repository the uiautomator (codegen)
job of android-e2e.yml uploads the collected directory
alongside Gradle's own report.
Selector mapping (UI Automator)¶
Only a single-field selector maps to a BySelector; a compound selector (traits, within,
index, or several fields together) has no faithful single-selector form and stays a // TODO
rather than a broadened match that drops a constraint.
Selector field |
UI Automator |
|---|---|
id |
byId('…') — By.res(Pattern.compile("(.*:id/)?" + Pattern.quote(id))) |
label |
By.text('…') |
value |
By.desc('…') (the content-desc channel the driver reads) |
idMatches (fnmatch glob) |
By.res(Pattern.compile('…')) — a glob is a whole-string match, so Pattern full-match is faithful (* → .*, ? → .; a […] class → // TODO) |
labelMatches (metacharacter-free) |
By.textContains('…') — a plain substring, matching the DSL's re.search; a real regex → // TODO (see below) |
traits / within / index / compound |
// TODO |
labelMatches is a Python re.search (substring) pattern, but UI Automator's By.text(Pattern)
requires a full-string match — so only a metacharacter-free pattern is a plain substring that
maps faithfully (via By.textContains). A real regex has no faithful single-selector form here (the
same limit the XCUITest emitter hits for NSPredicate MATCHES), so it stays a // TODO.
Action mapping (UI Automator)¶
| Scenario step | UI Automator |
|---|---|
tap |
act(<by>).click() |
type (with into) |
act(<by>).text = '…' |
type (no into) |
// TODO (no resolved target element) |
longPress |
.longClick() (the platform long-press timeout; the scenario duration has no parameter) |
clear |
act(<by>).clear() — the faithful peer of the driver's own clear (BE-0265) |
delete { count } |
.click() + count × device.pressKeyCode(KeyEvent.KEYCODE_DEL) (BE-0265) |
select |
.click() + device.pressKeyCode(KeyEvent.KEYCODE_A, KeyEvent.META_CTRL_ON) (BE-0265) |
copy |
device.pressKeyCode(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON) |
back |
device.pressBack() — UI Automator's native system back, the peer of the adb driver's keyevent 4 (BE-0210) |
swipe { on, direction } |
.swipe(Direction.<UP/DOWN/LEFT/RIGHT>, 0.75f) |
swipe { from, to } |
// TODO (coordinate swipes are not generated) |
drag { on, direction } |
the same primitive as swipe { on, direction } — UiObject2.swipe is a real drag, so an element-anchored drag both scrolls and moves handles on Android (BE-0227) |
scroll { to } |
UiScrollable(UiSelector().scrollable(true)).<setAsHorizontalList/setAsVerticalList>().setMaxSearchSwipes(<max>).scrollIntoView(<selector>) — UI Automator's native scroll-to-element, bounded by maxScrolls (BE-0326) |
pinch |
.pinchOpen(0.5f) / .pinchClose(0.5f) (scale ≥ 1 zooms in) |
wait { for } |
awaitPresent(<by>, <ms>L) — a sliced device.wait(Until.hasObject(…)) that fails naming the selector |
wait { until: gone } |
awaitGone(<by>, <ms>L) — the same, on Until.gone |
wait { until: screenChanged/settled } |
device.waitForIdle(<ms>L) — findObject does not auto-wait, so this is a real condition wait, not a bare comment |
relaunch |
launch(extras) (re-issues the launch intent) |
doubleTap / rotate |
// TODO (no UI Automator gesture) |
handleSystemAlert |
// TODO (iOS-only; tap the system dialog directly on Android) |
Assertion mapping (UI Automator)¶
| Assertion | UI Automator |
|---|---|
exists |
assertTrue(device.hasObject(<by>)) (assertFalse with negate) |
value (equals/contains/matches) |
assertEquals/…(… , device.findObject(<by>).contentDescription) |
label (equals/contains/matches) |
the same over .text (.contains(Regex('…')) for matches) |
enabled / disabled |
assertTrue/False(device.findObject(<by>).isEnabled) |
selected |
assertTrue(device.findObject(<by>).isSelected) |
count (equals/atLeast/atMost) |
device.findObjects(<by>).size with assertEquals / assertTrue(size >= n) / assertTrue(size <= n) |
The adb backend has no network-interception surface, so every network assertion (request /
requestSequence / responseSchema) and the device-control family (setLocation / push /
setClipboard / …) emit a labeled // TODO naming why, exactly as the XCUITest target does for its
own gaps.
Name generation¶
- Method name
test_<sanitized>(the scenario name normalized[^0-9a-zA-Z]+→_, with a_prefix if it starts with a digit) — shared by the XCUITest and UI Automator targets. - Class name from the stem title-cased with a suffix:
UITestsfor XCUITest,UITestfor UI Automator. The CLI derives it from the output filename (the-ostem) or, absent that, the scenario filename.
Real-compile verification¶
The unit suites check the emitted text as a string; they do not prove the generated file builds or that the referenced APIs still exist at the pinned SDK version. Each device target closes that gap with a checked-in fixture that CI regenerates, compiles, and runs against a real device:
- XCUITest —
make -C demos/showcase ui-testre-generatesComponentsUITests.swiftfrom a scenario and runs it withxcodebuild teston the iOS lane (a required check). - Playwright —
make -C demos/web codegen-e2ere-generatescodegen/smoke.spec.tsfromscenarios/smoke.yamland runs it with the real@playwright/testrunner against a real Chromium on the web lane (BE-0293), a required check (codegen (playwright)inweb-e2e.yml) since it proved stable in CI. - UI Automator —
make -C demos/showcase/android e2e-codegenre-generatesCodegenAndroidUITest.ktfromcodegen_android.yamland runs it with Gradle'sconnectedAndroidTestagainst the booted emulator on the Android lane (BE-0294), a non-gating signal first, promoted to the gate once stable. Because it regenerates the checked-in.ktbefore building, a stale check-in cannot mask an emitter orandroidx.test.uiautomatorAPI drift.
Both run with no bajutsu runtime, no driver of ours, and no AI at test time — the codegen output path exactly as a downstream team would run it. See showcase for the live runs.