Skip to content

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 launchEnvironment then app.launch() at the top. The env is the merge of config's launchEnv < the scenario's preconditions.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:

  • labelMatches with regex metacharacters — it is a Python re.search pattern; only a metacharacter-free one is a plain substring (CONTAINS). A real regex (e.g. ^Item) has no faithful NSPredicate form (ICU MATCHES is 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 / selected vocabulary.

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's baseUrl, the web launch equivalent of page.goto). Config's launchEnv < the scenario's preconditions.launchEnv is seeded via page.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's delay — intrinsic to the gesture, the same honesty the iOS path applies to press(forDuration:). A directional swipe carries 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 byId helper 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 native android:id and a Compose testTag (surfaced via testTagsAsResourceId, which carries no prefix) both resolve.
  • The act helper waits for the element before returning it. findObject alone is a single-shot query with no implicit wait. Acting right after launch() or a UI transition could otherwise race the render. An element-targeting action routes through act; a wait step routes through awaitPresent / awaitGone instead, since it targets a condition rather than an element to act on; relaunch stays direct. Both act and the two await… helpers throw an AssertionError naming the selector and the timeout; relaunch emits launch(extras), whose failure names the package and the attempt budget instead. findObject alone would instead throw a bare NullPointerException that names neither. A read-only assertion still calls device.findObject / device.findObjects directly, unwaited. That matches the driver's own assertions.
  • Each method builds an extras map (config's launchEnv < the scenario's preconditions.launchEnv) and calls launch(extras), which forwards the env as intent extras — the reverse of the adb backend's am start --es.
  • launch waits 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 next act() can otherwise race a screen still mid-layout. waitForIdle closes 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.
  • launch checks 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 first act. That act then 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_TASK tears 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. After LAUNCH_ATTEMPTS attempts launch fails, naming the windows it did see. The intent is rebuilt per attempt, which also keeps launchEnv arriving through onCreate; resuming the existing task instead would route the extras to onNewIntent.
  • A wait spends its budget across several reads, dropping the accessibility cache between them. hasObject, findObject, and every Until condition built on them resolve through the platform's per-connection AccessibilityNodeInfo cache, and only an accessibility event invalidates it. A dropped event therefore does not merely delay a read — it pins it, so every poll inside one device.wait re-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. waitSliced therefore caps each device.wait at CACHE_REREAD_SLICE_MS and calls clearAccessibilityCache between 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 a timeout: 0 step still reads the tree once, as device.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. kickWindowTracking presses 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_ATTEMPTS bounds 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 own TRACKING_KICK_ATTEMPTS budget per attempt, putting the worst case for one launch at `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. ensureWindowTracking reads the list before each attempt and catches the first: a list holding nothing at all, which one run logged as no accessibility windows reported before 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: UITests for XCUITest, UITest for UI Automator. The CLI derives it from the output filename (the -o stem) 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:

  • XCUITestmake -C demos/showcase ui-test re-generates ComponentsUITests.swift from a scenario and runs it with xcodebuild test on the iOS lane (a required check).
  • Playwrightmake -C demos/web codegen-e2e re-generates codegen/smoke.spec.ts from scenarios/smoke.yaml and runs it with the real @playwright/test runner against a real Chromium on the web lane (BE-0293), a required check (codegen (playwright) in web-e2e.yml) since it proved stable in CI.
  • UI Automatormake -C demos/showcase/android e2e-codegen re-generates CodegenAndroidUITest.kt from codegen_android.yaml and runs it with Gradle's connectedAndroidTest against 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 .kt before building, a stale check-in cannot mask an emitter or androidx.test.uiautomator API 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.