Pular para o conteúdo principal

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 locale prop, and with the persona configured in the dashboard.
  • Copy is themeable. headerTitle, assistantName, welcomeMessage and 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

testIDElementOn screen when
assistant-launcherFloating button that opens the chatmode="bubble", the chat is closed, visible is not being controlled, triggerButton not "none"
assistant-inputThe message text fieldChat is open
assistant-sendSend buttonChat is open and the agent is idle (disabled while the field is empty)
appilots-stop-buttonStop-generating buttonChat is open and a reply is in flight — it replaces assistant-send in the same slot
assistant-clearHeader button that clears the transcriptChat is open
assistant-closeHeader ✕ that closes the chatChat is open and mode is not "inline"
assistant-suggested-promptsRow of suggested-prompt chipsChat is open, transcript is empty, input is empty, and at least one prompt is available
Why appilots-stop-button breaks the naming pattern

It 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

testIDElementOn screen when
assistant-transcriptThe scrollable message listThe chat is open (even with no messages yet)
assistant-last-messageThe most recent assistant replyAt least one assistant message exists
assistant-messageEvery other assistant replyTwo or more assistant messages exist
human-agent-messageA human operator's replyAn 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 element

Every 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).

testIDElement
assistant-confirm-cardThe confirmation card
assistant-confirm-approveApprove button
assistant-confirm-rejectReject 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.

testIDElement
assistant-action-approveApprove button on a pending row
assistant-action-rejectReject 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 for assistant-message.
  • <AppilotsChat> does not show them today. Its timeline routes only confirm actions 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 with requireApprovalFor returning 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.

testIDElementOn screen when
escalation-header-buttonHeadset button that requests a humanChat is open
escalation-bannerStatus strip shown while an escalation is openAn escalation is pending or active
escalation-offer-chip"Talk to a human" chip under the agent's offerThe 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:

  1. Dismiss the keyboard before the close step. Maestro ships hideKeyboard. Detox does not: use await 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-input is a multiline field with blurOnSubmit={false}, so the key inserts a newline and the keyboard stays up.
  2. 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.
  3. 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 with assistant-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