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()
longPress .press(forDuration: <sec>)
type (with into) el(id).tap() + .typeText(...)
type (no into) app.typeText(...)
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)
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, network request assertions, 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.

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). This mirrors the XCUITest split — idb coordinate-taps at run time, but the emitted XCUITest uses el(id).tap() and waitForExistence.

// 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 timings emitted are gesture durations (longPress's delay, directional swipe drags) — intrinsic to the gesture, the same honesty the iOS path applies to press(forDuration:).

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> })
swipe { on, direction } a page.mouse drag from the element center in the direction
swipe { from, to } // TODO (coordinate swipes are not generated)
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)

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()

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.

// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import android.content.Context
import android.content.Intent
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.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.regex.Pattern

private const val PACKAGE = "com.example.app"
private const val LAUNCH_TIMEOUT_MS = 5000L

@RunWith(AndroidJUnit4::class)
class ComponentsUITest {
  private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

  private fun byId(id: String) =
    By.res(Pattern.compile("(.*:id/)?" + Pattern.quote(id)))

  private fun launch(extras: Map<String, String>) {
    val context = ApplicationProvider.getApplicationContext<Context>()
    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)
    device.wait(Until.hasObject(By.pkg(PACKAGE).depth(0)), LAUNCH_TIMEOUT_MS)
  }

  @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")))
    device.findObject(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.
  • 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.

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 device.findObject(<by>).click()
type (with into) device.findObject(<by>).text = '…'
type (no into) // TODO (no resolved target element)
longPress .longClick() (the platform long-press timeout; the scenario duration has no parameter)
swipe { on, direction } .swipe(Direction.<UP/DOWN/LEFT/RIGHT>, 0.75f)
swipe { from, to } // TODO (coordinate swipes are not generated)
pinch .pinchOpen(0.5f) / .pinchClose(0.5f) (scale ≥ 1 zooms in)
wait { for } assertTrue(device.wait(Until.hasObject(<by>), <ms>L))
wait { until: gone } assertTrue(device.wait(Until.gone(<by>), <ms>L))
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)

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.

The live run on the showcase (make -C demos/showcase ui-test) is in showcase.