Test IDs
Your end-to-end suite has to drive the assistant the way a user does: open the chat, type, send, read the reply, approve a confirmation, close it. @appilots/sdk puts a stable testID on every control that flow touches.
The testID is the only handle the SDK promises. Everything else on those controls moves:
- Copy is localized. Button labels and accessibility labels come from the i18n bundle, so they change with the device locale, with the
localeprop, and with the persona configured in the dashboard. - Copy is themeable.
headerTitle,assistantName,welcomeMessageand the suggested-prompt chips are yours to set, and the dashboard can override them remotely. - Layout moves. An id is what you match on; it is not what makes an element reachable — see Matching is not reaching below.
The IDs
Chat surface
testID | Element | On screen when |
|---|---|---|
assistant-launcher | Floating button that opens the chat | mode="bubble", the chat is closed, visible is not being controlled, triggerButton not "none" |
assistant-input | The message text field | Chat is open |
assistant-send | Send button | Chat is open and the agent is idle (disabled while the field is empty) |
appilots-stop-button | Stop-generating button | Chat is open and a reply is in flight — it replaces assistant-send in the same slot |
assistant-clear | Header button that clears the transcript | Chat is open |
assistant-close | Header ✕ that closes the chat | Chat is open and mode is not "inline" |
assistant-suggested-prompts | Row of suggested-prompt chips | Chat is open, transcript is empty, input is empty, and at least one prompt is available |
appilots-stop-button breaks the naming patternIt is the oldest id on this page. It shipped with SSE streaming and cancellation, before the assistant-* convention existed, and it has been in released versions of the package ever since — so every integrator who tests the stop button already matches on this exact string.
Renaming it would be a breaking change for all of them, bought with nothing but tidiness, and it would break silently: their build stays green and their pipeline goes red. The inconsistency is deliberate and it stays. If you are here to make the names uniform, this is the one to leave alone.
Transcript
testID | Element | On screen when |
|---|---|---|
assistant-transcript | The scrollable message list | The chat is open (even with no messages yet) |
assistant-last-message | The most recent assistant reply | At least one assistant message exists |
assistant-message | Every other assistant reply | Two or more assistant messages exist |
human-agent-message | A human operator's reply | An operator has replied during an escalation |
User messages carry no testID on purpose — your test typed that text, so it already knows it.
assistant-transcript is the container, not a message. Use it to scroll the history when a reply runs
past one screen — a matched element is not necessarily a reachable one (see Matching is not
reaching):
// Detox — bring an older reply into view before asserting on it
await waitFor(element(by.id('assistant-message')).atIndex(0))
.toBeVisible()
.whileElement(by.id('assistant-transcript'))
.scroll(200, 'up');
assistant-message matches more than one elementEvery assistant reply except the newest carries it, so a transcript with four replies has three matches. In Detox that is an ambiguity error, not a match — by.id('assistant-message') throws unless you disambiguate:
await element(by.id('assistant-message')).atIndex(0).tap(); // oldest reply
Prefer assistant-last-message whenever you mean "the answer to what I just sent": it is unique by construction, and it does not shift index as the conversation grows.
Assistant bubbles also expose their full text as an accessibilityLabel. That is deliberate: on a container View, Detox's getAttributes() returns no text, so a "the reply mentions X" assertion has to read the label.
// Detox — assert on the reply's content
const reply = await element(by.id('assistant-last-message')).getAttributes();
expect(reply.label).toContain('...');
Confirmation card
Shown when the agent proposes an action that needs the user's approval (see Agent actions).
testID | Element |
|---|---|
assistant-confirm-card | The confirmation card |
assistant-confirm-approve | Approve button |
assistant-confirm-reject | Reject button |
Inline approval row
The compact approve/reject buttons on a breadcrumb row — the gate for a normal action, as opposed to the full card a confirm action gets.
testID | Element |
|---|---|
assistant-action-approve | Approve button on a pending row |
assistant-action-reject | Reject button on a pending row |
Two things to know before you write against these:
- They are not unique. Several rows can be awaiting approval at once, so disambiguate with
.atIndex(n)exactly as forassistant-message. <AppilotsChat>does not show them today. Its timeline routes onlyconfirmactions to the breadcrumb, and those render the card above instead. You meet this row when you mount the exported<ActionBreadcrumb>yourself in a custom chat surface withrequireApprovalForreturning true. The ids are published now because the buttons' only other handle is copy hardcoded in Portuguese.
Human escalation
See Escalation for the flow these belong to.
testID | Element | On screen when |
|---|---|---|
escalation-header-button | Headset button that requests a human | Chat is open |
escalation-banner | Status strip shown while an escalation is open | An escalation is pending or active |
escalation-offer-chip | "Talk to a human" chip under the agent's offer | The agent gave up and no escalation is open |
Stability
These IDs are part of the package's public surface, exactly like its exported types:
- Adding an ID is a patch change.
- Renaming or removing one is a breaking change and gets a major bump plus a changelog entry — even though nothing in the TypeScript surface moved. A rename silently breaks every integrator's e2e suite, which is the worst kind of break: green build, red pipeline.
Every ID on this page is pinned by AppilotsChat.testIds.realTree.test.tsx in the SDK. That suite does not check ids one at a time — it puts the component in each state where a group of ids belongs on screen and asserts the group as a set, because the failure worth catching is a control shipping without an id, and only an exhaustive list notices that. It asserts the reverse direction too: an id in the tree that this page does not list fails the suite, so the roster cannot quietly drift from the code.
Sixteen of the eighteen are asserted against a mounted <AppilotsChat>, driven through real turns (a reply in flight, two replies in the transcript, a pending confirmation, an open escalation, a handoff offer) with only the network scripted.
The exception: assistant-action-approve and assistant-action-reject are pinned against a mounted <ActionBreadcrumb> instead, because no state of <AppilotsChat> renders them — the reason given in Inline approval row above. If that ever changes, they move to the chat suite and this paragraph goes away.
Writing the tests
Detox
await element(by.id('assistant-launcher')).tap();
await element(by.id('assistant-input')).replaceText('Find the nearest one');
await element(by.id('assistant-send')).tap();
await waitFor(element(by.id('assistant-last-message')))
.toBeVisible()
.withTimeout(60000);
// The keyboard is still up here, and that is fine: the sheet shrinks to
// make room for it, so the header stays on screen. See "Matching is not
// reaching" below — it used to be otherwise.
await element(by.id('assistant-close')).tap();
Maestro
- tapOn:
id: 'assistant-launcher'
- inputText: 'Find the nearest one'
- tapOn:
id: 'assistant-send'
- extendedWaitUntil:
visible:
id: 'assistant-last-message'
timeout: 60000
# No `hideKeyboard` needed: the sheet makes room for the keyboard, so the
# header is still there. See "Matching is not reaching" below.
- tapOn:
id: 'assistant-close'
Matching is not reaching
An id fixes matching. It does not fix reachability, and confusing the two is how a passing scenario fails on its last step.
In Detox, by.id(...) resolves through the view hierarchy — no coordinates, no copy, no locale. But the action is a different stage: tap() synthesizes a real touch at the element's own coordinates, and it first asserts the element is visible. So a control that matched perfectly still fails with a visibility error when it is off screen. Maestro behaves the same way for the same reason: it drives the device, and the device can only touch what is on it.
Keep that in mind for your own controls: an id you add to something under a keyboard, inside a collapsed accordion, or below the fold of a scroll view is matchable and not tappable. Scroll it into view first (whileElement(...).scroll() in Detox, scrollUntilVisible in Maestro).
The chat header used to be one of those, and no longer is
This page came from that failure, so it is worth being precise about what changed.
The chat sheet was laid out at a fixed 75% of screen height inside a KeyboardAvoidingView. When the keyboard opened, that view gave the keyboard its space, the fixed-height sheet could not shrink to match, and the overflow went off the top of the screen — carrying the header, and assistant-close with it. Filling a text field is what raises the keyboard, so the run where the agent typed into a search box was precisely the run where the close step failed, on a scenario the agent had already answered correctly. Swiping the sheet down did not help either: the gesture was aimed at the same header that was no longer there.
That was our bug, not a fact of mobile testing, and it is fixed — the sheet is now bounded by a maximum height and shrinks for the keyboard instead of overflowing. You do not need to dismiss the keyboard before closing the chat.
If you are pinned to an older SDK, the workarounds still apply:
- Dismiss the keyboard before the close step. Maestro ships
hideKeyboard. Detox does not: useawait device.pressBack()on Android, and on iOS tap something inert in the transcript —await element(by.id('assistant-last-message')).tap()— to move focus off the field. Sending a return key does not work on either:assistant-inputis a multiline field withblurOnSubmit={false}, so the key inserts a newline and the keyboard stays up. - Let the assertion tell you. Wrap the close in
await waitFor(element(by.id('assistant-close'))).toBeVisible().withTimeout(2000)so the report says "not visible" instead of a bare tap failure — the difference between a five-minute diagnosis and an afternoon. - Or don't tap at all. If the chat is under your control (
visible+onClose), close it from the host app. That path touches no layout, no keyboard and no gesture. It remains the most robust option regardless of version.
What is not an ID
Some things on screen are yours, not the SDK's, and the SDK can't promise a handle for them:
- Suggested-prompt chip labels — they come from
registerScreen({ suggestedPrompts })or from prompts mined from your project's history. Match a chip by its text, and assert that the row rendered withassistant-suggested-prompts. - The header title, avatar and welcome message — set by props or by the dashboard's personalization settings.
- Your own screens and controls. Those
testIDs are yours to add, and adding them is also the single best thing you can do for the agent's accuracy — see Registering elements.
Next steps
- AppilotsChat — the props behind the modes and controls listed here.
- Registering elements —
testIDs on your own UI, and what the agent does with them. - CI integration — running the CLI's checks alongside these tests.