JsonUI
← ConceptsScreen identity and navigation assertionA test should be able to say 'we are on the my-page screen' without knowing a single element inside it. That needs one shared answer to 'what is a screen, and what is it called' — used by layouts, specs, tests, generated code and the transition diagram alike. Here is that answer, and the runtime marker that makes it checkable.~8 min read
The assertion you want to writeAsserting 'the navigation worked' by probing for some element that happens to live on the destination is fragile: the element moves, the copy changes, and the test starts reporting a layout edit as a navigation bug. A screen assertion names the destination itself. The runtime side is a marker that code generation emits into every screen — tests never spell it, and it exists only in development builds.
the whole assertion
{ "assert": "screen", "name": "mypage" }
A screen's id is its layout basename`Layouts/mypage.json` is the screen `mypage`. The same string is what a spec references, what a test's `screen` key holds, what the generated marker carries, and what a diagram node is called — there is no second naming scheme to keep in sync.• Layout directories are scanned recursively (`Layouts/**/*.json`) minus the resource subtrees. Nesting sheets and cells one level down is normal, so a flat glob would silently miss most of them; JSON at any depth under `Resources/` or `Styles/` is skipped entirely, neither classified nor scanned for references. Nothing references `strings.json` or `colors.json`, so without that exclusion they fall through to the default and become phantom screens.• Basenames must be unique across the whole tree. Two `detail.json` in different folders make the id ambiguous — that is the `screen-id-collision` error, not a silently-picked winner.• Responsive variant files normalize to the base id: `home@regular.json` is still the screen `home`. A variant is another rendering of one screen, never a second screen.
Not every layout is a screenCells and partials are layouts too, but they are instantiated inside another layout — sometimes once per data row. They must not carry a marker, must not be a valid `screen` value in a test, and must not become diagram nodes. Classification runs in a fixed order and the earliest matching rule wins.
classification order
1. explicit "role": "screen" | "cell" | "partial" on the layout root → wins outright
2. referenced as a non-screen by another layout
(cell / header / footer / cellClasses / include) → not a screen
3. "partial": true → not a screen
4. anything left over → screen
 
$ jui screens # read back what derivation decided
screen home (default)
cell conversation_cell (role)
partial filter_sheet_body (partial)
Derivation is deliberately imperfect. A screen-shaped layout that nothing references yet is classified as a screen — and so is a cell built from host-language code, because step 2 only sees references written in layout JSON: that cell then grows a marker inside its host screen's tree. Neither is a bug in the derivation; the information is not in the layouts. That is why the tools print what they decided: run `jui screens` (or the MCP `list_layouts`, which folds the same classification in), and settle any outlier with an explicit `"role"` on the layout root. When you audit, walk the complete set — `jui screens --json` lists every derived screen under `derivedScreens`. The name-shaped hint list beside it is a convenience, never a complete one: measured on a real project it flagged 7 layouts while missing 8 more. `"partial": true` still works but is a sufficient condition only, so it is not enough on its own.Screens the app owns outright — a hand-written page with no JsonUI layout behind it — are declared in `jui.config.json` under `test.appOwnedScreens: ["tokushoho", "company"]`. They are real navigation destinations, so without the declaration a legitimate id would be rejected as `screen-unknown`. An entry can also be an object — `{ "id": "tokushoho", "group": "static" }`, where `group` takes a string or an array — which is the only place such a screen can declare its transition-diagram group: with no layout there is no screen test to carry `metadata.group`. A screen test's own group still wins, and anything undeclared keeps landing in the diagram's ungrouped bucket, which is what makes a genuinely missing declaration visible. On web the page's root element carries the same `data-screen="<id>"` attribute code generation would emit, so drivers cannot tell the two apart. Having no layout is what these screens are, not something missing from them — which is why `jui verify`'s spec-coverage check passes over them: it looks for a layout that generates a screen nobody declared, and requiring a spec here would leave no way to comply, there being no layout root to carry a `role` and nothing for such a spec to describe.
The marker is a dedicated node, in dev builds onlyCode generation emits `__screen_<id>` into every screen and only into screens. It is a node of its own, never an identifier applied to the layout's root: every platform has exactly one identifier slot per node, and the root's own id is already load-bearing for existing tests. The runtime layer forms the prefixed name from a bare screen id, so generators pass the id and nothing else.
marker per platform
Platform | marker node | gate
----------------+-----------------------------------+---------------------------
iOS (SwiftUI) | 0.5x0.5 clear leaf in an overlay, | #if DEBUG in the library
| accessibilityIdentifier | modifier
iOS (UIKit) | 1x1 sibling UIView, | #if DEBUG
| accessibilityIdentifier |
Android | 1.dp Box sibling, testTag + | host app's FLAG_DEBUGGABLE
| testTagsAsResourceId on itself | (runtime check)
Web (React) | data-screen="<id>" on the root | NODE_ENV via a generated
| element (present in SSR markup) | screenMarker() helper
• A rendered screen exposes exactly one marker. Zero means the generated code or the library pin is stale; two or more is a generator bug. Both are hard errors, not warnings.• The marker sits as a sibling of the screen's scrollable, never inside the scrolling content: a node scrolled out of the viewport disappears from the accessibility tree, and the screen would stop reporting itself as displayed halfway down a long page (measured on Android API 35). It also sits at the centre of the screen root, never a corner — a generated root fills the screen, so hit-testing its top-left corner resolves to the navigation bar rather than the marker, and the assertion's `exists && isHittable` predicate fails on every screen. That is exactly what SwiftJsonUI 10.8.0 shipped; 10.8.1 moved it to the centre and is the floor for iOS.• On Android the marker also has to clear the system-bar window. The accessibility framework subtracts whatever a window above the app covers, so a node that fits entirely inside the status bar reports `isVisibleToUser=false` and `By.res` — the canonical Android predicate — never returns it. Measured on a tablet at the 48 px window edge: `y=46` invisible, `y=47` visible. Growing the marker does not fix it (the bar is taller on phones, so the size that works is device-dependent); the library insets the marker's outer node instead, which is why KotlinJsonUI 2.15.1 is the Android floor — 2.15.0 compiles fine and is simply wrong.• The gate is per platform: `#if DEBUG` on iOS, the host app's `FLAG_DEBUGGABLE` at runtime on Android (KotlinJsonUI ships as an AAR, so its own BuildConfig describes the library, not your app), and a generated `screenMarker()` helper keyed on the literal `process.env.NODE_ENV` on web. UI tests therefore run against a debug / development build — the same constraint Dynamic mode already imposes. On web that constraint got sharper in driver 1.8.3: the marker is no longer only what an assertion looks for, it is what decides a screen is ready to be tested at all. The gate it replaced was `networkidle`, which is not a statement about the screen — it says every request the page made has been quiet for a while, so one request that never finishes holds the run open until the test times out with the screen sitting there correctly rendered. Read from the published package: the strategy is `auto` by default, the screen id comes from the test's `source.layout` rather than the test's filename (basename, drop `.json`, cut at the last `@`, so a size variant resolves to the screen it varies), and `screenReadyStrategy: 'auto' | 'marker' | 'networkidle'` forces the choice. Two consequences worth checking against your own setup. A production bundle has no marker, so a suite that runs against one needs `'networkidle'` declared. And when no id can be derived, `auto` falls back to `networkidle` and says so on stderr — the fallback announces itself, because a run that quietly fell into the gate that hangs is indistinguishable from one that used the marker until both fail the same way. Under `'marker'` the same case is an error naming what to fix instead. One shape the gate asks the wrong question about, and it is worth knowing before you adopt it: a test whose expectation is that a screen does NOT appear — permission denied and something else renders, a stale session bouncing to login. Waiting for that screen's marker is waiting for the thing the test says will not happen. In 1.8.3 the strategy came from project configuration only — the published package resolved it from `this.config` with no per-test field — so one such test could not opt out without moving the whole suite back to `networkidle`. Both halves have since shipped, and this page described the declaration as accepted but inert for a few hours after that stopped being true. Since jsonui-cli 1.7.32 a test file may carry `screenReady`, and validate accepts five forms — `"none"`, `"auto"`, `"marker"`, `"networkidle"` and `{"marker": "<screen id>"}` — rejecting anything else by listing the allowed set, and rejecting the object form without a usable `marker`. On 1.7.30 the same file fails with `Unknown top-level key: screenReady`. Driver 1.8.4 is what reads it. From the published package: the file's own declaration outranks the project switch, resolved as `declared ?? this.config.screenReadyStrategy`, so a project can keep `'marker'` everywhere and let the few absence tests say `"none"`; and the object form waits for the marker it names instead of the id derived from `source.layout`, which is the form for a screen whose readiness is really some other screen's. Four of the five outcomes announce themselves on stderr — `'none'`, the object form, `'networkidle'` and a forced `'marker'` each print which of the test or the project asked for them. The fifth does not: an explicitly declared `"auto"` takes the same branch as declaring nothing, and that branch logs only under `--verbose`. Writing `"auto"` to record a deliberate choice therefore leaves no trace in a normal run, and the output cannot be told apart from a file that declared nothing. Two halves shipping apart is itself what jsonui-cli 1.7.33 now checks. Validate reads the installed web driver's version and errors when a declared key needs a newer one: with 1.8.3 installed, a file carrying `screenReady` fails with `requires the web driver 1.8.4 or newer, but 1.8.3 is installed — it does not read this key, and ignores it without saying so`, and the run exits 1. Measured around that boundary: 1.8.4 and anything above it are silent, `1.8.10` included, so the comparison is numeric rather than lexicographic — but `1.8.4-beta.1` is silent too, because the pre-release suffix is dropped before comparing, so a beta of the required version counts as the required version. The tool now documents that as deliberate — tightening it would fail every project validating against a pre-release, so a beta could be published and nobody could run it — and documents, in the same place, that the reason was written down after the behaviour was measured and questioned rather than designed in. Which of those two a rule is tells you how much to build on it, and you can only know it if whoever wrote the rule says so. When the version cannot be read at all the run says so as a warning and passes, rather than guessing in either direction, and as of 1.7.36 the two ways it can be unreadable say different things: an installed package that could not be found gets the warning above, while a run with no config and no enclosing checkout is told that no project root could be resolved and the driver was therefore never looked for, with passing `--config` or running from the project root as the fix. On 1.7.33 that second case was a traceback with no result line at all. Only web can be checked this way: the iOS and Android drivers arrive through SPM and Maven and leave nothing in the tree to read a version out of, so on those platforms the mismatch is still yours to avoid. Two things about when it fires. It is opt-in by declaration, so a suite on an old driver with no `screenReady` anywhere is not warned — there is nothing there to be ignored. And a file carrying any other validation error is left out of the check, so fixing an unrelated typo can be what makes the version error appear; that is the check reaching a file it had been skipping, not a second problem arriving with the fix.
`assert: "screen"`The target key is `name`, mirroring the screenshot assertion. It is deliberately not the step-level `screen` key: that one says where the step runs, and during a transition the two legitimately differ. `timeout`, `label`, `optional` and `when` are accepted like any other assertion, and assertions already auto-wait — there is no separate `waitForScreen`.
flow test step
{
"steps": [
{ "screen": "login", "action": "tap", "id": "submit_button" },
{ "assert": "screen", "name": "mypage", "timeout": 10000 }
]
}
It asserts the named screen is displayed, not that it is displayed exclusively: embedded screens, split panes and tab hosts legitimately show several markers at once. "Displayed" is a per-platform predicate, and since driver 1.9.1 iOS carries a second clause — `exists && (isHittable || no other screen's marker is hittable)`. The marker lives inside the generated view, so a full-screen overlay the app layers outside it (a coach mark, a loading scrim, a hand-rolled modal) covers the marker even when transparent; what separates that from a sheet is not reachability but whether something else claims to be the displayed screen. Sheets and covers are screens and carry their own marker, overlays carry none. Android matches on the accessibility tree and web on CSS visibility, so neither needs the clause. Note also that a marker means the destination has been constructed, not that the transition animation has settled — both NavigationStack and NavHost compose source and destination simultaneously while animating.
Implicit verification on every screen changeIn a flow test the driver can verify the marker at every point where an inline step's `screen` differs from the previous step's — without a single assertion written by hand. Steps skipped by `when` do not advance the tracker, a file-reference step resets it to unknown, and steps inside a block continue the enclosing screen. The wait uses `screenTransitionTimeout` (10000 ms) rather than the 5000 ms default, because real cross-screen waits after a cold start are longer. Failures are classified rather than lumped together — every class fails the step through the ordinary assertion path, so the class names the likely cause, not how seriously to take it:• `marker-absent` — no marker anywhere. The cause is the build rather than the navigation: a production build (markers are development-only), stale generated code, or a stale library pin. Fix it with `jui build` and a current pin — the failure itself is not softened. Only reported after the timeout, because a suspending navigation legitimately shows no marker while it is pending. Since drivers Android 1.8.2 / web 1.8.1 the message also names the likeliest culprit: when another app owns the foreground (a system dialog, a picker that never returned), Android says so instead of blaming your build, and only raises the stale-codegen hint when the app under test is actually frontmost; web appends the current URL, which is how an auth redirect gives itself away.• `previous-screen-only` — only the previous screen's marker is present: the navigation did not happen. This is a real test failure.• `marker-not-displayed` — the expected marker exists but fails the platform predicate: a transition-animation or predicate problem.• `children-unresolvable` — the marker is present but the screen's own element ids cannot be resolved: an accessibility regression.The switch is `verifyScreenTransitions` in the runner config, and it is on by default from drivers iOS 1.9.0 / Android 1.8.0 / web 1.8.0. An app whose generated code predates screen markers therefore fails every screen change with `marker-absent` — the intended signal that it needs `jui build` and a current library pin, not a reason to soften the check. The pins do not move in step: an `upToNextMajor` SwiftPM range picks the new driver up on the next resolve, while the Android and web pins stay put until you raise them by hand, so regenerate iOS first. A project mid-migration opts out in one line with `verifyScreenTransitions: false`.
What the validator enforces`jsonui-test validate` resolves every `screen` value against the classified layout tree plus the declared app-owned screens. Four of the five rules are errors, so a violation stops the install pipeline — pass `--no-install` when you only want to inspect.
validator rules
screen-unknown error the 'screen' value is neither a layout nor an app-owned screen
screen-not-a-screen error the 'screen' value resolves to a cell or partial — name the owning screen
screen-id-collision error two layouts share a basename, so the id is ambiguous
screen-source-layout-missing error a flow's sources[].layout path does not exist
screen-alias-unreferenced warning a declared sources[].alias is referenced by no step
Order matters when you clean up a project: giving a layout `"role": "cell"` is what makes the steps that used it as a `screen` start failing with `screen-not-a-screen`. Declare the role and fix those steps in the same commit.
Known limitsThese are named rather than papered over — a gate no driver can implement would be worse than a documented gap.• Web, first document load: the marker is in the server-rendered markup, so a screen assertion can pass while the page is still hydrating and clicks are being dropped (measured). React exposes no standard 'hydrated' signal, so the predicate cannot gate on it. It does not recur on client-side navigation, which happens after hydration.• Suspending transitions: the source unmounts to a fallback before the destination mounts, so there is a window with no marker at all (~1.2 s measured). That is why `marker-absent` is only reportable after the timeout.• No exclusivity assertion: there is no 'only this screen is displayed' form. Asserting exclusivity needs window scoping that no driver has, and co-presence is legitimate anyway (split panes, tab hosts, embeds). That same legitimacy is why the one exact detector for a wrongly-derived screen — a tree exposing markers for two or more ids — has to be a warning that lists the ids rather than a failure. It is not implemented yet.
Keep reading
Testing across three platformsWhere the screen assertion sits in the wider action / assert DSL./guides/testing
Screen compositionEmbeds and tab hosts are exactly the cases where several screens are displayed at once./concepts/screen-composition
Responsive designVariant files render the same screen differently — and share its id./concepts/responsive-design