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() |
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'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 timings emitted are gesture
durations (
longPress'sdelay, directional swipe drags) — intrinsic to the gesture, the same honesty the iOS path applies topress(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
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. - 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.
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:
UITestsfor XCUITest,UITestfor UI Automator. The CLI derives it from the output filename (the-ostem) or, absent that, the scenario filename.
The live run on the showcase (make -C demos/showcase ui-test) is in
showcase.