Release notes
All notable changes to @xenon-device-management/xenon (the Appium hub plugin).
Releases of Xenon Control, the Mac app in mac-app/, have their own entries,
headed "Xenon Control".
This project follows Semantic Versioning. Releases are
published to npm automatically when package.json's version changes on main
(see .github/workflows/npm-publish.yml).
Xenon Control 0.3.0
Xenon Control is redesigned for the people who test. It says whether you can test now and what to do next, keeps the technical details behind one switch, follows the Mac's light or dark appearance, and keeps every key and password in the Keychain.
Made for Xenon 2.17.0, and needs Appium 3.1.1 or newer. Profiles from 0.2.0 carry over.
Finding your way
- A sidebar with four places: Home, Setup, Settings and Logs. It replaces the profile list, the header of fields, the tabs and the status bar. The server's status and the one Start or Stop button are at the bottom of the sidebar. ⌘1 to ⌘4 open the places, so ⌘1 now opens Home.
- The profile is a switcher at the top of the sidebar. Rename, Duplicate, Delete, Import and Export are on the Profiles sheet: Manage profiles…, or File → Manage Profiles….
- Show technical details (View → Show Technical Details, ⌥⌘T, or the
switch at the bottom of Settings → Essentials) shows option names, folders,
commands and the app's own log lines. It is off at first. Where things
moved:
- the port: Settings → Essentials → Tests → "Port tests connect to";
- the base path, the Appium folder, the keep-alive timeout and environment variables: Settings → All settings → Technical, with technical details on;
- the launch preview and Export Config: the same Technical group, or Server → Preview Launch… (⌘P) and Export Config…, with technical details on;
- the log folder: Logs → Open log folder, with technical details on.
- Light and dark. The app follows the Mac's appearance, or View → Appearance (System, Light or Dark), and looks like the dashboard.
Home
- Home says whether you can test now, in one of a few plain states: "Let’s get this Mac ready", "Can’t start yet", "Ready to start", "Running" and "Xenon stopped unexpectedly", among others.
- One button fixes what is in the way: Use port N (the next free port), Set up this Mac, Fix it (opens the setting), How to install, or See Setup.
- While the server runs, Home shows the test address and the address colleagues on your network use, each with Copy, and Open dashboard. Copy Test Address (⇧⌘C) is in the Server menu and the menu bar icon's menu.
- Ready to start says how the last run ended, such as "Last run: today 09:42 · stopped normally".
- After a crash, Home says why, quotes the last problem the server printed, and See what happened opens Logs at that line.
Setup
- Setup replaces the Health tab. Its checks are in three groups, This Mac, Xenon and Phones, each one plain sentence. What the app can't install links to the install steps. Check again looks again.
- Set up this Mac (was Set up) shows its steps under it, on Setup or on Home. The commands, versions and folders are behind technical details.
- A slow login shell no longer leaves the app stuck. The app reads your
shell's
PATH(to find Node.js, Appium and the Android SDK) when it opens, for up to 5 seconds as before, and keeps a good read. A read that came too late is tried again, for up to 15 seconds, at most once a minute; Check again and Try again read the shell again at once, so a change to your shell is picked up without quitting.
Settings
- Essentials has the settings most people change, in plain words: which phones, tests at the same time, the port, how long to wait for a free phone, recording and history, sign-in, sharing with a hub, and AI repair.
- All settings has every Xenon option with a plain name and a line of help, in groups, with a search. Options a newer Xenon adds appear under More.
- Keys & accounts replaces Secrets & Env. Each key has a plain name and what it is for, Saved or Not set, Save, Clear (which asks first) and "Used by this profile" (was "inject in this profile").
Keys and passwords
- The Cloud access key and the proxy password are kept in the Keychain, for each profile, and are no longer written into the launch's config file. A copy of a profile gets its own, and deleting a profile clears them. One that 0.2.0 kept in a profile moves into the Keychain when 0.3.0 first loads it. Profile files and exports never contain them.
- Xenon gets the cloud key and the proxy password from the app at each
start, never from a file. (Technically: as
CLOUD_KEY, with the cloud user name asCLOUD_USERNAME, and asHTTPS_PROXYandHTTP_PROXY.) - A user name or key typed into a cloud provider's address is cut out when it is saved, and a note says to save the key in Keys & accounts.
Logs
- Everything or Problems only, a search, and the time of each line. Problems only shows the lines with error or warning words, Xenon's own ❌ and ⚠️ marks, and Appium's "No route found" and "No drivers have been installed" lines.
- Copy and Save as… take the lines shown, with their times. A saved file starts with a line that says what it is: "Xenon Control log · 8 October 2026 · Problems only · 3 of 1,204 lines".
- The app's own lines show only with technical details on, except the ones that say how the server stopped. Open log folder is there with technical details on.
- A crash is kept when the window is closed: when it opens again, Home's reason, the Logs dot and the lines are there. Clear doesn't change Home's reason, and Problems only always shows the line that says how the server ended.
Accessibility
- Every action works from the keyboard, with a visible focus ring and a skip-to-content link. Arrow keys move within the sidebar, tabs and choices. Moving through the sidebar never changes the place; Enter or a click does.
- Status changes are announced, each status has an icon and words, and text and controls meet WCAG 2.1 AA contrast in both themes.
Known issues
- What the proxy carries. With a proxy password set, Xenon's webhooks and
its download of the Chrome driver go through the proxy too, unless the
profile's
NO_PROXYnames their host. - Colons in proxy passwords. Xenon 2.17.0 cuts a proxy password at its first colon (:). Keys & accounts warns about it. Use a password without a colon until Xenon is fixed.
2.17.0
A hub keeps the Device logs of sessions on its nodes' phones, a node's iPhone is no longer refused by its hub, network capture's certificate loads everywhere, and startup no longer rewrites a database's migration history.
No database migration. Upgrade the hub and its nodes together: a node's phones' Device logs need both (#505), and the capture certificate is made on the server a phone is plugged into (#508).
Changed — operator action may be needed
- Startup no longer rewrites a database's migration history (#509).
2.16.0's repair (#493, #502) is removed: Xenon no longer marks migrations
applied or rolled back (
migrate resolve), no longer tries migrations on a copy beside the database, andnpm run db:migrateno longer warns of schema changes no migration makes. #482's rule stays:prisma migrate deployfor a database whose history matches its tables,prisma db pushfor any other, with--accept-data-lossonly on a database with no history (every database made with the default settings, which 2.16.0's repair never touched). Whendb pushwould drop data from a database with a history, the server stops and prints, with its own paths, a backup command, the command that lets the listed changes go, and (when no failed migration is recorded) how to try the migrations on a copy first.- A history 2.16.0 changed is read like any other; nothing needs to be done about it.
- If a 2.16.0 start was stopped while it tried migrations on a copy, delete
any
<database file>.xenon-trial-<number>file (and its-journal,-walor-shm) left beside the database, once no 2.16.0 server is running. Only SQLite, and only a database with a history whose tables had been changed by hand.
- A node's phones' Device logs need the hub and the node on 2.17.0 (#505). With an older node, the hub logs it once and those sessions keep an empty Device logs tab.
Added
- Device logs for sessions on a node's phones (#505). On a hub with the
dashboard on, a session that runs on a node's phone (Android, iPhone or
simulator) gets the same Device logs as one on the hub's own phone, from
the node, while the test runs and to its last line. Its AI failure analysis
gets them too. The hub collects them about every 10 seconds, keeps
collecting after a hub restart without saving a line twice, and leaves out
sessions with
xe:save_device_logs: false. A node stops recording a session if no hub asks for it within 2 minutes, so a hub with its dashboard off costs the node nothing.
Fixed
- A hub no longer checks a node's iPhone itself before a session (#507). The check asked WebDriverAgent on the hub's machine, and when nothing answered there it tried to start the hub's own stream for the phone. So a session on a node's iPhone was refused as "unhealthy and could not be autonomously recovered" when the node ran on another machine, and with hub and node on one Mac the hub could start a second WebDriverAgent on the iPhone the node drives. The node still checks the phone when it starts the session.
- Network capture's certificate has a valid serial number (#508). Through 2.16 the CA Xenon generates had its serial encoded with illegal leading zeros, so OpenSSL 3, Go and BoringSSL-based clients refused it, and the Android system-store file name wasn't the one Android looks up. A CA written by an earlier version is replaced the first time a session uses the interceptor, and phones that had the old CA installed get the new one at that session's install.
2.16.0
Open dashboards follow their users' sign-ins and teams, captured network traffic stays with admins everywhere it was leaking, the database decides how it is updated at startup, and iOS simulator sessions start again.
No database migration. Upgrade the hub and its nodes: iOS sessions, their
Device logs and the proxy option run on the server a phone is plugged into.
Changed — operator action may be needed
- The database, not
databaseProvider, decides how its schema is updated at startup (#482, #493, #502).prisma migrate deployruns for a database that keeps a migration history matching its tables,prisma db pushfor any other, accepting data loss only on one with no history, as before. No action is needed. WithXENON_AUTO_MIGRATE=false, run the command Xenon would; from a source checkout,npm run db:migratechooses for you. A database whose history has fallen behind its tables is repaired (see Fixed). - Failure categories come from what Appium and the drivers really report (#485), and only the failure that ended the session decides. Two new categories, each with a runbook: Session lost (the phone's automation helper or the session went away mid-run) and Stale element (the test used an element the app had redrawn). WDA failure and Xenon command failure are no longer given to new sessions, a session ended by the server shutting down is Hub restart, and a failure Xenon can't recognise is Unknown. Older sessions keep their category. A webhook template or report that filters on the two retired categories needs the new ones.
- OpenTelemetry follows Xenon's own settings with tracing on (#483).
Metrics go only to
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, never to the trace URL +/v1/metrics, andOTEL_METRICS_ENABLED=falsestops them. Logs go toOTEL_EXPORTER_OTLP_LOGS_ENDPOINTthrough Xenon's exporter. Nothing is sent over OTLP for a signal whose URL isn't set. A collector that received metrics at the trace URL needs the metrics URL set. - In JSON log mode, Xenon's lines go out at their own level (#483), so
--log-levelapplies and errors go to stderr. - The
proxyoption applies to every call to another server (#488): a session's commands, screenshots and heartbeats, device control, recordings, the live-preview and logcat sockets, and a node's calls to its hub, not only the session's create. Hosts inNO_PROXY, and this machine's own addresses, go direct. Itsprotocolandauthare now used, andtlsRejectUnauthorizedapplies to a node or cloud provider reached through it. Without the option, these calls followHTTP_PROXY,HTTPS_PROXYandNO_PROXYas Xenon's other calls do (#483). - A live dashboard connection is closed when its sign-in stops being
accepted (#500), and Socket.IO's reconnect is checked like a new one. A
client of your own on a bearer token must fetch a fresh token for each
connection (a function as
auth, as the docs show): one with a fixed token is refused once the token runs out. xe:save_device_logs: falsekeeps a session's device log off (#494). Through 2.15 the capability was read nowhere and every session's device log was saved while the dashboard was on. It is now a switch likexe:record_video, on by default; any value buttrueturns it off, and the Device logs tab says so.- The first session on an iOS simulator takes a few minutes (#503): the XCUITest driver builds and starts WebDriverAgent itself, as it does without Xenon.
Security
- A live dashboard connection follows its user (#500). Its role and teams change as soon as an admin changes them: an Admin made a Member stops getting network capture events, and a Member taken off a team stops getting its phones' events, without reconnecting. A connection whose sign-in is no longer accepted is closed and its reconnect refused: the user made Inactive or deleted, the sign-in signed out (a password changed or reset elsewhere included), the API key revoked or expired, the user's access key rotated, or the bearer token run out (when the REST API stops taking it). A change made on another server sharing the database applies within a minute. Through 2.15 a connection kept the role, teams and sign-in it connected with until it reconnected: a demoted or disabled admin's open dashboard kept receiving every captured request's headers and bodies.
- Captured network traffic is sent live to admins only (#486). The
interceptor_request,interceptor_session_startedandinterceptor_session_stoppedevents reach only Admins and Super admins. Through 2.15 every dashboard client that could see the phone got each captured request's headers, and its bodies withcaptureBodieson, Members included, while the REST routes and the Network panel showed them to admins only. - Selector Health's live events follow the page's rule (#486). A Member's
dashboard gets
selector_fixed,_muted,_unmuted,_cancelled,_regressed,_progressand_resolvedonly for selectors healed in a session they may see. Through 2.15 every dashboard client got every selector event, including the regression banner for selectors hidden from it. - A session's command log no longer keeps its network capture (#496). On a
hub with its dashboard on, a test's own
xenon: exportHar,getRequestsandgetMocksanswers, andaddMock's mock, were saved whole with the command: everyone who could see the session read them throughGET /session/<id>/session_logand bug reports, and the first 500 characters of each went to the AI provider when the session's failure was analysed. The log now keeps the command's name and whether it worked; a failed call keeps its error. The test still gets the whole answer. Rows saved by earlier versions stay until their session is cleaned up (#496 shows how to clear them now). xe:save_device_logs: false(#494, above) keeps the device log of an app whose log may hold sign-in tokens or personal data off the server.
Fixed
databaseProvider: postgresqlno longer stops the server (#482). With it, a database made with the default setting stopped startup with P3005 "The database schema is not empty". With the default, a database first made withpostgresqlwas moved past its migration history, so a laterprisma migrate deployfailed on a migration it had already applied, and any table beyond its migrations was dropped. A database with a history that differs from its tables is now updated without dropping anything; if dropping something would be needed, the server stops and says what to do.npm run db:migratechecks the database URL first and no longer rewritesprisma/schema.prisma.- A database that keeps a migration history is brought up to date by
repairing the history, not by
db pushfor good (#493, #502). 2.15.0 sent a database whose history had fallen behind its tables, or that records a failed migration, todb pushat every start, and the first release whose migration drops a column or adds a unique index would have stopped it.- Xenon now records what the tables already have (
migrate resolve --applied) when they are exactly what the first migrations make, then runsmigrate deploy. It records only what the tables can show: a migration that changes rows (a backfill), or makes a trigger, a view or a collation, is run rather than recorded. - A failed migration is recorded as applied only when it failed because what it makes was already there. One that failed on the rows stays failed, runs again at each start when that can't do any of it twice, and otherwise stops the start with the commands to finish or undo it by hand.
- When something was added to the tables by hand, Xenon tries the
migrations on a copy next to the file (
VACUUM INTO; it needs twice the file's size plus 64 MB free there), and runs them on the file when they work there and the copy then has the whole schema, keeping what was added. It doesn't try when the tables lack part of what their recorded migrations make, or when the history names migrations this version doesn't have. Otherwise it usesdb push, which still stops rather than delete a table or column that holds data. - That stop now prints one command with your paths that backs the file up
and, only once the backup is made, runs
db push --accept-data-lossto let the listed tables or columns go. Its old advice failed in two of its three cases. A failed migration (P3018) at startup says what the next start will do with it.npm run db:migratewarns whenprisma/schema.prismahas changes no migration makes.
- Xenon now records what the tables already have (
- Sessions on iOS simulators work again (#503). Since 1.0.0 a session on a
simulator was refused ("Device
is unhealthy and could not be autonomously recovered") unless WebDriverAgent already ran on it: Xenon checked for WebDriverAgent before the driver had started it, and rebooted the simulator instead. The check is now made for real iPhones only. A simulator session also no longer ends failed after passing, and its video is recorded. - iPhone and simulator sessions' Device logs hold the whole run (#504).
Through 2.15 an iPhone session saved only some of its device log, and a
simulator session saved none. A session now records the log the XCUITest
driver captures, from before the app launches until the session ends, each
line once. A session with an app keeps what the app's own code logs, every
error and fault inside it, what the phone says about its launch, state and
end, crash reports and memory kills that name it, and every fault on the
phone. The Device logs tab says so, and Errors only finds the phone's
errors. A test's own
getLog('syslog')still gets every line; sessions withappium:skipLogCapturehave no device log. - Telemetry (#483): every session's span is sent when the session ends,
however it ends, with its commands as children; a failed command's span has
status error and the exception; spans of sessions still running on a node
when the hub stops are sent at shutdown, saying so.
XENON_OTEL_DEBUGprints log records and metrics as well as spans. - Smaller fixes (#483): a
XENON_MCP_TOKEN_TTL_SECthat isn't a whole number of seconds above 0 is ignored with a warning; Android discovery tries adb again a minute after it fails to start, and says why it failed; the "no device" error no longer ends its first sentence with two periods; the example Loki stack deletes logs older than 24 hours. - Xenon Control: a setting changed just before creating or duplicating a profile is kept (#479). The launcher saves edits shortly after you stop typing, and creating or duplicating a profile dropped an edit still waiting to be saved. The fix ships in Xenon Control 0.2.0; the 0.1.3 download doesn't have it.
Docs
- The documentation site (
website/) is now the one copy of the user docs, checked against 2.15.0 and updated for this release's changes (#484, #489, #491, #495, #497, #498, #499). Its configuration page lists theproxyoption's fields again. The Kotlin SDK 2.4.0 page coversselfHealing(false), which turns healing off for a session (#492). The docs say correctly whichhealingTiersvalues ran which tiers through 2.14 (#481).
2.15.0
Self-healing works as written and heals to the right element, a slow AI provider can't hold a test, an Android session's Device logs hold the whole run, and what a test typed, what its app sent and what its screen showed stay with the session.
One new database migration, and three things to do when you upgrade:
re-mint session tokens, set XENON_PUBLIC_URL if Xenon emails password
resets, and run Node 20.19+, 22.12+ or 24+ (below). Upgrade the hub and its
nodes: healing, selector learning, the H.264 preview and an Android
session's Device logs run on the server a phone is plugged into.
Changed — operator action may be needed
- Database migration: one new column,
LocatorEtalon.path(#465), which Resilio heals with.runMigrationsapplies it at startup. If you run withXENON_AUTO_MIGRATE=false, apply20261004174413_locator_etalon_pathyourself before starting this version. Selectors learnt before it get their path the next time they are found. - Session tokens minted by 2.14 or earlier are refused: mint new ones
(#467). A session create now refuses a session token without the
sessionsscope, whether or notXENON_REQUIRE_SESSION_TOKENis on, and older tokens carry no scopes.POST /auth/tokengives asessionTokenonly to a credential withsessionsoradmin, and only for a grant that includesappium:use. - Set
XENON_PUBLIC_URLif Xenon emails password resets (#467), to the server's address (https://xenon.example.com,http://lab-mac:4723; the dashboard's…/xenon/address works too). Reset links now point there, never at theHostof the request. Without it, self-service reset stops: the sign-in page sends people to an administrator, whose reset links are handed to them rather than emailed. Each device'sdashboard_linkuses it too. - Node 20.19+, 22.12+ or 24+, with npm 10+ (#473).
enginesnow states the versions Appium 3 runs on; the old range listed Node 14, 16 and 18, which Appium 3 never supported. xe:options.healingTiersholds for every session, and two values mean something new (#466).[]turns healing off for that session. A value that isn't a list of tier numbers from 1 to 5 ("1,2,3",["1","2"],[1, 6]) runs only tiers 1, 2 and 3, which stay on the server, with a warning in the server log once per session.[], and a value that wasn't a list or had no numbers in it, used to run every tier; a list that held some numbers ran those and skipped the rest ([1, 6]ran tier 1,[6]none).- The live
session_commandevent is a summary (#476): which command ran, how it went and how it healed. It no longer carriesbody,response,screenshot,url,titleorsubtitle. Neither the dashboard nor Xenon Studio read them. A client of your own that did should read the session's commands fromGET /xenon/api/session/:sessionId/session_log. - AI engine settings saved on the page win (#464). The provider, model
and base URL saved on the AI engine page or through
POST /xenon/api/confignow survive a restart, and replace the plugin option or environment variable the server was started with.POST /configrefuses a value that can't work with400 invalid_setting: an unknown provider, a model name with spaces or over 200 characters, or a base URL that isn't http(s) or holds a user name, password or query. - Ending a failed session no longer waits for the AI (#461). The failure
category is saved before the end returns; the analysis follows within 2
minutes, or not at all, so
ai_analysismay still be empty whendriver.quit()returns. Test connection now fails on a rate limit. - A session Appium ends for being idle is filed as a Timeout (#463),
with Appium's own reason ("New Command Timeout of 60 seconds expired.
..."). The
session_failedwebhook's failure reason changes the same way. It read "Driver shut down unexpectedly" and was filed as Unknown. - Elements found in a screenshot answer like real ones (#465, #472). A
-custom:ai-textor-custom:ai-iconfind that matches nothing fails with W3Cno such element(it wasunknown error), so a client wait retries it, and self-healing no longer runs on it.getTexton an element AI vision found fails withunsupported operation. An element belongs to the session that found it: another session's use of its id answersno such element, and it is dropped when the session ends. - An Android session keeps far more device log lines (#478): up to
12,000 rows in the
Logtable, where it kept about 100. A busy phone reaches that in about five minutes; after it only errors are kept. Build cleanup removes them with their session, as before. - Servers with
enableDashboardoff, nodes included, now store selector fingerprints (#470): an element's text, labels and position, in their own database. Nothing is sent anywhere, and turning self-healing off stops it. On an iPhone, learning a new selector can delay the test's next command, once per selector.
Security
-
A session's
healingTiersnow keeps healing from sending its screen to the AI provider (#466). Through 2.14 it was ignored for a session with video off (xe:record_video: false) on a server withenableDashboardoff, a node's for its hub included. Such a session healed with every tier, so its screenshot, and for the LLM its page source, could reach the AI provider. If you ran sessions like that with an AI provider set up, their screens may have gone to it. Failure analysis is separate and doesn't readhealingTiers. -
Credentials and tokens (#467):
- An Inactive or deleted user's access key and token, or session token, no longer create Appium sessions as them. They count as wrong credentials, and a create whose owner can't be looked up is refused.
- A Bearer token's
adminscope stops counting once its user is demoted to member. A session token takes over someone else's lease only asresolveActordecides, on its user's role now. - A token or API key minted with a Bearer token or an expiring API key expires no later than it. A 1-hour token could make one that never expired.
- Rotating your access key needs a dashboard sign-in or a credential with
the
adminscope.POST /apikeysrefuses unknown scopes. - The appium-dashboard-plugin's address and each device's
dashboard_linkno longer come from theHostheader of the first request after startup, which anyone could send without signing in.
-
The event log no longer keeps a session's own data (#474, #475). Captured network requests (headers with sign-in tokens and cookies, and with
captureBodiestheir bodies) and every command's arguments and answer (the textsetValuetyped, page sources, screenshots) were written to theEventLogtable, kept forXENON_EVENT_LOG_RETENTION_DAYS(30 days by default), and stayed after the session or its build was deleted. Nothing in Xenon reads that table, so the API and the dashboard never exposed the rows, but they were in the database file and its backups. Captured requests now get no row, and a command gets a summary. The session's own capture, its command log and the dashboard are unchanged.To remove what an older version wrote, wait out the retention, or stop the server and run this against the file your
DATABASE_URLnames (by default~/.cache/xenon/xenon.db):sqlite3 ~/.cache/xenon/xenon.db "DELETE FROM \"EventLog\" WHERE type IN ('session_command', 'interceptor_request'); VACUUM;"VACUUMrewrites the file so the deleted rows don't stay in its free pages. Backups taken before the delete still hold the rows. -
The live
session_commandevent no longer carries what a test typed or what a command answered (#476). Every dashboard connection that could see a phone, teammates and admins who weren't running the session included, received each command's arguments (passwords included) and its whole answer. -
GET /xenon/api/configno longer shows a user name, password or query value in the AI base URL (#464).
Added
- A runbook for every kind of failure (#468), behind a failed session's "Open runbook" link: Element not found, App crash, Permission blocked, WDA failure, Xenon command failure and System overload join Timeout and Hub restart. Each says which messages put a failure there, what usually causes it and what to try.
Fixed
- An Android session's Device logs hold the whole run (#478). They held about 100 lines from the start of the run: after the first command nothing more was saved, so a crash later in the run was never there, and each line carried the time it was saved. A session now records its phone's log from when the phone was given to it until it ends, however it ends. Each line is kept once, with its own time (on the server's clock, so it lines up with the commands, even for a phone in another time zone) and its level, so Errors only finds a crash's "FATAL EXCEPTION". A session keeps up to 10,000 lines, then up to 2,000 more errors, and says so in the log. A log tab with 500 rows or more now draws only the rows on screen, so a long one opens at once. The App crash, Element not found and Unknown runbooks send Android testers to Device logs again. A session on a node's phone isn't recorded yet, and iPhone sessions are unchanged.
- Self-healing no longer heals a selector to a different element that took its place (#469). Learnt fingerprints held only an element's position and type: learning asked the driver for attributes with a command the drivers don't have. So a broken selector could heal to whatever sat in the same spot, such as "Cancel order" where "Pay now" used to be. Fingerprints now record the element's attributes, and a heal by position is refused when the element's id names another element and none of its text reads the same. A renamed label, a renamed id that kept its text, and a renamed text that kept its id still heal. Fingerprints learnt before this release are learnt again on the next find that works, and no longer keep an element's value, which on a text field is what the test typed.
- Self-healing learns on every server (#470). Fingerprints were learnt
only with
enableDashboardon, and never on a node, so a broken selector elsewhere more often went on to OCR, Visual AI and the LLM. - Healing tiers that didn't work (#465):
- Resilio never ran. It now heals a positional selector broken by a layout change, and leaves renamed or missing elements to Fuzzy XML instead of guessing.
- The OCR tier read a field tesseract.js 7 no longer returns. It works
again, and matches text across neighbouring words, as
-custom:ai-textdoes. - An element healed by OCR or Visual AI was tapped during the find, so the test's click tapped it twice.
- A heal on a node's phone was recorded nowhere. It is now recorded on the hub, on the session's page and in Selector Health.
- Selector verification counted a build as clean when the selector's find failed outright.
- With autowait on,
setValuewaited on an element named after the text being typed.
- A provider that doesn't answer can't hold a test command (#461). Every
AI call gives up after 30 s and cancels its request: the LLM and visual
healing tiers,
assertVisualState,analyzeScreen, ai-icon finds, Test locator, Omni-Scan and Test connection. - A rate limit is a failed call (#461). Gemini's was saved as the text
CONNECTION_OK_RATE_LIMITEDin place of the failure analysis. At most 4 analyses run at once, and one that fails no longer erases one saved earlier. - The AI provider in force is the one chosen (#461). Choosing a provider on the AI engine page, or only in the plugin options, now turns failure analysis and the LLM and visual healing tiers on or off without a restart.
- The AI engine page (#464) no longer shows Temperature, Max tokens or Top P, which no AI call used, or asks for an Ollama key that never existed.
- OCR works on a server with no internet access (#464). Tesseract's English data ships with the plugin (about 3 MB more), instead of being downloaded on first use into the directory the server was started from.
- A Live devices tile left an Android phone captured twice, and an H.264
preview froze when its capture stopped (#459), with
streaming.androidH264on. A tile now asks for MJPEG first, and a viewer still playing H.264 keeps it until they leave. On a hub, upgrade the nodes too: a node on 2.13 or older keeps its H.264 capture until its idle stop. enableDashboardis described by what it does (#462, #470): a full record of each session for the dashboard, which is always served at/xenon/. Runbooks point at the session page's current Commands tab and Why it failed card, and the Infrastructure runbook, for a failure Xenon never reports, is gone. A session without a video says whether recording was off.- A bug report's video clip uses Xenon's own ffmpeg (#473), so it works on a server started from the Mac app.
- Xenon Control's Database URL is kept in the Keychain and used (#473). The Settings field saved it in the profile as plain text and never passed it, so the server always used its default database. A URL left there is moved into the Keychain but not turned on: tick "inject in this profile" to use it. The fix ships in Xenon Control 0.2.0; the 0.1.3 download doesn't have it.
- Sign-in and accounts (#467):
XENON_USER_SESSION_TTL_MSlonger than a day now keeps people signed in that long, the password reset email says how long the link lasts, the API keys page's team help is right, and with the appium-dashboard-plugin on an HTTPS Appium server the dashboard no longer stalls for about 4 s every 30 s.
2.14.0
Settings, options and commands that said they worked now do: a phone keeps
its team when it reconnects, its network comes back however a session ends,
the Shell tab runs only what it lists, and xenon: scripts say when they
didn't do the work.
One new database migration, and three changes a webhook receiver or a test may notice (below). Upgrade the hub and its nodes: the network, Shell and iPhone tap fixes run on the server a phone is plugged into, and the team, webhook and script-forwarding fixes on the hub.
Changed — operator action may be needed
- Database migration: one new table,
DeviceSetting(#454). It keeps each phone's team, tags, maintenance and reservation apart from itsDevicerow.runMigrationsapplies it at startup, and the first start copies the settings of the phones already listed into it. If you run withXENON_AUTO_MIGRATE=false, apply20261004140000_keep_device_settingsyourself before starting this version. - A generic webhook's
session_failedbody has new field names (#452):sessionId,sessionName,failureReason,udid,deviceName,platform,osVersion,startTime,endTime, in place of the session's database row (id,failure_reason, capabilities, ...). A receiver that read the old names needs updating. Slack messages, templates and Send test now use the same fields, one documented set per event.session_failedis also sent when a session times out for inactivity, its driver crashes or its heartbeat stops, once per session. - A
xenon:orxe:script Xenon doesn't have fails withunknown command(#456), instead of answeringnull. So doxenon: assertVisualStatewhen it can't check (no AI provider, a failed call, an answer that isn't true or false), andvisualTap/smartTap { icon }when they can't look. A test that relied on the old silentnullor placeholder pass now sees an error. - The
interceptorserver option now applies (#455). It did nothing; now it is the default for sessions that don't set their own, and the session's capability wins field by field. A server started withinterceptor.enabled: truecaptures the network of every Android session. maxSessionscounts Appium sessions only (#452), running or starting. Live previews, recordings and idle SDK leases no longer take a slot, and a burst of creates can no longer pass the limit together. A value below 1 means no limit.- Saved Maintenance and Settings values now apply (#451, #453), from the
next run or command and without a restart, in place of the option the
server was started with. A value that can't work (a retention window of 0,
a bad cron expression, a switch that isn't true or false) is refused with
400 invalid_setting.
Security
- The Shell tab runs only the commands it lists (#450). Its allow-list
matched a command's beginning and passed the rest to the phone's shell, so
a member could run any command on a phone they could control. Commands now
match the list word for word, in plain words. On iOS, a command can no
longer name another device,
listis gone (it showed every device on the Mac), simulator commands take no paths, and real iPhones on iOS 17+ answer (the command now goes through the phone's tunnel).
Fixed
- A phone lost its team, tags, maintenance and reservation when it
reconnected (#454): unplugged or rebooted, offline in adb, a server
restart, or a node going away for a while. A team's phone came back in the
shared pool, visible to every member. Settings now survive; a reservation
still ends at its time. A team can't be deleted while one of its phones is
away, and
PUT /device/:udid/teamcan move such a phone.removeDevicesFromDatabaseBeforeRunningThePlugin, which did nothing, now makes a server forget the settings of its own phones at startup.POST /device/tagsanswers 404 for a phone the server doesn't have. - A session's phone stayed offline, or pointed at a dead proxy, when the session didn't end through the test (#455). A network profile and network capture were undone only when the test deleted a session Xenon kept in memory. After Appium's new-command timeout, the idle release, a stale heartbeat or a shutdown, the phone kept its state and the capture was lost. Every ending now puts back what the phone had (only the radios that were on, and the phone's own proxy) and saves the capture, and the next start undoes what a crash left. Network profiles use the same adb as the rest of Xenon, iPhones and simulators get only the command delay, and a hub no longer changes its nodes' phones' network. A Member's Network panel says that only admins can see network requests.
- Assign team on the Devices page failed with a 404 (#452).
- Webhook templates and Slack messages (#452): a failed session's Slack message said "undefined", template chips didn't fill in, a value with a quote or line break broke a template, and Send test ignored it. The message format (Slack or JSON) can now be chosen.
- Device control captured an Android phone twice with
streaming.androidH264on (#452). It plays the H.264 stream, and a browser that can't gets MJPEG alone. - The AI self-healing switch on the Settings page did nothing (#453).
Turning it off now stops healing a failed
findElement(and learning selectors) from the next command, and the page shows the saved choice. The switch applies to the server it is saved on. - Maintenance values were ignored by the cleanup job (#451), and saving more than one setting failed with a 500. The Settings page showed a 30-second health-check default; it is 5 minutes.
- Options that did nothing now work (#451):
maxConcurrentRecordingsandrecordingsAssetsPath(the environment variables remain the fallback),XENON_JSON_LOGGING(no longer overridden by the option's default),emulators(boots its AVDs with the defaultplatform: both; it is a boot list, not an allow-list), andappium:iPhoneOnly/appium:iPadOnly(they filter by model). xenon:scripts that answered as if they had worked (#456):assertVisualStatepassed without checking. It now asks the AI provider a true/false question about a screenshot, and accepts a plain-string condition.analyzeScreenand device control's Omni-Scan sent no screenshot. They now do, andai_insights_errorsays whyai_insightsis missing.smartTapand the-custom:ai-textlocator never matched text of several words, such as "Sign in".- On iPhones, taps and elements found by OCR or the AI, and by the OCR and Visual AI healing tiers, landed at 2-3x their position (screenshot pixels, not points).
- On a hub with its dashboard on, autowait, Omni-Vision and network-capture
scripts for a node's phone answered
nullwithout reaching the node. setSessionName,setSessionStatus,debug,addTagandcaptureEvidencethrew or silently did nothing where the server keeps no session record. They answer{ recorded: true }or{ recorded: false, message }and never fail the test.
2.13.2
A leased phone stays its lease holder's, a lease-bound session no longer waits in the session queue, and a config file may leave options out.
No new database migration, and nothing to configure. Leases, the ownership checks and the session queue run on the hub: on a hub with nodes, upgrade the hub. The Kotlin SDK's leased parallel runs (SDK 2.2.0) need this release.
Fixed
-
A lease-bound session was ended after 60 s without a command (#448), whatever its
appium:newCommandTimeout: the server'snewCommandTimeoutSecapplied instead. A session now keeps its own timeout, as any allocated session does. -
The lease holder was refused their own phone (#448). Device control on a freshly leased phone answered
409 device_in_use_by_sessionto everyone but admins, and after a lease-bound session ended the live preview and recordings refused it too. The holder (the key that took the lease, or any credential of the user behind it) may now control, preview and record it. -
A leased phone could be freed, or used by someone else, while its lease was live (#448). A lease holds its phone with
busyalone, and:- the idle sweeper freed it a new-command timeout after it was taken, before a slow first session had started on it;
- a lease-bound session ending freed it while the lease went on;
- device control, the logcat stream, live preview and recordings only read
busy, so once it was cleared anyone could drive, watch or record the phone.
The sweeper now leaves a leased phone to its lease's heartbeats and expiry, a session's end hands the phone back to its lease, and all four check the lease itself. Anyone but its holder gets
device_held_by_another_user("leased by …"), or reasonleasedfor a recording. On a hub, a preview of a node's leased phone is refused before the call reaches the node. -
A lease-bound session waited in the session queue (#448), behind every create waiting up to
deviceAvailabilityTimeoutMsfor a busy phone, though its phone was already its own. It no longer queues. Other creates still queue first come first served. -
A create stalled on a leased phone while other phones were free (#448). Allocation offered the lock only its first candidate, which the lock skips when a lease holds it. It now offers every candidate, and only those exact rows, so a reserved phone with the same udid on another host is never taken in a candidate's place.
-
A create that timed out could leave a phone claimed for 10 minutes (#448), when an attempt still running took it after the wait gave up.
-
A config file had to set 23 options it didn't need (#446).
schema.jsonmarked them required, though each has a default, so Appium refused a--configfile that left one out, the README's own sample included. Flags were unaffected. -
A session's Device and Debug logs tabs showed "log · Command" instead of the log line (#447). Lines now show their text, and errors and warnings are marked.
2.13.1
iPhone control that does what it says: keys after the live preview restarts, clipboard writes that land, and refusals the dashboard shows.
No new database migration (2.13.0's is applied at startup unless
XENON_AUTO_MIGRATE=false), and nothing to configure. The iPhone fixes take
effect on the server the iPhone is plugged into: on a hub with nodes,
upgrade the nodes for their phones.
Fixed
- iPhone keys stopped working after the live preview restarted (#444). Each preview starts its own WebDriverAgent session, and Xenon kept the first one. After device control was closed and opened again, Home and the volume buttons failed until a lock or a swipe happened to recover, and a key the iPhone has could be reported as one it doesn't. Keys now go to the current session, and to the one WebDriverAgent names when it refuses an old one.
- The iPhone's Home fallback never worked (#444). WebDriverAgent takes its home-screen command only outside a session, and Xenon always sent it inside one. The same call is used by the clipboard read.
- Writing an iPhone's clipboard reported success and changed nothing (#444). iOS ignores a clipboard write from an app in the background. WebDriverAgent is now brought to the front for the write, the text is read back before it counts, and the app that was in front, or the home screen, is put back. A write takes about 3 s.
- The side buttons on device control said nothing when a press failed (#444). Home, Back, the app switcher, volume, lock and unlock now show why in a toast. The failure only reached the browser's console.
- A refusal showed its code instead of its sentence (#444): a member
releasing someone else's reservation saw
not_reservation_holder. - A refusal was shown twice (#444): an admin saving Settings, AI engine or Maintenance got "Only a super admin can change the lab's settings." twice.
2.13.0
Tighter access rules, answers that say what really happened, device allocation that acts on the phone you name, and a complete API reference.
Upgrading. One database migration (a reservation now records who took
it); npm run db:migrate applies it, and npm run dev runs it for you. A hub
and its nodes can be upgraded in any order. Read Access below before
upgrading: some calls that worked now answer 403.
Access
- Changing the lab's settings needs a super admin (#439).
POST /config,POST /config/reset-metricsandPOST /config/test-airefuse anADMINwith "Only a super admin can change the lab's settings." Reading the settings now needsADMIN; any member could read them before. The checks meant for/configwere never reached, because a second set of/configroutes answered first. - Scopes are checked where they were missing (#439).
/usersand the webhook list need theadminscope; starting, stopping and marking recordings needsdevices. Aread-only key now gets403on them. - A new API token can't have more access than the credential creating it
(#439). An admin's
read-only key could mint itself a wider token. A super admin can now ask for a narrower token, and unknown scope names are refused. - Bearer tokens no longer need an
Originheader on changes (#439), as the access key and token headers never did. SDK and MCP callers were refused every change sent without one. - Signed-in users and bearer tokens are rate limited per user (#439), 300 a minute, with ten times that for reads. Only API keys were limited.
- A bug report's network capture goes to admins only (#439), as on
/interceptor; for anyone else the manifest says it was left out. - The counts on
GET /sessions/activecover only the sessions you can see (#439), not every team's.
Device allocation
- Block and unblock need both the udid and the host (#438), and change exactly that phone. An empty request blocked the first phone in the list.
- Reservations (#438):
- The phone is found by udid and host; a wrong host answers
404, where it used to answer success and reserve nothing. - Only the person who took a reservation, or an admin, can release or extend it. Reservations made before this release can still be changed by anyone, as before.
- An extension is at least a minute and can't end a reservation more than 24 hours from now.
- The phone is found by udid and host; a wrong host answers
- Leases (#438) apply the
sdkanddeviceNamefilters, skip blocked, reserved and unhealthy phones as new sessions do, and answer404when nothing matches (an unknown udid answered409, "all busy").
Fixed
- Requests that never got an answer (#436): deleting an unknown API key,
creating a user whose email exists, a
%in a selector lookup and others hung until the client gave up. Every error under/xenon/apinow gets a JSON answer:404for something that doesn't exist,409for a duplicate,400for a malformed request, and500otherwise. - Answers that reported success when nothing worked (#437):
- iPhone clipboard, lock, unlock and key presses report WebDriverAgent's
refusal; a key the iPhone doesn't have answers
400 unsupported_key. - A failed screen scan or Test locator answers
500, not "nothing found". - Send test on a webhook reports a failed delivery (
502) and uses the webhook's own type and template; deleting an unknown webhook is404. - Android's live logs report an adb failure as an error, not as a log line.
- Installing from the app library without an app id answers
400.
- iPhone clipboard, lock, unlock and key presses report WebDriverAgent's
refusal; a key the iPhone doesn't have answers
/controlanswers an unknown device with JSON (#437) on every route. Most answered with the plain text "Device not found".- The combined video of a group recording can be seeked (#437):
composite.mp4honoursRange. - Stopping a group recording twice no longer touches recordings that had already finished (#437).
Docs
- The API reference at
/xenon/api-docsis complete (#435): every route the server serves, with its roles, scopes, answers and examples. It showed 46 of 100 paths, and 61 routes were never documented. A test now fails when a route and the reference disagree.
2.12.0
Device control's Logs and Screenshot tabs, redesigned, and a machine without usbmuxd no longer stops Xenon at startup.
No database migration, and nothing to configure. Only the dashboard and the iPhone discovery changed, so a hub and its nodes can be upgraded in any order.
Added
- Screenshots are kept in the browser (#432). A phone's newest 50 captures survive a reload or leaving the phone. Clear all and signing out remove them. In a private window they last until the page closes, and the tab says so.
- New Screenshot tab tools (#432): copy a capture, annotate it (saved as a marked copy, the original kept), compare two side by side, and download one or all of them as a zip.
- Undo on Delete and Clear all in the Screenshot tab (#432).
- Closing device control during a Logs recording asks first (#432): Keep recording, or Save and close, which downloads the recording. Esc and Back to devices used to throw the recording away.
Changed
- The Logs tab (#431). One toolbar row, one line per record from a 720 px panel, a level bar with counts, and a details panel per line (copy it, or show or hide its tag or app). Pausing to read no longer fights the stream and keeps your place. Only the lines on screen are drawn, so a full 5,000-line buffer stays smooth. The filter syntax, Find, Record and Export work as before, and Record and Export keep their file formats.
- The Screenshot tab (#432). Captures are named "Screenshot 12" with their time, orientation, resolution and size, not an id. About ten thumbnails fit at 1440 px instead of two. The look matches the Actions tab.
Fixed
- A failed screenshot said nothing (#432). It now says why.
- Screenshot thumbnails overlapped with more than about seven captures or any landscape one, hiding their numbers (#433).
- Xenon stopped a few seconds after startup on a machine without usbmuxd
(#433), such as a Linux node without iPhone tooling, with the default
--plugin-xenon-platform=both. It now logs one warning that real iPhones aren't available there, and runs on.
2.11.1
The hub's collection of a node session's CPU and memory, made sturdier.
No database migration, and nothing to configure. As for 2.11.0, upgrade the hub and its nodes together.
Fixed
- One malformed figure from a node could stop a session's chart (#428). A figure with no time, or a value that isn't a number, failed every later write of the session's figures for up to 30 minutes. The hub now keeps only well-formed figures.
- An absurdly long
?after=dropped a session's figures on the node (#428). A 400-digit number read as Infinity onGET /xenon/api/node/sessions/:id/metrics, and the node dropped everything it held for the session. It now reads as noafter. - Ending the sessions of a node that went dark was slower than it needed to be (#428). Each waited up to 5 s more for a last answer about its figures. A node that wasn't answering isn't asked again.
- Only sessions that run on a node are collected from (#428). A cloud provider's session and this server's own were kept out by a device check alone; the session's type now decides.
2.11.0
CPU and memory for sessions on a node's phones, live on the hub's session page, and Deploy on the Apps page can be cancelled again.
No database migration, and nothing to configure.
Upgrade the hub and its nodes together. The node samples its phones and the hub collects the figures, so both need 2.11.0. Until a node is upgraded, its sessions keep showing "Performance isn't recorded", and the hub logs that once for that node. An older hub never collects, and a 2.11.0 node frees what it held 10 minutes after each session ends.
Added
- The Performance panel for sessions on a node's phones (#426). Until now
the hub's session page said "isn't recorded" for every such session.
- The node samples its own phones for the sessions the hub creates, whatever its dashboard setting, and holds the figures in memory: the newest 30 minutes per session.
- The hub collects them every 10 s, so the chart fills in as the test runs. It asks once more when the session ends, so the last seconds are kept. After a hub restart it carries on from the last figures it stored.
- The figures are the same as for the hub's own phones: device and app CPU and memory on Android, device CPU on an iPhone.
GET /xenon/api/node/sessions/:id/metrics?after=on a node (#426), beside the session-status route and under the same rule: the hub's session token when per-command auth is on.
Changed
- The Performance panel's "isn't recorded" wording (#426). It no longer blames a device connected to another machine, since such a device is now recorded unless its machine needs updating.
Fixed
- Deploy on the Apps page couldn't be cancelled (#425). After Deploy, the device picker opened on top of the button, which by then read CANCEL, so the picker couldn't be closed. It now opens beside the button. The control sweep found it.
2.10.1
Selector Health and the session page stay fast on a busy lab, and a fixed selector that heals again always goes back to "To fix".
Four indexes are added, on the commands, device logs and profiling tables (#423). A server builds them itself at its next startup. On a database with a million commands and 2.5 million log lines that took about 4.5 s and made the file about a fifth larger. Nothing to configure.
Fixed
-
Selector Health was slow on a busy lab (#423). The commands table holds every command of every session and had one index, on the selector. On a million commands:
- the "To fix" list over a year: 1.8 s to 0.2 s;
- search: 0.9 s to 30 ms;
- the panel of a selector healed 50,000 times in a year: 1 s to 0.35 s. It no longer attaches the session to every heal it reads.
-
The session page and cleanup read whole tables (#423). A session's commands, device logs, debug logs and profiling had no index on their session. With 2.5 million log lines:
- a session's device logs: 149 ms to 7 ms;
- cleaning up a build of 100 sessions: 97 s to 4 s.
The failed-command check at the end of every session is faster in the same way.
-
A fixed selector that healed again could stay fixed (#423). The heal sends it back to "To fix" at once, but that step could fail, and nothing tried again. The verification job also counted only clean builds: a selector with three of them was promoted to fixed however often it healed in others. The job now checks for a heal since Mark fixed on every selector being verified or fixed, and sends such a selector back.
-
The "broke again" note reset the view (#423). Its Show selectors to fix went back to the default period and order. It now keeps both, clears any search or filter that could hide the selector, and opens the selector when only one broke.
2.10.0
CPU and memory for every session, and Selector Health rebuilt as a list with a side panel.
Two tables are added (SessionMetric, SelectorEvent); a SQLite server adds
them itself at startup. Nothing to configure: an optional sessionMetrics
setting (on by default) turns session sampling off.
Added
-
A Performance panel on the session page (#420). Every session on this server's own phones records CPU and memory every 2 seconds, and the page charts them as the session runs:
- Android phones and emulators: device CPU and memory, and the app's CPU
and memory (the session's
appPackage, else the app in the foreground); - iPhones: device CPU.
A node's phones get no figures, and the hub's panel says so. The figures come from
GET /session/:id/metrics. - Android phones and emulators: device CPU and memory, and the app's CPU
and memory (the session's
-
Selector Health as a list with a side panel (#421).
- Summary: selectors that needed healing, heals, sessions affected, and time spent healing, each compared with the period before, plus a heals-per-day trend with the heals that used AI.
- Tabs: To fix, Being verified, Fixed and Muted, each with its count. Selectors and suggested fixes show in full. Search, platform and healing-method filters, three sorts, and pages of 50.
- Panel: the status and actions, every suggested fix with Copy as, where the selector heals, its latest heals, and an Activity history of who did what. ↑/↓ move between selectors.
- The view, including the open selector, is kept in the address. Old
/selector-health/detaillinks open the panel.
-
GET /healing/selectorsandGET /healing/selectors/detail(#421), the list and the panel. They show a caller only selectors healed in sessions they can see; any other answers404, as an unknown one. -
GET /healing/summaryaddstimeSpentMsandtrend(#421), the trend counted in the caller's time zone (tz).
Changed
- Members can mark selectors fixed and mute them (#421).
POST /healing/selector/statenow needs roleMEMBERand thesessionsscope (wasADMINandadmin), on a selector healed in a session the caller can see.- A mute is lab-wide: it leaves the selector out of every team's list, CI gate and digest.
- Every change is recorded with the person who made it and, for a mute,
an optional reason (
reason, up to 500 characters).
- The Android app profiler is replaced by the session sampler (#420). It
ran only with an
appPackageand in practice saved nothing. The "System profiling" tab still shows old sessions' rows.
Removed
- Cost estimates (#421).
estCostUsdis no longer in/healing/summary,/healing/hotspots,/healing/hotspots/violationsor/healing/selector, and the digest drops its "est. $" line. A client that reads the field now getsundefined. The figure priced every heal the same whatever model ran, local ones included.
Fixed
- Selector Health's Pending and Resolved lists lost selectors (#421). The server cut the list to the top 50 by heals before splitting it by status, and a fixed selector that stopped healing fell out of the period. Both tabs now start from each selector's status.
- "To fix" stopped counting at 5,000 heals (#421). It now counts in the database, with no cap.
- The detail page never offered Mark fixed or Mute (#421). The list opened it without the selector's strategy.
- Selector actions from the dashboard were saved with no one as their actor (#421). They were recorded by API key, and a dashboard user has none. They are now recorded by user.
2.9.2
Xenon reaches WebDriverAgent only through a phone's own forwarded port.
No database migration, and no configuration to change.
Fixed
- A simulator's WebDriverAgent commands can no longer reach an iPhone
(#418). When a command to WebDriverAgent failed,
WDAClientretried it at the device's network address, port 8100. A simulator's address is this Mac's own, where 8100 can be an iPhone's forwarded WebDriverAgent, so a failing simulator could send taps and typing to an iPhone. The stream watchdog likewise took whatever answered there as the device's WebDriverAgent being alive. Both fallbacks are removed. A dead or hung forward is now "not responsive" and gets the watchdog's normal restart, as it always did on a real iPhone.
Removed
- The iPhone network-address lookup (#418). Detection ran
ios infoon every iPhone at every pass to read an address go-ios never reports, so the address was always empty. Real iPhones behave as before, with one go-ios call fewer per phone per detection pass.
2.9.1
Xenon's startup no longer kills its own iPhone detection, plus clean-ups to the iPhone tunnels.
No database migration, and no configuration to change.
Fixed
- Startup no longer kills Xenon's own go-ios calls (#414). At boot Xenon
kills whatever a previous run left of go-ios (tunnels, WebDriverAgent
runners, log streams), matching every process running the go-ios binary.
It did this at the end of startup, after device detection had begun, so it
also killed the
ios infocall detection makes for each plugged-in iPhone, logged as "Failed to fetch IP via go-ios" at every boot. That call only looks up the phone's network address, which go-ios doesn't report for recent iPhones anyway, so nothing visible was lost. The clean-up now runs as soon as the database is ready, before anything starts go-ios. - A second error from a tunnel process no longer crashes the server
(#415). The tunnel listened for the process's
errorevent once, so a second one was thrown as unhandled. - An iPhone tunnel's start waits at most 20 s (#415). It counted 41 checks half a second apart, but each check can take up to 1 s, so a tunnel that answered slowly could hold a stream start for about a minute.
- A session ending on an iPhone leaves its tunnel's ports (#415). It released every port lease the phone had, the tunnel's pair included, while the phone's preview and tunnel could still be running. Those ports were never handed out meanwhile, since a live listener blocks that. The tunnel gives its pair back when it stops.
Changed
- One go-ios path and one port-lease time for the tunnels, streams and detection (#415), instead of a copy in each.
- CI uses
actions/setup-node@v7(#416). v7 no longer sets a placeholder npm token for the publish job's install, which it doesn't need.
Known issues
- Two Xenon servers on one Mac kill each other's go-ios processes when either starts or stops, if the same user runs both (a hub and node side by side). The startup and shutdown clean-ups find go-ios by its binary's path, which both servers share.
2.9.0
A screenshot works on an iPhone that isn't streaming. On iOS 17 and
later, go-ios reaches the screenshot service only through the phone's tunnel,
and only a preview, recording or Appium-driven stream opened one. So
GET /xenon/api/control/:udid/screenshot failed on an idle iPhone ("failed
to get tunnel info"), which hit API and SDK clients. The dashboard was
unaffected, since it starts a stream first.
No database migration, and no configuration to change.
Added
- A screenshot opens the iPhone's tunnel when it has none (#412).
- The first screenshot takes a few seconds longer while the tunnel starts. Later ones reuse it.
- The tunnel stays while screenshots keep coming, and stops 2 minutes after the last one, freeing its two ports.
- A preview or recording that starts meanwhile takes the tunnel over, and closing it ends the tunnel.
- If the tunnel can't start, the screenshot's error gives go-ios's reason.
- Logs never needed this: they work without a tunnel.
Changed
- A phone's iOS version is cached for 10 minutes (#412), so screenshots on iPhones below iOS 17, and on simulators, don't each ask go-ios for it.
2.8.3
A finished session stops showing "running" on the Sessions list, and an iPhone's tunnel failures say what went wrong.
No database migration, and no configuration to change.
Fixed
- A finished session in an older page of the Sessions list stops showing
"running" (#410). Older pages, loaded with Show 200 more, were fetched
once, so a session running then kept showing "running" until the period or
build was changed. This was a known issue since 2.7.0.
- Each refresh now re-reads, by id, the older rows still running.
GET /xenon/api/sessiontakesids: up to 200 session ids, comma-separated, under the usual team-visibility rule. Otherwise it is400 invalid_ids.
- A failed iPhone stream start stops its go-ios tunnel (#409). A start whose tunnel came up and whose WebDriverAgent then failed left the tunnel running, holding its two ports, until the phone's next start. It is now stopped, except while an Appium session may be using it.
- An iPhone tunnel failure says what went wrong (#409). The dashboard
showed "Lost the connection to the device tunnel. Reconnect the device,
then retry." for every one. It now says when:
- no tunnel ports are left;
- the tunnel stopped while starting, with go-ios's own reason;
- the tunnel came up on an unexpected port.
2.8.2
An Android phone with H.264 preview is released when its tile closes. In 2.8.1, closing such a tile left the phone held, with "1 other viewer(s)" and nothing connected. The same phantom viewer also blocked Android's idle release, so the hold lasted until someone stopped the preview.
No database migration, and no configuration to change.
Fixed
- A viewer who hangs up while the stream starts is no longer counted
(#407).
- The stream request counted its viewer only once the stream had started, which takes seconds on a cold start, and only then listened for the browser leaving. A browser that left during that wait was counted for good.
- An Android tile with H.264 preview did this every time: it drops its MJPEG warm-up as soon as H.264 is ready. An iPhone tile closed during its start did the same.
- 2.8.1's known issue tied this to recordings; it happened without one.
2.8.1
Closing a preview releases the phone. Closing a Live devices tile, or leaving device control, used to leave the phone held for about 10 minutes. The server logged "keeping the preview for 2 other viewer(s)", and Appium sessions for that phone queued behind the hold until the idle watchdog released it. Now the phone is released 3 s after the last view of it closes.
No database migration, and no configuration to change.
Fixed
- A closed or reconnected stream view no longer counts as a viewer
(#405). A browser keeps loading an MJPEG image after it leaves the page,
until it is garbage collected, and the server counts each open stream as
a viewer. Each tile close, and each tile reconnect (a recording start
causes one), left a viewer that wasn't there.
- The dashboard now closes the stream when its image goes: the Live devices tile, the device-control screen and the inspector's live view.
Known issues
- An Android phone with H.264 preview can stay held after it was recorded. After a recording, its Android MJPEG viewer count can stay one too high, so closing its tile doesn't release it. Stop its preview to release it. iPhones, and Android phones without H.264 preview, are released.
2.8.0
Several iOS 17+ iPhones stream at once on one Mac. Until now only one could preview or record at a time. Every iPhone's go-ios tunnel took the same ports, 60105 and 60106. A second iPhone's stream start killed whatever listened there, which was the first iPhone's live tunnel.
No database migration, and no configuration to change. Xenon now uses local ports 12100–12199 for these tunnels, two per streaming iPhone, and no longer uses 60105 or 60106.
Fixed
- A second iPhone's stream no longer takes down the first one's (#403).
Each iOS 17+ iPhone gets its own go-ios tunnel on its own leased pair of
ports.
- go-ios commands about a phone find that phone's own tunnel: WebDriverAgent's launch, the live logs pane, the logs request and screenshots.
- Starting, stopping or restarting one iPhone's stream leaves every other iPhone's tunnel and stream alone.
- Nothing is killed by port any more. A stream start clears only its own phone's leftovers, and still none while an Appium session holds the phone.
- An unplugged iPhone's tunnel is stopped within seconds (#403). go-ios keeps a tunnel running when its phone is unplugged, and restarts it one port up on the replug, where it would land on the next phone's ports. Xenon checks each tunnel every 5 s. It stops one whose phone has gone or whose port has moved, and frees its ports. The phone's next start gets a fresh tunnel.
Known issues
- Screenshots and logs don't open a tunnel for an iPhone that isn't streaming. On iOS 17+ they work while its preview, recording or Appium session runs.
- A stream start whose WebDriverAgent fails leaves that phone's tunnel running until the phone's next start or stop.
- A running session in an older, already loaded page keeps the status it had when loaded, until the period or build is changed.
- Install by path is refused for a node's phone: a path names a file on one machine.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks.
2.7.0
The Sessions list pages through every session. It used to stop at the newest 500 sessions of a period or build, while the summary above it counted them all. It now shows the newest 200, with Show 200 more for the next page, and says how many are loaded of how many there are.
No database migration, and no configuration to change.
Added
- Paging on the Sessions list (#401).
- The newest 200 sessions are refreshed every 3 s.
- Show 200 more loads the next page, once.
- Rows loaded this way stay listed while new sessions arrive: nothing is skipped or repeated.
- Under the table: "Showing the newest 400 of 2,340 sessions".
GET /xenon/api/sessiontakeslimitand a page cursor (#401).limit: 1 to 2000, default 500 as before. A larger limit gets 2000.beforeandbeforeId: the last row of the page before.- Sessions are now ordered by
createdAt, then byid, so pages continue exactly where they stopped. - A
limitorbeforeit can't read is refused with400(invalid_limit,invalid_before).
Changed
- CI runs on Node 22 (#401). Node 20 reached end of life in April 2026.
Known issues
- A running session in an older, already loaded page keeps the status it had when loaded, until the period or build is changed.
- Install by path is refused for a node's phone: a path names a file on one machine.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.6.2
Copy a build's failed tests. A build's page had a Retry failed button that did nothing but say retry would come later. The server can't re-run a client's tests, so the button now copies them instead, ready to paste into a CI re-run or a ticket.
No database migration, and no configuration to change.
Changed
-
"Retry failed" is now "Copy failed tests" (#399). It copies each failed session (failed, error or timeout), oldest first, with:
- its test;
- the first line of why it failed;
- its phone and its session id;
- a header with the counts.
With sessions selected, it copies the failed ones among them. When nothing failed, it is disabled and says so.
-
CI runs off the actions GitHub runs on deprecated Node 20.
actions/checkoutis on v7 andactions/setup-nodeon v6, both Node 24.setup-nodestays below v7 until the publish job'snpm installis confirmed without v7's placeholder token.
Known issues
- The session list still returns at most the newest 500 sessions.
- Install by path is refused for a node's phone: a path names a file on one machine.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.6.1
A phone is free again seconds after its recording ends. A recording that started the phone's stream itself left the phone held for about ten minutes after it stopped: any recording made without a preview open. That was 642 s on the lab's S9+, and nobody else could use the phone meanwhile.
No database migration, and no configuration to change.
Fixed
- A recording lets go of its phone when it ends (#397). The phone now
goes the way a closed preview does: after a 3 s grace, its stream stops
and its hold is released, unless someone is watching it or another
recording reads it.
- A phone with a preview open keeps its hold until that preview closes.
- A node's phone is released at once, as before.
- Before, the hold was kept whenever the phone's stream was running, taken for a preview's. But a recording starts that stream itself.
Known issues
- The session list still returns at most the newest 500 sessions.
- "Retry failed" is still a placeholder.
- Install by path is refused for a node's phone: a path names a file on one machine.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.6.0
A session's page leads with how it ended and why. The page now opens with the result and the test's name, then the phone, where it ran, who ran it, when and for how long. Four tiles follow: the result, the commands, the self-healing and the slowest command.
The page also shows:
- the reason a failed session failed, with the first failed command's own error and the AI analysis the server had stored but never shown;
- every selector self-healing fixed;
- a timeline of the commands and the screenshots they kept, in place of the two placeholder tabs.
A running session's page follows it live.
No database migration, and no configuration to change.
Changed
- The failure panel shows for
errorandtimeoutsessions too, not onlyfailedones (#395). - Capabilities are labelled Requested and Actual (they were "desired" and "session"), and long names wrap instead of being cut.
- The breadcrumb shows a short session id. The full id, with a copy button, is in the new Details card.
Added
- The session page's outcome header and tiles (#395). The tiles share their look with the Sessions page's summary strip.
- Why it failed: the reason, the first failed command and its error,
and the AI analysis.
- Only
**bold**and`code`are rendered in the analysis, never HTML, and a long analysis folds behind "Show all". - Copy includes the analysis.
- Only
- Self-healing panel, when something was healed: each selector the test asked for, what it healed to, the tier and the confidence.
- Timeline and Screenshots tabs.
- Timeline places every command on one time axis, failed ones red and healed ones amber.
- Screenshots shows the images commands kept, each opening full size.
- Every command row shows its duration.
- An empty tab is dimmed instead of showing "0".
- Details card: session id, build, UDID, platform, where it ran, who ran it, start and end, tags, and the iOS performance trace's download when the session kept one.
- Live updates:
- A running session's page asks for its status and commands every 4 s.
- When the session ends, the page loads its device and debug logs once more.
GET /xenon/api/session/:idcarriesownerandranOn, as the session list does since 2.5.0.
Fixed
- Healed commands are marked in the command list again. The row read a
healedfield the server never writes; the server writesis_healed. - "Errors only" also keeps commands marked
is_error.
Known issues
- The session list still returns at most the newest 500 sessions.
- "Retry failed" is still a placeholder.
- Install by path is refused for a node's phone: a path names a file on one machine.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.5.0
The Sessions page opens on every session, with a summary.
- Every session, newest first: the page no longer starts empty until a build is picked. It lists the chosen period's sessions across all builds.
- Summary on top: pass rate and its change from the period before, failures, what's running now, and median and p90 duration.
- Names first: rows show the test's name, its build, the device, where it ran and who ran it. Failures are marked, with the reason inline.
- Builds column: it now filters the table, and each build has an outcome bar.
No database migration, and no configuration to change.
Changed — operator action may be needed
GET /xenon/api/buildcountserrorandtimeoutsessions as failed, andendedas passed (#393). This is the same verdict the session rows use.failedCountused to count onlyfailed, andpassedCountonlysuccessandpassed, so a client reading those counts may see them change for the same builds.- The session detail page says Passed, not Success, matching the list.
Added
-
The Sessions page redesign (#393).
/buildsopens on all sessions of the period, which defaults to the last 7 days and is chosen in the page header.- The summary strip shows a chosen build's own numbers.
- The builds column comes first with "All sessions", and each build shows a pass/fail bar. A build sent with no name reads "Build · Sep 29, 07:30" instead of "Default Build".
- An unnamed session shows its app (package, bundle id or file name). The
page shows how to name tests and runs with the
xe:options.nameandxe:options.buildcapabilities. - Search covers what the rows show, the owner and node included.
- Rows open with Enter or Space as well as a click.
-
GET /xenon/api/session?since=<ISO date>lists only the sessions created since then. Each listed session also carries:owner:{ name, email }, or null;ranOn:here, a node's host, or null when that isn't known. Asincethat isn't a date is refused with400 invalid_since.
-
GET /xenon/api/session-summary?since=&buildId=:- counts by outcome for the period and for the period of the same length before it;
- median and p90 duration of the sessions that ended;
- what runs now.
It covers only the sessions the caller may see.
Known issues
- The session list still returns at most the newest 500 sessions. When it stops there, the count says "(the newest)". The summary counts the whole period.
- "Retry failed" is still a placeholder.
- The session detail page hasn't been redesigned yet.
- Install by path is refused for a node's phone: a path names a file on one machine.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.4.0
A hub records its nodes' phones. Recording a node's phone, alone or with others (across nodes too), now runs on the hub from the node's stream. Per-phone videos, the combined video, marks and proof bundles work as they do for the hub's own phones. With this, every device-control action works on a node's phone except install by path.
No database migration. Only the hub needs this version: nodes on 2.1 or later serve the stream it records.
Changed — operator action may be needed
- Recording a node's phone uses the hub's network while it runs (#391): roughly 1–3 MB/s per phone, between the hub and the node. If the node restarts mid-recording, that phone's recording ends; the rest of the group carries on.
- While the hub records a node's phone, stopping its preview is refused
(
409 device_recording), as it is for the hub's own phones.
Added
- Record a node's phone from the hub (#391).
- The hub reads the node's stream through a relay on its own loopback, so the recording pipeline is unchanged.
- The node's stream is signed again for each connection.
- The recording starts once the phone's stream is live, as for a local phone, so marks line up.
- You can record a node phone you're previewing.
- A cloud provider's phone can't be recorded.
Known issues
- Install by path is refused for a node's phone: a path names a file on one machine.
- Installing an app on an iPhone through a hub wasn't checked on a phone (no test app to install). It takes the same path to the node as Android, which was checked.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.3.0
Device control works on a hub's node phones, end to end. Installing apps, by upload or from the app library, and Omni-Vision now go through the hub. The hub shows who holds a node phone's preview and names them when it refuses someone. Every device-control action works on a node's phone apart from install by path and recording. Checked on an Android phone and an iPhone, through a hub and an auth-enabled node. Device control's Upload file also works now, on every server.
Includes a database migration, applied automatically at startup. Read "Changed — operator action may be needed" before upgrading.
Changed — operator action may be needed
- Database migration: one column on
Device(#386):nodeHold, who holds a node's phone, from the node's report.runMigrationsapplies it at startup. If you run withXENON_AUTO_MIGRATE=false, apply20261001120000_device_node_holdyourself before starting this version. - Installing apps on a node's phone through a hub needs the node on 2.3 (#387, #388). An older node's own upload-install received no file, so the install fails there, with that node's error. Everything else in device control works with 2.1 and 2.2 nodes.
- Uploads for install are limited to 4 GB (#387) and written to a
temporary file, not held in memory. A larger upload is answered
413.
Added
- Install apps on a node's phone through the hub (#388).
- Upload and install: the hub streams the upload to the node without storing it.
- Install from the app library: the hub checks the app's team as before, then sends the file from its library to the node.
- The node gets 15 minutes to answer an install.
- Omni-Vision on a node's phone through the hub (#388): the scan and locator testing run on the hub, with the hub's AI settings, on a screenshot from the node.
- Who holds a node phone's preview (#386).
- The device picker, the device cards ("Live control by you") and a reload's tile restore treat it like a local preview.
- The hub fills the holder's name into a node's refusal, where the node could only say "another user".
- Nodes of any 2.x version already report the holder.
Fixed
- Device control's Upload file never installed anything (#387): every upload was answered "No files were uploaded." The uploaded file is now removed after the install, whether it worked or not.
- An Android install that took longer than 20 seconds was reported as failed (#388) though it had worked; adb's library stopped the command at its default limit. Installs now get 10 minutes.
- A failed screenshot was never answered (#389). On an iPhone with no live preview, the Screenshot tab spun forever; it now gets an error with the reason.
Known issues
- Recording a node's phone from the hub doesn't work yet: it runs on the hub's own machine.
- Install by path is refused for a node's phone: a path names a file on one machine.
- Installing an app on an iPhone through a hub wasn't checked on a phone
(no test app to install). It takes the same path to the node as Android,
which was checked, and then the node's
ideviceinstaller. - BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.2.0
A hub's dashboard works on its nodes' phones. Device control of a node's phone now runs on the node: screenshots, clipboard, installed apps, shell, logs, the inspector, and the live preview and live logs, relayed through the hub. What the hub can't pass on yet, it refuses with a message naming the node, instead of running it on the hub's own machine.
No database migration. Only the hub needs this version: a 2.2 hub works with 2.1 nodes, which need no change (checked on a phone with a 2.1.0 node).
Changed — operator action may be needed
- A node phone's live preview goes through the hub (#383). Each viewer is one connection from the hub to the node, so plan for the hub's bandwidth: an MJPEG viewer is roughly 1–3 MB/s, H.264 much less. The node still holds the phone for the preview, refuses a second user and releases it when nobody watches, as it does for its own dashboard.
- Actions the hub doesn't pass on to a node are refused (#382) with
501 not_available_through_hub, and the dashboard shows the reason: uploading an app, installing from the app library,installby path, and Omni-Vision. They used to run on the hub's own machine. That failed, or, with hub and node on one machine, worked by accident on the hub's adb. - A cloud provider's phone gets
501 not_available_for_cloud_phonefor device control (#382), and nothing is sent to the provider.
Added
- Device control of a node's phone runs on the node (#382): tap, swipe,
text, key events, long press, screenshot, clipboard, lock and unlock,
screen state, installed apps, uninstall, logs, shell and the inspector
snapshot. The node's answer comes back unchanged. A node that can't be
reached is
502, and one that doesn't start answering in 60 s is504. - Live preview and live logs of a node's phone through the hub (#383): device control's preview (MJPEG), the Live Devices tiles (H.264) and the Debug Logs tab. The viewer's ticket is the hub's, issued after its team check. The hub relays the node's stream with the node's own close codes, and a viewer that falls behind slows the node's stream rather than the hub's memory.
- A node's phone shows as busy on the hub as soon as its preview starts (#383), not a report interval (30 s) later, so the hub doesn't hand it to a session meanwhile.
Security
- Nothing is sent to a cloud provider for device control (#382). The five input actions used to be sent to the provider's host, typed text included.
Fixed
- A server set to emulators only (or real phones only) listed a phone of
the other kind for about 30 s after it was plugged in (#384), and every
such phone connected at startup, and could hand it to a session. A plugged
phone now follows
androidDeviceTypeas discovery does. - The lease sweeper logged an expired lease as "missed heartbeats" (#381); it now says when the lease expired.
- The unit test suite wrote to
~/.cache/xenon/xenon.db(#381), the database a developer's own server uses; it now runs against a throwaway one.
Known issues
- The hub doesn't show who holds a node phone's preview. The picker shows such a phone as busy even to its holder until the preview ends, and Live Devices tiles for node phones aren't restored after a reload.
- Recording a node's phone from the hub doesn't work yet: it runs on the hub's own machine.
- Uploads, installing from the app library and Omni-Vision are refused for a node's phone (see above).
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.1.0
Hubs and nodes work on Appium 3. A session through a hub is created, driven and ended on its node, with auth enforced at the hub. Node sessions survive a hub restart, a hub's device control reaches its nodes' phones, and a node's reports no longer undo the hub's claim on a phone. Xenon's and Appium's WebSockets now both work on every supported Node version.
Includes a database migration, applied automatically at startup. Read "Changed — operator action may be needed" before upgrading.
Changed — operator action may be needed
-
Database migration: three columns on
Device(#375):claimSessionId,claimedAtandnodeBusy. They let a hub keep its own claim on a node's phone apart from the node's report.runMigrationsapplies it at startup. If you run withXENON_AUTO_MIGRATE=false, apply20260929120000_device_claimsyourself before starting this version. -
Upgrade a hub and its nodes together if the nodes have auth on (#373, #377). The hub no longer forwards a client's credentials to a node. It sends its own short-lived, signed token, which the node checks against the hub's public keys. So a 2.1 node with auth on refuses a create from an older hub, and an older node with auth on refuses a 2.1 hub's creates. A node with auth off is unaffected.
-
Create sessions on a node's phones through the hub (#377). A node with auth on now refuses a session that didn't come from its hub, with "Create sessions through the hub", naming the hub. A node with auth off still accepts one, for local development.
-
Leases end at
expiresAt(#378). A client that kept heartbeating could hold its phone up to 15 minutes past the lease's end; the sweeper now ends the lease then. A session running on the lease keeps its phone until the session itself ends. -
Keep passwords out of Appium's own request log (#366). Appium logs the
[HTTP] -->body of every request before any plugin runs, the dashboard's sign-in (POST /xenon/api/auth/login) included. Add this rule next to 2.0.0's token rule, in the same list. With--log-filters <file>:[{"pattern": "([Pp]assword\\\\?[\"']?\\s*:\\s*(\\\\?)([\"'`]))(?:\\2\\\\(?:\\2[\\s\\S]|[^\\\\])|(?!\\3)[^\\\\\\x00-\\x1f])*", "flags": "g", "replacer": "$1**REDACTED**"}]With an Appium config file (
--config, which is how Xenon Control starts Appium):server:log-filters:- pattern: '([Pp]assword\\?["'']?\s*:\s*(\\?)(["''`]))(?:\2\\(?:\2[\s\S]|[^\\])|(?!\3)[^\\\x00-\x1f])*'flags: greplacer: '$1**REDACTED**' -
With
XENON_REQUIRE_COMMAND_AUTHon, the session listing and session WebSockets are checked too (#367).GET /wd/hub/appium/sessionslists only the caller's own sessions (an admin sees all). BiDi and drivers'/ws/session/<id>/...sockets need the owner's or an admin's credentials. Enable it on the hub; a node accepts the hub's token in its place.
Added
- A session gateway in front of Appium's routes (#371, #373). On a hub, a session on a node's phone is created on the node and answered by the hub, and its commands and its end go to the node. Before, Appium 3 answered the create with a 500 while the node kept the session, and no later command reached the node. Every hub call to a node carries a short-lived token the hub signs, for that one session, create or device-control call.
- Node sessions survive a hub restart (#374, #377): a SIGTERM restart, an outage longer than 90 seconds, and a hub with the dashboard off.
- The hub's device control reaches its nodes' phones with auth on (#377): tap, swipe, text, key events and long press.
- Hub and node health checks work with auth on (#372). Each side used to call the other dead, so the hub dropped the node's phones and the node never registered.
Security
- Xenon's internal loopback calls need a per-process secret (#371).
The
/wd-internalpath alone used to mark a call as Xenon's own, which would have been a way around per-command auth. - A client's credentials never leave the hub (#373). They used to be forwarded to the node.
- An API key's access key is redacted in stored capabilities and logs, and the token log rule covers every token field (#366).
Fixed
- An iPhone's live preview or recording could show another phone's screen (#368). After a failed start and a retry, the iPhone's stream and an Android stream could end up on the same local port.
- WebSockets depended on the Node version (#369). On Node 22.21 and later,
Appium closed Xenon's live-preview, logcat and dashboard sockets, so preview
fell back to MJPEG and live events to polling. Before 22.21, BiDi and
drivers' log sockets got no answer. A WebSocket URL with a malformed
%escape could also crash the server. - A node's report could undo the hub's claim on its phone (#375), letting a second session take a phone that was in use. It also reset the team, tags, reservation and block the hub had set for the phone.
- A node's shutdown could remove the hub's own phones (#377), and the hub ran its health checks against its nodes' phones.
- A create through a hub could run twice on a slow node, and the hub's health check kept abandoned node sessions alive (#377).
- A refused session create answered "Error: {}" instead of Appium's reason (#373).
- A live preview could kill a running test session's WebDriverAgent or tunnel on the iPhone (#378): when it started, restarted or was stopped while a session was using the phone.
- Ending a lease freed a phone a session was still using (#378).
- A lab reached as
localhostforwarded taps on its own phones to itself, and refused them with auth on (#377). - A hub on the same machine as a node dropped the node's phones (#365).
extendignored a lease'sexpiresAt, andxe:options.teamwasn't read (#366).- An offline device's team name and team picker were hard to read (#364).
- The test suite no longer depends on file order, other suites running at the same time, or the developer's local database (#370, #376, #379).
Known issues
- Most device-control actions on a node's phone still run on the hub's own machine: screenshot, clipboard, app install, shell, live preview and logs. Tap, swipe, text, key events and long press reach the node.
- BiDi and session WebSockets aren't routed through a hub. A session's
webSocketUrlpoints at the node, so nodes must not sit on untrusted networks. - Only one iOS 17+ iPhone per Mac can stream at a time, because every go-ios tunnel binds the same port.
2.0.0
Major release. Xenon's session capabilities move to xe:options, and
df:options is no longer read. Update every test client before you
upgrade.
Session credentials no longer reach the Appium driver or anything Xenon stores. Appium sessions now follow the same team rules as the rest of Xenon. Uploaded apps belong to teams. An opt-in setting checks the caller on every Appium command.
Includes a database migration, applied automatically at startup. Read "Changed — operator action may be needed" before upgrading.
Changed — operator action may be needed
-
Session capabilities move to
xe:options(#361). It is Xenon's one capability namespace:- credentials:
accessKey+token, orsessionToken; - a lease:
leaseId,leaseToken; - Xenon's other options:
healingTiers,interceptor,name, and so on.
xenon:optionsis still read as an alias. When a session sends both,xe:optionswins field by field. - credentials:
-
df:optionsis not read at all, and neither arexenon:df:optionsorappium:df:options. It came from appium-device-farm and isn't Xenon's.- A session that sends its credentials only there counts as having no credentials. It's admitted with a warning but has no owner, so the device ownership guard denies its phone to every non-admin, including whoever started it.
- With
XENON_REQUIRE_SESSION_TOKENon, it's refused with "passxe:options.accessKey+xe:options.token, orxe:options.sessionToken". - The flat
xenon:accessKeycapability shown in older docs was never read. Theaccess_keyspelling is no longer read either.
Before:
'df:options': { accessKey: process.env.XENON_ACCESS_KEY, token: process.env.XENON_TOKEN },'xenon:options': { healingTiers: [1, 2] },After (WebdriverIO):
'xe:options': {accessKey: process.env.XENON_ACCESS_KEY,token: process.env.XENON_TOKEN,healingTiers: [1, 2],},Java:
options.setCapability("xe:options", Map.of("accessKey", accessKey, "token", token));. Python:options.set_capability("xe:options", {"accessKey": access_key, "token": token}). A client that uses a session token sends"xe:options": {"sessionToken": ...}. -
The lease-create response now carries
xe:options: { leaseId, leaseToken }(#361). Clients that passappiumCapabilitiesthrough unchanged need nothing. -
Upgrade nodes before the hub (#361). An older node reads only
df:optionsandxenon:options, so a client onxe:optionswould lose attribution and its lease there. -
Rotate API tokens that clients sent in
df:options, if you keep Session rows, queue history or debug logs from earlier releases (#361). Until now those tokens, and session JWTs sent inxenon:options.sessionToken, reached the driver and were stored and logged. Old rows are not rewritten. -
Keep tokens out of Appium's own request log. Appium logs the
POST /sessionbody before any plugin runs. This rule redactstoken,sessionTokenandleaseTokenvalues, including in a body Appium cuts off inside the token. It replaces the lease-only rule from 1.29.0. With--log-filters <file>:[{"pattern": "([Tt]oken\\\\?[\"']?\\s*:\\s*\\\\?[\"']?)[A-Za-z0-9._~+/=-]+", "flags": "g", "replacer": "$1**REDACTED**"}]With an Appium config file (
--config, which is how Xenon Control starts Appium):server:log-filters:- pattern: '([Tt]oken\\?["'']?\s*:\s*\\?["'']?)[A-Za-z0-9._~+/=-]+'flags: greplacer: '$1**REDACTED**' -
Members' Appium sessions follow their teams (#362). A session with a key pair or a session token is allocated phones, and resolves uploaded apps, by the same rule as REST. What that means per caller:
- a member gets their teams' phones plus the shared pool;
- a key or token narrowed to one team gets that team;
- an ADMIN or SUPER_ADMIN owner, or an admin-scoped key, gets every phone.
Until now, a member's ordinary key reached shared phones only, and a session token reached every team's phones.
xenon:teammay now pick any team the caller is in. -
Uploaded apps belong to a team (#360).
- Upload stays admin-only and takes an optional team; the default is
shared.
PUT /xenon/api/apps/:id/teammoves an app. - Members see shared apps and their teams' apps. A session names an app by id only if its owner can see it. Installing an app on a phone applies the same rule.
- Existing apps stay shared.
- A team that still owns apps can't be deleted.
- Upload stays admin-only and takes an optional team; the default is
shared.
-
Database migration: one nullable column,
App.teamId(#360).runMigrationsapplies it at startup. If you run withXENON_AUTO_MIGRATE=false, apply20260928120000_app_teamyourself before starting this version. -
A CI gate using a member's API key now counts only that key's teams (#356). A build whose sessions ran on phones the key can't see gets
violationCount: 0from/healing/hotspots/violations. For a lab-wide gate, use an admin-scoped key. -
GET /xenon/api/cliArgsis admin-only, and redacts secret-looking values (#358).
Added
- Opt-in per-command auth for Appium sessions (#359). With
XENON_REQUIRE_COMMAND_AUTH=true, every request under/wd/hub/session/:sessionIdmust carry credentials: thex-xenon-access-keyx-xenon-tokenheader pair or a bearer token. The caller must be the session's owner or an admin.
- A refusal is WebDriver's own "invalid session id" 404, so it can't be told apart from a session that doesn't exist.
- A session with no owner is refused to everyone but admins.
- Off by default. It never applies with auth disabled.
- Pair it with credentials at session creation, and have clients send the
headers on every request.
docs/server-args.mdshows how for WebdriverIO, Java, Python and curl.
- A session's app is downloaded with a single-use link (#360). When a session names an uploaded app by id, the driver downloads it with a ticket bound to that app, valid for 10 minutes. With auth on, the driver's credential-less download used to be refused, so a session naming an app failed.
Security
-
Session credentials reached the driver and storage (#361). An API token, a session JWT or a lease token sent in the capabilities reached:
- the Appium driver;
- the pending-session queue;
- the Session row's stored capabilities;
- a debug log line.
Xenon now removes
accessKey,token,sessionTokenandleaseTokenfrom the capabilities before anything else reads them. A peer Xenon node gets them back on the copy forwarded to it, and removes them itself; a cloud provider never does. A Session row also redacts any secret-named value the driver hands back. -
The session queue showed other users' API tokens (#357).
GET /queuereturned each waiting request's raw capabilities, credentials included, to any member. Credentials are now redacted for every caller. -
Members saw other teams' data in:
-
A member's session token could reach another team's phone (#362).
-
Lease heartbeat, extend and release revealed whether a lease was active (#358). Any caller whose token doesn't verify now gets the same 403
token_mismatch, whatever the lease's state. Only the token's holder learns that it's gone.
Fixed
- The annotated export misplaced a late phone's marks (#354). A phone added more than 5 minutes into a recording had its annotations burned in at the wrong time, usually stuck at the end. They now land where the Recordings page shows them.
- An offline device's tags were below readable contrast (#355): about 2.3–3.2:1. They now measure 5.0–5.8:1 in both themes, on the card and in the table.
- The Devices table hid its sorted column (#355). Filtering down to one host while sorting by Host left no header showing the sort. The column now stays while it's the sort.
- Three test specs depended on the order they ran in (#353).
Known issues
- Sessions through a hub fail on Appium 3. The node creates the session, then Appium crashes on the hub while attaching plugins to a session with no local driver. The client gets a 500, and the node keeps the session open on the phone until it times out. A hub also forwards later commands through a proxy that Appium 3 places after its own routes, so they wouldn't reach the node either. Single-server setups are unaffected.
- A hub treats a node's phones as stale when both run on the same machine, because the stale-device filter compares the IP address and ignores the port.
- The late-phone export fix (#354) is covered by unit tests only. It hasn't been checked on a device yet.
1.29.1
Security patch. Session files (videos, screenshots, performance traces) could be downloaded with no login at all, and several session routes still served other teams' data. Session files now need a login and a team check. As a side effect, the dashboard's session videos and screenshots load again. Upgrade promptly, and read "Changed — operator action may be needed".
Security
-
Session files were served with no login (#351).
/xenon/session-recordings/served the session folder straight from disk, outside the API's authentication. Anyone who could reach the server and knew an id could download:- a session's video, screenshots and performance trace;
- its network capture, given the file name;
- by default, a Live devices recording, which is stored under the same folder.
The route is removed. Session files are served only by
GET /xenon/api/session/:sessionId/asset/:kind/:file, which needs a login and the session's team. -
Other teams' sessions were readable (#351). A member could:
- read another team's session log, device log, debug log, live video and profiling;
- download a bug report (video, logs, network capture and summary) for any session;
- see other teams' session ids and phones in healing events, the healing selector timeline and the internal request log.
These now follow the session's phone, with the same rule as 1.29.0. A session on a phone you can't see answers exactly like one that doesn't exist.
Changed — operator action may be needed
- Anything that downloads
/xenon/session-recordings/...directly must switch toGET /xenon/api/session/:sessionId/asset/:kind/:file, with credentials.kindisscreenshots,videoorperformance, andfileis the file name, as stored in the session'svideo_recording,performance_traceor a log'sscreenshot:<sessionId>/<kind>/<file>. The route supportsRange. GET /xenon/api/logs/requestsandGET /xenon/api/node/statusare admin-only, like/processes. The first lists every internal HTTP call, including text typed on remote phones and forwarded session ids. The second lists every attached phone. Neither the dashboard nor Xenon Control calls them.
Fixed
- Session videos and screenshots never loaded in the dashboard (#351).
Their links pointed at
/xenon/assets/, which serves only the web app's own files. They now use the new route. On the lab, a session's Recording card that failed to load now plays. - A member's own session disappeared when its phone was unplugged (#351). Its owner now still sees it in the list and can open it, as with recordings. Nobody else can.
- A build shared by name across teams counted every team's sessions (#351). A member now sees only their own sessions in its counts, and the session list's 500-row limit counts only sessions they can see.
- The active recordings list included phones the caller can't see (#351), when an admin added one to the group or a phone moved team. They're left out, with their marks.
Notes
- A member's Selector Health summary counts heals from their own sessions only. Resolved and pending counts stay fleet-wide, because selector state has no team.
- Found by the same audit and not yet changed:
- Appium commands are checked by session id alone, so anyone who has a session id can drive that session. Closing the id leaks above reduces the exposure.
/cliArgsshows the stored Appium arguments to any logged-in user.- Uploaded apps have no team.
- The queue lists other teams' pending requests.
- No database or configuration changes.
1.29.0
Minor release. Teams are now a device boundary everywhere a phone can be
reached: device control, reservations, leases, recordings and the dashboard's
live events. Past recordings get a library where you can find, replay in sync,
download and delete them, and the Devices page gets a sortable table view.
Also fixes a device sync that could free a phone in use, and dashboards
signed in through /login now get live events. Includes a database migration
that is applied automatically at startup. Read "Changed — operator action
may be needed" before upgrading.
Changed — operator action may be needed
-
Members can use only shared phones and their own teams' phones (#345, #347). This covers device control, previews and stream tickets, reservations, SDK leases, recordings and live dashboard events.
- Anyone using a team's phone without being on that team loses access until an admin adds them on the Teams page.
- A phone they can't see looks exactly like one that doesn't exist.
- Admins and servers with auth disabled are unchanged.
-
A session that names a lease must prove it holds it (#346). A lease id alone no longer gets its phone. A session that sets
xenon:options.leaseIdmust do one of these:- Pass the lease token. Pass the lease-create response's
appiumCapabilitiesthrough unchanged: they now includexenon:options.leaseToken. A client that builds its own capabilities adds theleaseTokenit already holds for heartbeat, next toleaseId. - Use the credentials that created the lease. A lease created with an
access-key pair is matched only by that key's
df:optionspair. One created with a bearer token or the dashboard is matched by any of that user's keys, or by axenon:options.sessionTokenfor that user. - Be allowed to override: a SUPER_ADMIN, an
admin-scoped key, or an ADMIN or SUPER_ADMIN session token. An ADMIN's ordinary key may not.
The phone must also be one your teams can see, and servers with auth disabled are unchanged. A refused session fails with one message,
lease <id> is not active, or this session did not prove it holds it …. It reads the same for an expired lease on purpose. Don't re-acquire in a loop on this error: each new lease holds a phone until its TTL runs out. - Pass the lease token. Pass the lease-create response's
-
Hub and node fleets: upgrade nodes before the hub (#346). A new hub forwards the lease token to the node that runs the session, and only a new node strips it before the driver sees it.
-
Add a log filter for the lease token (#346). Appium logs the raw
POST /sessionbody before Xenon sees it. It cuts that body at 1024 characters, and the cut can land inside the token, so the rule matches the token's hex digits. With--log-filters <file>:[{"pattern": "(leaseToken\\\\?[\"']?\\s*:\\s*\\\\?[\"']?)[0-9a-fA-F]+", "flags": "g", "replacer": "$1**LEASE TOKEN**"}]With an Appium config file (
--config, which is how Xenon Control starts Appium):server:log-filters:- pattern: '(leaseToken\\?["'']?\s*:\s*\\?["'']?)[0-9a-fA-F]+'flags: greplacer: '$1**LEASE TOKEN**' -
Database migration: one nullable column,
Recording.started_by(#342).runMigrationsapplies it at startup, and older rows stay null ("recorded by" is unknown for them). If you run withXENON_AUTO_MIGRATE=false, apply20260927120000_recording_started_byyourself before starting this version.
Added
- A Recordings library (#342). Past Live devices recordings used to be
reachable only from the download bar right after Stop.
/recordingslists every recording you can see: when, phones, length, who recorded it, bookmarks and status. You can filter by phone, recorder and time, or search phone names and bookmark labels. The filters are kept in the link.- A recording's page plays every phone side by side on one clock, with
Space to play or pause, a timeline you can drag, and ← → to skip 5 s.
- A phone that started later shows "Starts at 0:12".
- Bookmarks are marked on the timeline.
- Annotations are drawn on their phone at their time.
- Download gives all videos, the side-by-side video, the proof bundle, or one phone's video with or without its annotations.
- Delete is for the recording's owner or an admin.
- Live devices offers Open in Recordings after Stop.
- A table view on the Devices page (#340), beside the cards. It has
sortable columns (Status, Device, Platform, Type, Team, and Host when there
is more than one host) and the card's own Control, Reserve and More
actions. The view and the sort are remembered and carried in the link
(
?view=table).
Security
- Recordings exposed other teams' phones (#341, #343, #344). In a recording mixing teams, a member could download another team's phone's video, zips, proof bundle and side-by-side video. They could also start, stop, bookmark or annotate recordings of phones they can't see, and clear their marks. Stop's response listed every phone in the group. Every recording route now follows one team rule and answers a hidden phone or group with a plain 404.
- Device control, reservations and SDK leases ignored teams (#345). A member could tap, type, install, read the screen, clipboard and logs, preview, reserve or lease another team's phone whenever nobody held it.
- A lease id alone claimed its phone (#346). Anyone who knew an active lease's id could start a session on its phone. The token that proves a lease never reaches the driver, the Session row, the dashboard or Xenon's logs.
- Every dashboard received every team's live events (#347): phones, session commands, healing, intercepted network traffic, recordings and bug reports. Bug reports also reached nodes. Each event now goes only to dashboards whose user can see that phone. Selector and node events, which aren't one phone's data, are unchanged.
Fixed
-
A device sync could free a phone in use (#348). Every 30 s by default, the sync read each phone, spent seconds in adb and the iOS tools, then wrote the whole row back. That undid anything written in between:
- a live preview's hold, after which Stop had nothing to release;
- a new session's lock, so a second session could get the same phone;
- a team change;
- a stream's port.
iOS simulators came back free from every sync, even mid-session. The sync now writes only what discovery changed. In a timed test on the lab, a preview started inside a sync lost its hold 2 times in 3 before this fix and 0 times in 3 after.
-
The "New device" webhook fired for every phone on every sync (#348), and the dashboard got a
device_addedfor each. Both now fire only for a phone that is actually new. -
Dashboards signed in through
/logingot no live events (#349). The socket accepted only the older raw-key cookie, so with auth enabled the header stayed on "Connecting…". Pages that rely on live events alone, such as session activity, healing, network capture and selector health, never updated. Only the Devices page, which also polls, looked right. -
Team-scoped Appium sessions failed on every real server (#345). The Prisma store's team filter put
nullinside aninlist, which Prisma rejects. This broke device allocation for a team-bound API key orxenon:team. -
A recording's
video.mp4ignoredRange(#341). It advertisedAccept-Rangesbut always sent the whole file.
Notes
-
New endpoints:
GET /xenon/api/recordings;DELETE /xenon/api/recordings/:groupId;GET /xenon/api/recordings/:groupId/source.mp4.
GET /xenon/api/recordings/:groupIdadds asummary. The lease-create response'sappiumCapabilitiesaddxenon:options.leaseToken. -
An admin asking for a recording group with no rows now gets 404 from
video.mp4,videos.zip,bundle.zipandannotations/clear. Before, this was an empty 200 or a 500. -
Members don't get the side-by-side video of recordings made before 1.22.1, which have no layout file to check. Admins still do.
-
A live event's team scope is fixed when the dashboard connects. A membership change applies after a reload.
1.28.1
Patch release. A phone open in two tabs keeps its live preview when one tab closes, and an iPhone left in an abandoned preview is released like an Android phone. Device control's tab is named after the phone.
Fixed
- Closing one of two tabs on the same phone froze the other (#337).
Leaving device control, or removing a Live devices tile, stopped the
preview and released the phone at once, even while another tab was
watching it. A test could then take the phone.
- Leaving now asks the server to let go, and the server waits 3 seconds. It stops the preview and releases the phone only if nobody is still watching and no recording is running.
- A reload keeps its stream, because the new page starts it again within those 3 seconds.
- Stop still stops the preview for everyone.
- An abandoned iPhone preview was never released (#338). A crashed browser, a sleeping laptop or a lost network held the iPhone until someone released it by hand or restarted the server. The server now checks every minute and releases a preview that nobody has watched for 10 minutes, as it does on Android since 1.28.0. It keeps a preview that an Appium session or a recording relies on.
- Device control's tab read "381103b720057ece · Device" (#336). It now reads "Galaxy S9+ · Device", the name its header shows.
- Android offered "Write to device" for the clipboard (#336), which only ever failed: Android lets Xenon read the clipboard, not write it. The button is gone on Android, and a note under the field says why. iOS keeps writing.
- The Omni-Vision divider's grip was hard to see (#336): 1.66:1 against the page in dark and 1.34:1 in light. It is now 5.70:1 and 5.39:1.
Notes
POST /xenon/api/control/:udid/stream/leaveis new. It has the same ownership rule asstream/stopand answers 202.- No database or configuration changes.
1.28.0
Minor release. Omni-Vision is rebuilt around a compact element tree you can use from the keyboard, beside a details pane you can resize, and its "AI Insight" tab becomes Checks. Device control now lets go of a phone when you close its tab, so tests no longer queue behind a page nobody is viewing.
Added
- Checks, replacing "AI Insight" (#331). Nothing in that tab was AI. It gives seven results for the selected element: unique locator, stable locator, interactive, enabled, has size, on screen, and accessible name. Each passes, warns or fails with a one-line reason. A locator that isn't unique comes with one that is, if one exists.
- A notice when a capture may be out of date (#331). The tree shows "Captured 12 s ago". After any input to the device, it says "The screen may have changed since this capture." and offers Refresh, and the highlights on the device dim.
- A resizable split between the tree and the details (#332). Drag the divider, or focus it and use ← → Home End. Double-click resets it, and the browser remembers where you left it.
- An element path in Info (#332). It lists the element's ancestors, and each one selects that ancestor.
- A search match count (#332), which screen readers announce.
Changed
-
A compact element tree (#332).
- Rows are 26 px high.
- A run of plain wrapper elements shares one row, such as "hierarchy › FrameLayout › … › launcher", and each part still selects its own node.
- Names are short: a resource id is shown without its package.
- Each row has a role icon.
On a 66-element Android home screen, a fully expanded tree now has 45 rows instead of 66 and 10 levels instead of 19. Names cut off went from 48 to 12.
-
The tree works from the keyboard (#332). It is one tab stop, with ↑ ↓ → ← Home End to move and Enter or Space to select. Screen readers hear it as a tree.
-
The selected element is always shown (#332). Selecting it on the device, in search, from the path, or again after a Refresh opens its branch and scrolls to it. Refresh keeps the element selected with its new values, and keeps your Code gen locator.
-
The locator buttons have labels (#332): Test, Verify, Tap and Copy.
- Without an Appium session on the device, one note explains why Verify and Tap are unavailable.
- The session is checked again with every capture.
-
Code gen wraps long lines instead of scrolling sideways, and the element count and source moved to the capture line (#332).
Fixed
- Closing device control's tab left the phone held (#333). Closing the
tab, reloading or typing another address kept the live-preview hold, so
Appium sessions for that phone queued for 5 minutes and then failed with
"Device is busy".
- The page now releases the phone as it goes away.
- On the server, a hold that nothing released (a crashed browser, a sleeping laptop, a lost network) is released 10 minutes after its last viewer left. With H.264 preview it was never released before, and with MJPEG the check ran hourly; it now runs every minute.
- Neither releases while anyone is watching, over either transport, or while a recording runs.
- The Test locator button never outlined its match on the device in device control (#332).
- After a Refresh, Info showed the old capture's element (#332).
- A slow capture could replace a newer one (#331), for example the previous device's.
- Search hid matches inside collapsed branches (#331).
- "Interactable" was claimed for any enabled container (#331).
- The health monitor's "stream still running" check never worked (#334). It looked up the stream services by a name that TypeDI does not register, so a busy device whose Appium session had been lost could be reclaimed while a stream was still running on it.
Notes
- Two tabs showing the same phone share one hold, so closing either releases it. Leaving device control through the app already did this.
- On iOS, closing the tab releases the phone, but the server-side release of an abandoned hold is Android-only for now.
1.27.0
Minor release. Device control's Actions tab is redesigned around apps: you can install builds from the Apps library, and search and uninstall installed apps from a list. The Android clipboard now reports honestly: it can be read, and a write says plainly that it isn't possible.
Added
- Install from the Apps library (#328). The Actions tab lists the library's builds for the device's platform, with version and package, and installs the one you choose. Before, it could only upload a file from your computer. With nothing for this platform, the menu says so and links to the Apps page.
- A searchable list of installed apps (#328), replacing the dropdown of
package IDs.
- Each app has Copy and Uninstall, and uninstalling still asks first.
- Typing a package ID the list doesn't show, such as a system app, offers to uninstall it. This replaces "Or enter manually".
- Write text to the device's clipboard (#328), next to Read and Copy. This works on iOS; on Android see below.
Changed
- The Actions tab has three sections (#328): Apps, Text and
clipboard, and Swipe. It uses the app's standard buttons and fits
without scrolling.
- Upload file installs as soon as you pick a file.
- The round D-pad is now a row of four swipe buttons, which keyboard and screen-reader users can still use.
- Copying falls back when the browser blocks it (#328). A dashboard
served over plain
http://on a network address can't copy. The text is selected instead, with a hint to press ⌘C or Ctrl+C.
Fixed
- Android clipboard writes reported success and did nothing (#329).
Appium Settings, which Xenon uses to reach the clipboard outside a test
session, can only read it.
POST /control/:udid/clipboardnow answers 501 on Android with that reason, and the dashboard shows it. - Android clipboard reads couldn't fail (#329). Any error came back as an empty clipboard. A failed read now answers 500 with the reason, and a successful one uses the documented Appium Settings action.
1.26.2
Patch release. Device control's Actions tab asks before uninstalling an app, reports the result of every action, and works with a keyboard and a screen reader. No server changes.
Fixed
- Uninstall ran on one click (#326). It removed the app and its data straight away. A dialog now asks first, naming the app and the device, and Cancel or Esc closes only the dialog. The result says what happened ("Uninstalled com.foo from Galaxy S9+") or why it failed.
- Actions failed silently (#326).
- Smart Input has a Send button (Enter still works) and shows "Sent" when the text goes through. A failure shows the reason and keeps your text so you can resend it.
- Swipe buttons say why a swipe failed.
- Clipboard errors fit the platform: the Android hint about the Appium Settings app no longer shows on iOS, which gives the actual reason instead.
- Install messages name the device and the file, not the UDID.
- Keyboard and screen readers (#326).
- The four swipe buttons, the text field, the app picker and the manual package field now have names.
- Tab now reaches the "Select File" chooser, which shows a focus ring.
- Light theme (#326). Placeholder text measured 2.11:1 and now passes (4.80:1), and the manual package field no longer looks disabled.
1.26.1
Patch release. Device control names a device the way its Devices card does. No server changes.
Fixed
- Device control showed the codename (#324). Opening the Galaxy S9+ from the Devices page showed "star2ltexx" in the header. The header now reads "Galaxy S9+", falling back to the reported name when a device has no friendly one, and screen readers announce the view by that name too. The Shell tab's greeting uses it as well.
1.26.0
Minor release. The Devices page can filter by platform and by real or virtual device, and its search finds a device by the name its card shows. Filters live in the link. Secondary buttons on the Devices page, such as Reserve and Release, look as designed again. No server changes.
Added
- Platform and Type filters (#321). A second toolbar row, under the
status tabs, holds:
- Search. Press
/to jump to it. - Platform and Type menus. Each is a quiet "+ Platform" button until
set, then a chip such as "Platform: iOS" with an × to clear it. The
menus show counts:
- Platform: Android and iOS, plus tvOS when the lab has one;
- Type: real devices, or virtual ("simulators and emulators").
- Clear, shown only while something is filtered.
- A count such as "2 of 5 devices", and an icon Refresh.
- Search. Press
- Every count accounts for the other filters (#321). With iOS selected, "Ready 3" means 3 ready iOS devices, so a number is always what you'd get by choosing it.
- Filters live in the link (#321), for example
/devices?status=ready&platform=ios&q=galaxy. They survive a reload, and opening a device and closing it returns to the same filtered list. Back leaves the page rather than stepping through each change.
Fixed
- Search couldn't find the name on the card (#321). It matched the raw name and UDID only, so "Galaxy" didn't find a Galaxy S9+ whose raw name is "star2ltexx". Search now matches every word you type anywhere in the shown name, the codename, the maker, the model, the platform and OS version, "Emulator" or "Simulator", the team or the UDID.
- Device control's stylesheet restyled buttons on the Devices page (#322). Its own secondary-button rule shared a class with the app's Button. On the Devices page it made Reserve and Release bold and over-padded, and squeezed an icon button's icon to nothing. The class is now renamed. A new test fails if a stylesheet outside the Button's own restyles every Button again.
- Nothing matching the filters now reads "No devices match these filters", with a Clear filters button (#321).
1.25.0
Minor release. Live Devices recordings can no longer be lost by stopping the device's stream underneath them. When the idle timeout releases your devices, a notice says so and can put them back. Live Devices also shows the same device names as the Devices page.
Fixed
- A recording left alone for 5 minutes was lost (#317). The idle timeout ran during recordings, and its release stopped the device's stream under the recording. Stopping the recording then gave FAILED with 28 bytes, so everything was lost, including unattended runs such as an automated test. The timeout is now paused while recording. After Stop, the 5 minutes start again.
- The server stopped a stream that a recording was reading (#318).
POST /control/:udid/stream/stopnow answers 409device_recordingwhile the device is being recorded, and leaves the stream and the lock alone. The answer includes the recording'sgroupId.- Admins are refused too: recordings stop by group, so forcing one device
would end the whole group.
POST /recordings/:groupId/stopdoes that cleanly, keeping the video and releasing the devices. - A caller who may not use the device still gets the usual 403 and learns nothing about the recording.
- Admins are refused too: recordings stop by group, so forcing one device
would end the whole group.
- Clicking a recorded device in the Live Devices list did nothing visible (#319). It now shows "Stop recording before changing the devices on the grid.", as adding a device already did, and sends no request.
- Live Devices showed codenames such as "star2ltexx" (#319). The device list, the tile's label and the Remove button now use the name the Devices page shows, such as "Galaxy S9+". Searching the list by codename still finds the device.
Added
- A notice after an idle release (#317): "Released N device(s) after 5
minutes without activity.", with Restore devices and Dismiss. It
stays until you dismiss it.
- Restore devices puts the same devices back, the same as clicking them in the list.
- A device someone else now holds, or one that's offline or no longer connected, is skipped, and the notice names it.
- Choosing Release now in the warning shows no notice.
1.24.0
Minor release. On Live Devices, recording no longer switches Annotate on, so you can use the phone as soon as you press Record. An Interact / Annotate switch replaces the Annotate button. No server changes.
Changed
- A recording starts on Interact (#315). Pressing Record used to turn Annotate on, which paused taps and swipes until you found and turned off the Annotate button. Taps now reach the phone straight away. Marks you draw still stay on screen when you switch back.
- An Interact / Annotate switch replaces the Annotate button (#315).
- It always shows which mode you're in.
- Annotate is unavailable until you start recording, and its tooltip says so.
- The drawing tools work only on the Annotate side, as before.
Added
- Esc returns to Interact while annotating (#315). It's ignored while you type in a text field, and it isn't intercepted in Interact mode, so a focused Android device still receives Esc as Back. The on-tile label now reads "Annotating · Esc to stop".
1.23.1
Patch release. Two polish fixes on the Devices page. No server changes.
Fixed
- Control and Reserve looked like plain text (#312). Control was a grey
outline and Reserve had no border at all.
- Control is now a green-tinted button in the theme's accent colour, and fills solid green on hover.
- Reserve and Release are outlined buttons.
- The tinted style is available to the rest of the app as the Button's
tonalvariant.
- The status filters read flat (#313).
- The selected filter now uses the accent colour.
- Ready, Busy, Reserved, Maintenance and Offline each have a dot in the colour of that state's cards.
- A filter with no devices fades back, so the others stand out.
- Selector Health's Window filter gets the same selected style.
- Refresh wrapped onto a line of its own in narrow windows (#313). Search and Refresh now wrap together.
1.23.0
Minor release. The Devices page shows each device by the name people know it by, with a phone or tablet outline, in a redesigned card that leads with the device's state. The server now reads each device's name, model, maker and form factor. Includes a database migration that is applied automatically at startup.
Changed — operator action may be needed
- Upgrade the hub before its nodes (#309). A node running this version sends four new device fields. A hub on an earlier version stores a node's devices exactly as sent, so it rejects them and the node's devices do not register. This hub ignores device fields it cannot store, so later upgrades in either order are safe.
- Database migration: four nullable columns on
Device(#309):marketingName,model,manufacturerandformFactor.runMigrationsapplies them at startup (db pushon SQLite,migrate deployon PostgreSQL), and existing rows need no backfill. If you run withXENON_AUTO_MIGRATE=false, apply20260926120000_device_identityyourself before starting this version.
Added
-
Friendly device names (#309). The server reads them when it discovers a device, and never holds up discovery (5 s limit):
- Android: the name on the phone's About screen ("Galaxy S9+", or whatever your lab renamed it to), plus model, maker, and phone, tablet or TV. Emulators use their AVD name.
- iPhone and iPad: the model name from a built-in table (iPhone 11 through the iPhone 16 family, and recent iPads). A model the table doesn't know shows the device's own name rather than a guess.
- iOS simulators: keep their name.
A device that can't report these fields registers exactly as before.
Changed
-
Redesigned device card (#310). The card is about 200 px tall instead of 320, with four parts:
- Status band across the top, showing the state and what the device is doing ("Busy · Test session · 12m", "Reserved · 46m left · by priya@…"). Ready is a quiet grey band, so the unusual states stand out.
- Name block: a phone, tablet or TV outline (dashed for emulators), the friendly name, and maker · model · OS.
- Badges: battery, temperature and team, with tags as quiet text.
- Footer: a quieter Control button, with the reason beside it when Control is disabled.
The "Real" and "Shared" labels, the server URL, the network address and "Time in use" are no longer on the card.
-
The ⋯ menu holds the copy actions (#310): Copy UDID, Copy server URL, Copy IP address and Copy capabilities.
-
Admin-only menu items (#310). Manage tags, Assign team (which replaces the clickable team chip) and Enter/Exit maintenance now appear for admins only. The server has always refused them to members, who got an error.
Fixed
- Card text below 4.5:1 contrast (#310). The reserved band in dark theme, the busy band in light theme and an offline card's details now all read at 4.5:1 or better.
1.22.4
Patch release. The device cards on the Devices page show each device's real state and offer only actions that can work. No server changes.
Fixed
- A device in maintenance showed as a red "Error" (#307), and no status filter included it: the filters added up to one fewer than All. It now reads "Maintenance" and has its own filter, and every device counts in exactly one filter.
- Cards offered actions that could not work (#307). Control was enabled on offline devices, devices in maintenance and devices another user was controlling, and Reserve showed on offline devices. Control is now disabled in those cases, with the reason shown on the card ("Device is offline", "In maintenance", "A test is running on this device", "Another user is controlling this device"). Admins can still control a device in maintenance or one another user holds, as the server allows.
- Internal IDs on the cards (#307). "SID · manual_u42" and "RES · …" now read "Live control by another user", "Test session · 12m" and "Reserved by you · 46m left".
- The reservation countdown showed fractions of a second (#307), such as "46m 16.1s", and did not change between refreshes. It now shows whole minutes.
- Low battery and high temperature were blue (#307), the colour of a reservation. They are now amber.
- Emulators showed a battery and temperature (#307), which are synthetic, and offline devices showed their last, stale reading. Neither shows a badge any more.
1.22.3
Patch release. A polish pass on Live Devices: clearer layout, proper icons, plainer wording, and better screen-reader support. No server changes.
Changed
- The grid starts on Auto (#305). It sizes itself to the devices on it, so one or two phones fill the space instead of sitting in a 2×2 grid with empty cells. The other layouts are still one click away.
- Real icons replace text symbols and an emoji (#305) on the recording toolbar, the tile's side buttons and the device list, matching the rest of the app.
- While recording, the REC timer takes Record's place (#305) instead of sitting next to a greyed-out Record button.
- Clearer tile messages (#305). "Starting stream…" shows the device name instead of its ID, and on an iPhone says a start can take up to 20 seconds. The "Connection failed" screen and the tile's side buttons now follow the light and dark themes.
- Plainer wording (#305). "Grid" instead of "mosaic", "Home" instead of "Home (android)", and the video-ready message points up to the download button, which is where it is. The device list says it is loading (or that loading failed) instead of showing "No devices online." before the list arrives.
Fixed
- Screen readers (#305). Each device in the list is announced by its name and platform and says whether it is on the grid; the iOS / Android groups say whether they are expanded; the tile's side buttons are announced as Home, Back, Recent apps, Screenshot and Remove instead of by their symbol.
1.22.2
Patch release. Live Devices tiles respond where you tap, and a tile whose stream ends reconnects by itself instead of going blank or freezing.
Fixed
- Taps near the top or bottom of a Live Devices tile landed off target (#302). A tile is often letterboxed, as in the 3×2 layout, so the picture fills only part of it. Taps were mapped against the whole tile, which put a tap near an edge about 220 px (Android) or 75 points (iPhone) toward the middle. Taps are now mapped against the picture, and a press on the black bars around it is ignored. A swipe that ends off the picture stops at the screen edge.
- After a server restart, a reopened Android tile couldn't be tapped (#302). Only the "start stream" request fetched the screen size, and reopening a tile never sends it. The size is now fetched by every request a tile uses to connect to the stream.
- A tile went blank or froze when its stream ended (#303). This affected iPhone tiles, and Android tiles on the MJPEG stream (for example while recording). After a server restart the tile went blank; after the stream was stopped it froze on the last frame. Either way it stayed "live" and never retried, because the browser reports nothing when such a stream ends. A live tile now asks the server every 5 seconds whether its stream is still running and reconnects if not. While the server is unreachable, for example mid-restart, the tile waits instead of giving up.
1.22.1
Patch release. Completes 1.22.0's annotation work for multi-device recordings: the side-by-side video now carries every mark, and each device's own video shows its marks at the right moment.
Fixed
- The side-by-side video of a multi-device recording had no marks
(#299). Each device's marks are now drawn into its own cell, in the right
place and for the right time window, exactly as in that device's own
video. This applies to
composite.mp4,videos.zipandbundle.zip, and the download is prepared in the background when you press Stop. Recordings made before this version have no layout record, so their side-by-side video is served without marks, as before. - Marks appeared early in each device's own video in multi-device recordings (#300). By about 1–3 seconds, depending on how long the devices took to start. Each device's video starts recording before the dashboard's timer does. Xenon now records that head start per device and moves the marks to match, so they line up with the screen as they do in the side-by-side video. Single-device recordings were not affected.
- Marks drawn after a page reload used a different timebase (#300). A reload restarted the recording timer from the first device's start rather than the original start, so in multi-device recordings those marks were off by the same head start. A reload now resumes on the original start.
1.22.0
Minor release. Annotations on Live Devices recordings now come out in the downloaded video exactly as drawn: same place, same shape, same colour, gone when you press Clear marks. A recording also survives a page reload. The Bookmark button is removed. Includes a database migration that is applied automatically at startup.
Changed — operator action may be needed
- Database migration: one nullable column,
Annotation.end_timecode_ms(#296).runMigrationsapplies it at startup (db pushon SQLite,migrate deployon PostgreSQL), and existing rows need no backfill. If you run withXENON_AUTO_MIGRATE=false, apply20260925120000_annotation_end_timecodeyourself before starting this version.
Fixed
- Marks appeared in the wrong place in the recorded video (#295). The drawing layer was stuck at the browser's default 300×150 canvas instead of covering the tile, so marks were measured against the wrong box. Nothing below the top 150 pixels of the tile could be drawn at all.
- Marks were also off-target when the tile was letterboxed (#296). A tile added before the device reported its screen size kept a 9:16 fallback, and a narrow 3×2 cell squeezes a tile out of shape. Marks are now measured against the video picture itself, not the tile.
- A page reload lost a running recording (#296). So did closing the tab
or navigating away. The server kept recording and nothing in the dashboard
could stop it. The page now picks the recording back up (REC timer, Stop,
and the marks on screen) from the new
GET /xenon/api/recordings/active. - The same device could be recorded twice at once (#296). Record after a
reload started a second capture; four of those used up
maxConcurrentRecordingsuntil a restart. A device that is already recording is now refused with the busy reasonrecording_other_group. - Clear marks only cleared the browser (#296). Every mark stayed in the video until the end. Clear marks now removes the marks from the video from that moment on, and it can no longer overtake a mark that is still being saved.
- Circles and arrows came out as filled boxes, and Draw was just another rectangle (#296). Each mark is now rendered by the browser with the same code as the live preview and composited over the video. Draw is real freehand.
- One text mark wiped every mark from the download, and text marks never appeared (#296, #297). Text marks can only be created through the API. The bundled ffmpeg has no font lookup, the text position used the wrong variables, and an apostrophe broke the filter. Xenon now ships a font (Inter, OFL-1.1) and escapes text correctly. If text ever fails to render anyway, the other marks still do.
- The annotation toolbar moved under the cursor (#296). Buttons appeared and disappeared as marks were drawn, so clicks landed on the wrong control. They now stay in place and grey out instead. The on-screen hint is now a small label, not a banner covering a third of a small tile.
Added
POST /xenon/api/recordings/:groupId/annotations/clearwith{ timecodeMs }(#296).- An optional
image(PNG data URL) onPOST …/annotation, burned into the video as-is. Marks without one keep the previous box rendering (#296).
Removed
- The Bookmark button and its
Bshortcut (#296). Existing bookmarks,POST …/bookmarkandbookmarks.jsonin proof bundles are unchanged.
Development
- LogcatView's recording specs leaked stubs between tests (#294). The suite passed but reported 5 unhandled errors; it now reports none.
- The control sweep covers the sign-in pages as a signed-out visitor (#293).
1.21.7
Patch release. Two small fixes in the dashboard sidebar and on the Users page.
Fixed
- The Invite user button on the Users page was unstyled (#291). It showed as plain text with the "+" stacked above it, at every width. Its styles lived in the Settings page's stylesheet, which the Users page never loads. They now live with the shared page header, so every page that shows a header button gets them.
- Users and Teams had the same icon in the sidebar (#290), so the two entries couldn't be told apart in the icon rail. Users now has its own icon (a person with a cog), matching its page header.
Development
- The control sweep now tests the Save, Discard and Restore Defaults buttons on Settings and Maintenance (#289). It had silently skipped them. It also names any control it couldn't test instead of only counting it.
1.21.6
Patch release. Finishes the auth-disabled clean-up started in 1.21.5.
Fixed
- With auth disabled, Profile opened on a password form that could only fail (#287). In that mode you are a synthetic admin with no account password, so "Update password" always returned an error. Profile now leaves that view out and opens on API tokens. Those work in this mode, acting as the bootstrap admin. With auth enabled, nothing changes.
1.21.5
Patch release. No Logout when authentication is disabled.
Fixed
- With auth disabled, the account menu offered Logout (#285). There is no session to end in that mode: every visitor is a synthetic super admin. Logout sent you to a sign-in page with no account behind it, and the only way back was to type a URL. The menu now says "Sign-in is off on this server." instead. The sign-in page itself redirects to the dashboard while auth is disabled. With auth enabled, nothing changes.
GET /xenon/api/auth/meincludesauthDisabled(#285):truefor the synthetic auth-disabled identity,falseotherwise.
Documentation
- The 2026-09-23 dashboard UI audit is now in the repo as
website-audit.md(#284).
1.21.4
Patch release. Device control behaves like a proper dialog for keyboard and screen-reader users.
Fixed
- The device-control view was not announced as a dialog (#282), and
keyboard focus could Tab onto the Devices page, sidebar and header hidden
behind it. It is now a modal dialog named after the device.
- The page behind can't be focused or clicked.
- Focus moves into it when it opens and returns to the Control button when it closes.
- Escape closes it, except while typing in a field.
- Toasts raised from device control are still announced and clickable.
Development
npm run test:sweep(#281) runs the control sweep that found the dead Upload app button and the hidden account menu. On every page it clicks each control with a real mouse click and fails if one does nothing or can't be clicked. It runs against a live server without changing anything: writes are stubbed and devices mocked. It takes about 18 minutes; run it before a release.
1.21.3
Patch release. The account menu works on the Devices and Apps pages again, and the unit tests are safe and green.
Fixed
- The account menu was unusable on the Devices and Apps pages (#279). The menu opened behind those pages' toolbars, so Theme, Profile and Logout couldn't be clicked. It has been like this since the top bar was rebuilt in April. The toolbars now sit below the header.
- Some selected states were shown only by colour (#279). Screen readers now hear which Profile view is open and which Overview fleet filter (All / Non-ready) is on.
Development
- Running the unit tests killed every process the user owned (#277). A
test gave
ProcessRegistrya fake process ID of 1.ProcessRegistrysignals process groups, so this becamekill(-1), which on macOS signals every process the user owns: the test runner, the Xenon server and every open app. The test now stubsprocess.kill, andProcessRegistrynever group-signals a process ID below 2. That also keeps a device helper that failed to start from signalling init when Xenon runs as root in a container. npm run test:allpasses in one run on Node 20 and 22 (#278): 1,247 tests..mocharc.jsonbecame.mocharc.js, which turns off Node's built-in TypeScript type stripping on Node 22.18+ so ts-node stays in charge. Specs no longer depend on another spec having run first, and three out-of-date tests were brought up to date.
1.21.2
Patch release. The Apps page's Upload app button works again.
Fixed
- Upload app on the Apps page did nothing (#274). The button sat inside a label around the hidden file input, and browsers don't pass a click on a button through to a label's input, so the file picker never opened. It now opens the picker itself and works from the keyboard too. It is disabled while an upload is in progress. The empty-page "Upload your first app" card was not affected.
- The Apps page had two primary buttons (#274). Refresh is now a secondary button, as on the Devices page, leaving Upload app as the page's one primary action.
1.21.1
Patch release. It fixes one light-theme defect found after 1.21.0 shipped.
Fixed
- The disabled Record button rendered as a pink block in the light theme (#272). On Live devices, Record stays disabled until a device is in the mosaic, and in light its faded red fill blended to pink. The white label measured about 1.9:1, and the button read as an alert. When disabled it now looks like the Stop, Bookmark and Annotate buttons beside it. Enabled, it is still solid red. The dark theme is unchanged.
1.21.0
Minor release. It adds a light theme, a calmer and more accessible sign-in, honest save errors across the dashboard, and fixes that keep passwords and reset links out of the server log (#265). If you run without SMTP, read "Changed — operator action may be needed" before upgrading.
Security
- Password-reset links were written to the server log in plaintext by
default. With no SMTP configured,
XENON_PASSWORD_RESET_LOG_FALLBACKdefaulted to on, and every reset (self-service or admin-triggered) logged the full link,/xenon/reset-password/<raw token>, atwarnlevel. The token is a credential for the account until it is used or expires (1 hour). Anyone who could read the log, or any system it was shipped to, could reset that account's password, including a SUPER_ADMIN's. The logger's secret redaction did not catch it. - Opening a reset link also logged the token, even with SMTP. The
token was a path segment (
/xenon/reset-password/<token>), and the page then checked it withGET …/reset-password/check/<token>. Both URLs were written to the server log on every open (Appium's[HTTP]request lines and Xenon's UI-fallback line), about five times per open, while the token was still valid. Links now carry the token in the URL fragment (/xenon/reset-password#<token>), which browsers never send to the server. The validity check isPOST /xenon/api/auth/reset-password/checkwith the token in the body, and the page removes the token from the address bar and browser history. Links issued before this release still work; their first page load is logged as before, and they expire within an hour. - Dashboard sign-in wrote the user's password to the server log. Appium
logs every request body (
[HTTP] --> POST /xenon/api/auth/login {"email":…,"password":…}), and Xenon's routes share Appium's server. The same applied to change-password, the reset calls, creating a user with a password, and API-key sign-in. The dashboard now sendsX-Appium-Is-Sensitive: trueon each of those requests, and Appium logs a placeholder in place of the body. Scripted clients (SDK, CI,curl) calling these endpoints are not covered by this change. Send the same header, or add a redaction rule with Appium'slogFiltersserver option. Rotate any password or API key that may have been logged, and purge old server logs, including copies shipped to a log aggregator. - A reset link stayed valid after the password changed. Resetting with one link, or changing the password, left every other outstanding link for that user usable for the rest of its hour. That included one that had leaked into a log. A successful reset or a password change now revokes all of the user's outstanding links.
Changed — operator action may be needed
XENON_PASSWORD_RESET_LOG_FALLBACKis now opt-in (defaultfalse). If you ran without SMTP and relayed reset links out of the log, that stops on upgrade. Use the Users page instead (below). To keep the old behaviour, setXENON_PASSWORD_RESET_LOG_FALLBACK=trueexplicitly; the server then logs a startup warning that reset links will be written to the log in plaintext. ConfiguringXENON_SMTP_URLremains the recommended setup.- Admins issue reset links from the Users page (
POST /xenon/api/users/:id/reset-link). With SMTP the link is emailed to the user, as before. Without SMTP it is returned once, to the admin who asked, shown in a copy-once dialog, sent withCache-Control: no-store, and never logged. The audit line records who issued a link for whom, not the link. Role rules apply: an ADMIN can issue links only for MEMBERs, a SUPER_ADMIN for anyone, and nobody for themselves (use Change password). - Self-service forgot-password no longer creates tokens it cannot
deliver. With neither SMTP nor the opt-in fallback, the request still
returns
204(so it still doesn't reveal whether an email exists) but creates no token.
A SUPER_ADMIN locked out with no other admin and no SMTP can still recover:
start the server with XENON_BOOTSTRAP_RESET_PASSWORD=true. That sets the
oldest active SUPER_ADMIN's password to XENON_BOOTSTRAP_ADMIN_PASSWORD and
signs out their sessions. Remove the variable afterwards.
Added
- Light theme (#270). A Theme control in the account menu offers Dark, Light or System. System follows the operating system's setting and switches live when it changes. The choice is saved per browser and applied before the page first paints, so there is no dark flash. Dark stays the default. All light text meets WCAG AA contrast. The terminal, the live log and Omni-Vision's generated code stay dark in both themes.
Changed
- Graphite dashboard theme (#268). Surfaces are neutral graphite, not
green-tinted black. Green now means "an action you can take" and teal means
"healthy". Before, one green meant both.
- Secondary text meets WCAG AA; the old dim text measured about 3.1:1.
- The primary button measures 9.2:1, up from 3.3:1.
- All-caps labels and Title Case headings are now sentence case.
- Coloured glows are gone.
- Sign-in pages (#264). A still, plain layout replaces the animated hero.
- All text meets WCAG AA contrast.
- Errors are readable ("Incorrect email or password.", an unreachable server, a server error).
- A rate-limited sign-in counts down from the server's
Retry-After. - A session-expired notice appears only after a real session expiry.
- The password field warns when Caps Lock is on.
- Inter and JetBrains Mono are now bundled with the dashboard, so the dashboard no longer requests Google Fonts. That matters for air-gapped labs.
- Restore Defaults asks first (#266). It resets the server config and zeroes every device's healed-selector count, and it now opens a confirmation that lists these effects.
Fixed
- Refused saves reported success (#266). The dashboard treated a 400/500 response to a save as success. Settings said the change had synchronized while the server had rejected it. Saves now fail visibly and show the server's reason. The network is blamed only for a real network failure.
- The header status was hardcoded (#266). The pill always read "Online", and its "Updated Xm ago" timer turned red after 10 minutes on any open tab. It now reflects the real connection: Live, Connecting…, Reconnecting…, or Disconnected since a given time.
- Settings "Discard" was disabled while a field was invalid (#266), exactly when you would want it.
- Smaller dashboard fixes (#266):
- The Sessions search icon no longer covers the typed text.
- Each page has its own browser-tab title.
- ⌘K now searches builds, and reaches Live devices, Selector health, Users and Profile.
- Xenon Control matches the dashboard palette again (#269). The Mac launcher's generated colours had not been regenerated after #268.
Internal
- Colours moved into design tokens (
web/src/tokens.css), with no visual change (#267). A test ratchets the remaining hard-coded colours (54, down from about 590), so they can only go down.
1.20.6
Patch release. A redesigned sign-in page, and the dashboard now shows the right version number.
Changed
- Sign-in pages redesigned (#261). Login, forgot-password and
reset-password share a new layout: one dark background, a frosted-glass
card for the form, and a hero that shows three devices streaming live, with
a short loop in which a selector fails and then heals. Nothing is loaded
from a third party, no dependencies are added, and all motion is CSS that
stops under
prefers-reduced-motion. The login form gains a show/hide password toggle and an error box that screen readers announce. Sign-in behaviour and the?next=redirect are unchanged.
Fixed
- The header showed
v0.3.0on every release (#262).__XENON_VERSION__was read fromweb/package.json, a private manifest that is never bumped, instead of the plugin's ownpackage.json. The account menu now shows the published version, and a test pins the two together. - Login fields had no accessible names. Their labels were not linked with
htmlFor, so screen readers read the inputs without names and clicking a label did not focus its field.
1.20.5
Patch release. Found by clicking every control on the Devices page.
Fixed
- A reservation's remaining time displayed an hour short. Reserving a
device for 2 hours rendered
RES · alice (1h). The server was right —reservedUntilwas 120 minutes away — but the card formatted the remainder withprettyMilliseconds(…, { compact: true }), which keeps only the largest unit and floors it, so the banner was a whole hour low for all but the first millisecond of a reservation: 2h showed 1h, 4h showed 3h, 8h showed 7h. It is the number someone reads to decide whether they have time to finish, so it either rushed them or had the device re-reserved for nothing. It now reads1h 59m, and an expired remainder rendersexpiringrather than-5s.
Known
- A device in maintenance still shows a red ERROR badge and matches no
status filter, because
userBlockedmaps to theerrorstatus kind and there is no maintenance bucket. A deliberately parked device therefore looks broken and cannot be found through the filters. Unchanged here — adding a status kind, its label, colour and filter bucket is a product decision rather than a defect fix.
1.20.4
Patch release. Three defects on the Apps registry page, all found by clicking every control on it.
Fixed
- Deleting an artifact left the row on screen. The delete had already
succeeded — the server returned zero apps — but
DELETE /apps/:idanswers204 No Contentand the api-client ended in an unconditionalres.json(), which throwsUnexpected end of JSON inputon an empty body. The caller'scatchswallowed it into a console error, so nothing on screen changed and clicking again re-asked "Permanently remove …?" about an artifact that no longer existed. 204 and 205 now resolve instead of parsing, in the shared client, so every bodiless response is safe rather than just this one. - A filter that matched nothing claimed the registry was empty. The
condition was
filteredApps.length === 0, which conflates "you have no apps" with "your filter excluded them", so a full registry rendered the first-run state — "No apps yet … Upload your first app" — offering the one action that could not help. The two cases are now distinct, and the filtered-empty one reports how many artifacts exist and offers to clear the filter. - The copy-bundle-id control copied an empty string.
internal.bundleis a placeholder for a nullpackageName, not a value, so the click put nothing on the clipboard — and its confirmation tick never appeared either, because it storedpkg-while the check compared againstpkg-null. The control is now only offered when there is something to copy.
1.20.3
Patch release.
Fixed
- Downloading an app from the Apps registry returned 404. The row was
found and the file was on disk, but
res.downloadrefused it and answered with an HTML error page instead. Uploaded apps live under~/.cache/xenon/apps/, and.cacheis a dot-segment, whichsendhides unless told otherwise. It began failing when Appium 3 moved to Express 5:send0.19 read an unspecifieddotfilesas legacy and its legacy branch looked at the last path segment alone, so a file named<uuid>.ipawas served regardless of the directories above it;send1.2.0 dropped that branch, sodotfilesdefaults toignoreand a dot anywhere in the path is now fatal. Measured against the same.ipa: Express 4.22.1 → 200, Express 5.1.0 → 404, Express 5.1.0 withdotfiles: 'allow'→ 200 and all 6,579,953 bytes. No traversal risk: the path comes from the App row Xenon wrote at upload time, never from the request.express.staticwas unaffected — with arootset,senddot-checks only the request path — so this was the one caller that needed it.
1.20.2
Patch release. One label; no behaviour change.
Changed
- The device-control tab now reads "Logs" rather than "Debug Logs". It
matches the tab's own state key, which has always been
logs, and the four labels beside it — Actions, Screenshot, Shell, Omni-Vision — none of which qualify what they show.
1.20.1
Patch release. Found by using the Debug Logs toolbar rather than by testing it.
Fixed
-
Every filter change leaked a WebSocket, and the leaked one was unfiltered. Changing the level or the
package:term closed the old socket and opened a new one — butclose()delivers its event asynchronously, and the effect's cancellation token was a ref shared across runs, which the next run had already reset. The dying socket'sonclosetherefore read as an unexpected close, retried on the 500ms backoff, and reconnected from its own closure carrying the previous filter. That socket overwrote the reference to the correctly-filtered one, which no later cleanup could reach. Measured on an iPhone 14: two sockets opened per change, and the survivor was always the one with no filter at all.The pane still looked right, because the browser filters locally as well — but the device firehose kept arriving, which is exactly what pushing
--processdown toostraceexists to prevent. The visible symptoms were a RECORD that captured every process on the device while a single app was selected, a pane that churned through its 5000-record buffer in seconds, and a FREEZE whose content scrolled away underneath a held scroll position.The token is now created per effect run. Verified against the same device: a 10-second recording filtered to one process went from 10,606 lines spanning many processes to 769 lines from a single PID, and five filter changes opened four sockets and closed three — one live, none stranded.
Minor release. The Debug Logs filter now means the same thing on both platforms, and the iOS stream stops fighting itself when two people watch it.
Added
- Filter iOS logs by app id. An os_trace record names the binary that
logged (
/…/Food Truck), never the app it belongs to, sopackage:meant something different on each platform — on Android a process name already is the package name. The executable is now translated to its bundle id fromios apps, read once at stream start rather than per record, and both forms are accepted: the app id the filter is documented around, and the name a reader can actually see in the pane. Apps that are not installed apps —backboardd,locationd— keep their own names, exactly assurfaceflingerdoes on Android. Verified against an iPhone 14: a viewer filtered tocom.example.apple-samplecode.Food-TruckJM7967FMBSreceived the app's 4 launch records and none of the other 173,768.
Fixed
- Two viewers of one iOS device silenced each other. A log session was
keyed by device and filter, so two people wanting different slices got two
ios ostracechildren — and os_trace_relay serves exactly one consumer. Measured against an iPhone 14, three concurrent children left every one of them mute, including a fresh capture taken from a shell; killing them restored 2,223 lines in 10s immediately. There is now one child per device. A viewer needing levels it does not emit widens it in place, keeping the multiplexer and every attached socket, and each socket narrows the shared stream to its own slice. - A viewer that named no levels inherited another viewer's Debug. On a shared child, "asked for nothing" cannot mean "unfiltered" — measured, 102,809 Debug records nobody requested. The stream now reports which levels it granted, and that is what the socket filters to.
- A filter value could not contain a space.
package:Food Truckparsed aspackage:Foodplus a text termTruck, which is not what anyone typing it meant. Double-quoted values are now one term.
1.19.0
Minor release: the Debug Logs tab works on iOS.
Added
- iOS device logs in the Debug Logs tab. Previously Android-only — the tab
told an iOS user their platform was unsupported. It now streams over the same
WebSocket, the same multiplexer and the same pane, with the transport chosen
by the device's platform. The source is
go-ios ostracerather thansyslog: syslog carries only Notice and Error, and Debug is what a developer opens the tab for. Records arrive as structured JSON, so the subsystem becomes the tag — which is what Xcode's console groups by — and os_log's levels are mapped onto the set the UI already speaks, withDefaultfolded intoIrather than promoted toW, because rendering an ordinary message as a warning is a lie the colour scheme then repeats. - A level filter applied at the source on iOS. The unfiltered firehose was measured at 5,485 lines/sec, past what a pane can show and past what a browser can hold; the default excludes Debug and the dropdown widens the running stream when a viewer asks for more.
1.18.1
Patch release. Install hardening; no runtime behaviour changes.
Fixed
- A reinstall could leave a Prisma client that cannot load — and therefore a
plugin that silently does not start. An installed client was found holding a
fragment of its own previous version:
path.join(…)on one line continued by a bare.join(…)on the next. That is valid JavaScript, so nothing caught it untilrequire()failed withpath.join(...).join is not a function, at which point Appium logged "Could not load plugin 'xenon'" and carried on serving 404s. The splice itself could not be reproduced — six reinstalls across--source=npmand--source=localcame out clean — so what is fixed is what is demonstrably wrong.postinstallregenerates the client inside the installed package and copies it over the one the tarball shipped, and the two are never the same size (110180 shipped, 110172 regenerated) because the embedded engine paths differ between a checkout and a package undernode_modules. A per-file copy leaves whatever the new generation did not overwrite; the destination is now replaced outright. And nothing checked the result: the client is now loaded at the end of generation, while the install is still on screen, and a failure prints the file, the error and the way out.
1.18.0
Minor release: every iOS locator the inspector suggests changes value, and many
unique badges change with them. Nothing breaks — the old ones did not resolve
— but saved locators will look different.
Fixed
- iOS class chains were missing their backticks, so none of them worked.
Appium's class-chain grammar requires them around the predicate. Measured
against a real iPhone:
**/XCUIElementTypePageIndicator[name == "Page control"]resolves 0 elements and**/XCUIElementTypePageIndicator[`name == "Page control"`]resolves 1. Every chain this service had ever produced was unusable — 280 in a single home-screen snapshot, each badged unique. Xenon's own frontend matcher already expected the backticked form, so the generator had been out of step with its own parser. - iOS XPath selected on
@text, which is an Android attribute. An XCUIElement hasname,labelandvalueand no text at all. - Both XPath and the predicate hardcoded which attribute they named. On iOS
node.textislabel || value, so an element that keeps its text invalueand has no label — a page indicator, for instance — gotlabel == "Page 2 of 2"and matched nothing. Both now name whichever of the two supplied it. -ios predicate stringand-ios class chainhardcodedunique: true. A predicate badged unique was measured matching two elements. Both now consult the same uniqueness index as every other strategy, which also reduced the unique-badged class chains in one snapshot from 280 to 51 — the difference between a claim and a count. Predicate uniqueness is deliberately conservative: a compound is at least as selective as its label term, so a unique label implies a unique predicate but not the reverse.
Verified end to end against a real iPhone. Across the four strategies, locators
badged unique went from class chain 0/10, xpath 9/10, accessibility id 10/10, predicate 8/10 to 60/60.
1.17.5
Patch release.
Fixed
- An installed WebDriverAgent was reported as missing, sending readers off
to reinstall an app that was already there. Two causes. The run log was
appended to across runs and never truncated, and that same file is the
evidence the failure is classified from — so a "Did not find test app" line
written on one day was still matched the next, and once a device had
genuinely missed WDA even once, every later failure of any kind reported it
as missing forever. The log is now emptied before each spawn. And there was
no way to say the other thing: go-ios distinguishes
cannot get test app information: Did not find test appfromcannot start test runner: LaunchAppWithStdIo: failed to launch app, but only the first had a classifier. The second now has one, and it points somewhere useful — an app that will not start is nearly always the device refusing to run it (untrusted developer certificate, locked screen, lapsed signature), none of which reinstalling fixes.
1.17.4
Patch release. Upgrade straight to this if you are on 1.17.2 or 1.17.3 — neither of those fixes works, and the damage they were meant to prevent still occurs.
Fixed
- An iOS session could uninstall WebDriverAgent from the device, and could
not complete. Both symptoms had one cause: Xenon sent
appium:usePreinstalledWDAalongsideappium:webDriverAgentUrl. The capability does not choose the startup strategy — the URL already does;selectWdaStartupStrategyNamereturnsexisting-urlon it before looking at anything else, and that strategy is explicitly hands-off. All the extra capability adds is a detour throughpreparePreinstalled, which kills the runner and then uninstalls every WebDriverAgentRunner not on a one-entry keep-list. Against a WDA that Xenon hosts through go-ios, each half produced one of the symptoms — both observed on a real iPhone: the uninstall as "Removing WebDriverAgent runner app 'com.qasecret.WebDriverAgentRunner .xctrunner'", leaving the device with no WDA until one is re-signed by hand, and the kill as the driver's own next request failing with ECONNRESET whileiproxystill held the port. The capability is now deleted from both capability buckets rather than merely not added, including when a caller supplies it: on this path honouring it would kill the WDA Xenon is hosting.
1.17.3
Patch release. Superseded by 1.17.4 — this fix does not work. It corrected
the format of appium:updatedWDABundleId (the capability wants the id without
the .xctrunner suffix, which the driver appends when building its keep-list),
but a correct keep-list only converts the uninstall into a kill: the session
still fails and the device's WebDriverAgent is still stopped. The argument was
right; the call should not have been happening at all.
1.17.2
Patch release. Superseded by 1.17.4 — this fix does not work. It stopped Xenon claiming a preinstalled WebDriverAgent it could not name, but named it wrongly, so the device's runner was still uninstalled on the next session attempt.
1.17.1
Patch release.
Fixed
- A Mac with more simulators than the port range holds could not see its own
attached iPhone. Discovery leased a
wdaand anmjpegport to every simulator installed on the machine, booted or not — 158 of them against ranges of 100 where this surfaced. Both ranges drained,acquirethrew, and the throw escaped the whole discovery pass, so IOSDeviceManager returned no devices at all andremoveStaleDevicesdeleted the physically attached iPhone for not appearing in its own device list; it vanished from the dashboard about thirty seconds after appearing. Three changes: ports are only leased to a device that can use one now (a real device, or a booted simulator);PortAllocator.tryAcquirereports exhaustion asundefinedrather than throwing, because a device that cannot get a port is still a device and must still be listed; andiOSCapabilitiesacquires just-in-time, after the stream-reuse check rather than before, since that branch deletes both ports. Measured with the defaultiosDeviceType=both: real devices listed at t+115s went 0 → 2, exhaustion errors constant → 0, and port leases held from a drained range → 3, with 0 of 113 simulators holding one.
1.17.0
Minor release.
Added
- The device preview says when the device is merely asleep. A sleeping
device streams a perfectly black frame, indistinguishable from a broken
stream or a black-themed app, and the wake control sat two panes away with
nothing connecting them. The preview now says so over the black frame, with a
Wake button wired to the existing unlock route. It asks the device rather
than looking at the picture: sampling the frame for black is the obvious
implementation and the wrong one, because plenty of real app screens on an
AMOLED panel are pure black and telling someone their display is off while
they are looking at it is the worse failure. Keyed on
Display Power: state=rather thanmWakefulness=— measured on a Galaxy S9, a screen turned off by the power button reportsmWakefulness=Dozingwhile the panel is genuinely off.doze(always-on display is lit) andunknownboth show nothing; every ambiguity resolves toward silence. A 2s read-through cache shares its in-flight promise, so six concurrent requests cost onedumpsys power. iOS implements no reader and the overlay never appears there.
1.16.1
Patch release. Upgrade straight to this if you are on 1.16.0 — that release puts the Omni-Vision tree controls out of reach.
Fixed
- The source badge pushed every action button outside the panel. The tree header is 309px wide in the embedded layout and holds a title, a count pill and four buttons. It did not fit on one line before 1.16.0 either — the count pill was already breaking mid-word into "66 / ELEMENTS" — and adding a third pill pushed Expand All, Collapse All, the inspect-mode toggle and Refresh past the panel's right edge, off screen. The last two exist in that header only because embedded mode has no other route to them, so 1.16.0 removed the only way to reach inspect mode or refresh a snapshot from the device page. The row now wraps deliberately and the actions never shrink or leave the panel.
- The document root offered a locator that can never resolve.
<hierarchy>is the XML document element, not a UI element, and Appium's XPath engine returns nothing for/hierarchy[1]. It was the first row anyone clicks Verify on, badged unique and answering "found 0". It now offers none, and the panel says why instead of showing an empty list.
1.16.0
Minor release. One new capability, and the fix that makes it usable.
Added
- Locator verification. Every suggested locator gets a Verify button that
resolves it through the real Appium driver and reports what came back: found,
not found, or ambiguous with a count. A second button finds and taps, so you
can prove a locator drives the element you meant rather than the one above
it. Ambiguous locators are refused rather than acted on. This answers a
different question from the match badges beside it, which test a locator
against the captured XML and cannot evaluate
-android uiautomatoror an iOS predicate at all.
Fixed
- The inspector now works while a test is running. Android permits one
UiAutomator instrumentation at a time, so
uiautomator dump— how the inspector read the hierarchy — is SIGKILLed whileio.appium.uiautomator2.serverholds it. Verification needs a driver. You could inspect a device or drive it, never both. The hierarchy now comes from the session when there is one, which is also the only tree a suggested locator can honestly be judged by, and from the device otherwise. - Four locator defects that only surface once a Verify button can contradict
them, each measured against a live driver: an XPath tag is the
fully-qualified class (
//Button[@resource-id=…]found 0,//android.widget.Button[…]finds 1); absolute XPaths are rooted at/hierarchy[1], which also means a screen with more than one window no longer shows only the first; uniqueness is counted once per document instead of walked per suggestion, replacing several hardcodedunique: true; andtext/nameare stringified at the parser, sinceparseAttributeValueturns a clock reading "22" into the number 22 and the tree view slices it. - A failed snapshot states its reason in the panel instead of leaving it blank, and a badge says whether the tree came from the session or the device.
Generated absolute XPaths change form, but the old ones resolved to nothing, so
no working saved locator is affected. The Android tree gains a hierarchy root
row.
1.15.0
Minor release: three additive features, no breaking changes.
Added
- Debug Logs recording. RECORD captures the raw log stream between an explicit start and stop and writes it to a file. Its own buffer, independent of the 5000-record display cap and of the active filter, because that cap holds only about a minute of a chatty device. Records are serialised on arrival, and at the 500k cap the newest are dropped so the window you chose keeps its beginning; truncation is declared in both the file header and its trailer.
- Click-to-inspect in Omni-Vision. Clicking an element on the live device selects it and shows its bounds, attributes and ranked locators. The overlay already existed but was hidden behind the embedded flag in the only place the component is used, so the capability was unreachable.
- Orientation-aware, icon-only device controls — a vertical strip beside a portrait device, a horizontal bar under a landscape one, so the controls sit where the device is not. At 1440 the preview column drops 683px → 406px and the log pane gains it.
All three verified against a Galaxy S9, not only in unit tests.
1.14.0
Minor release: the Debug Logs tab gains a feature, and one behaviour change is visible to existing API clients.
Added
- Continuous logcat streaming. The Debug Logs tab streams a parsed,
filterable logcat over an authenticated WebSocket (Android only; iOS renders
an unsupported state). Replaces a 3-second
logcat -d -t 500poll that appended without dedup, so the 1000-line buffer held roughly the same 500 lines twice. Adds per-tag colouring, field-aware filtering, find with prev/next, match case and a soft-wrap toggle.
Changed
- BREAKING for API clients:
GET /control/:udid/logsis now ownership-checked. It served the sameadb logcatbytes as the new WebSocket with no ownership check, which made that check decorative — a reader refused at the socket could GET the identical data. It joinsclipboardinOWNERSHIP_CHECKED_READS. Only denies when the device is held by another user; your own and unheld devices are unaffected and admins bypass. An external SDK client polling logs on someone else's busy device will now see 409.
Fixed
--plugin-xenon-auth-disableddid nothing. It is declared inschema.jsonso Appium accepted and echoed it, but every consumer readsconfig.authDisabled, which comes fromXENON_AUTH_DISABLED. Nothing bridged the two.- Long-lived sidecars were orphaned on every SIGTERM. Appium's own handler
exits before the plugin's async cleanup phases run, so
adb logcat, scrcpy, go-ios, WDA, iproxy and ffmpeg all survived shutdown and another set leaked on the next restart. Now killed synchronously from aprocess.on('exit')hook. - Three status indicators returned to their intended animation:
device-control.cssdefined its own@keyframes pulse-dot, a global nameindex.cssalso defines, so loading that sheet silently redefined the animation app-wide.
1.13.1
Patch release. Nothing here changes a caller-visible contract: a new
Session.user_id column is populated and preferred when reading, so callers
who were previously denied start being allowed — the bug being fixed rather
than a change of behaviour anyone depended on.
Fixed
- A session authenticated by
xenon:options.sessionTokenwas unattributable. The gate verified that token and discarded the payload, so the ownership guard denied the caller their own device for being unidentifiable. - A
df:optionskey pair now records the user alongside the ApiKey id, so new rows resolve their owner without the ApiKey hop.SessionOwnerResolverprefersSession.user_idand falls back toapi_key_id → ApiKey.userIdfor rows written before this release.
1.13.0
Minor release, not patch: this changes behaviour visible to callers.
Changed
/controlmutations against a device held by another user, or running another user's Appium session, now return 409. Your own session stays interactive.- MEMBER cookie sessions gain the
devicesscope. Device control was admin-only in practice before this, which also meant no dashboard user could ever receive the 409 — admins bypass the guard. GET /control/:udid/clipboardnow requires ownership. Other reads stay open.stream/startno longer overwrites a lock held by someone else.
Note: sessions created without the df:options.accessKey/token pair persist
api_key_id = null and are unattributable, so the fail-closed rule denies
everyone non-admin on that device — including the engineer who started the run.
1.13.1 extends attribution to xenon:options.sessionToken callers.
1.12.1
Patch release. Upgrade straight to this if you are on 1.12.0 — that release prevents the server from starting against an existing config file.
Fixed
- 1.12.0 would not start with a pre-existing config — its three new
retention args were added to
schema.json'srequiredlist. Appium validates the config file against that schema and refuses to start when a required key is absent, so any install upgrading into a config written before 1.12.0 died at boot withREQUIRED must have required property 'recordingFailedCleanupDays'and exit code 2, with nothing in the message to suggest the fix was to hand-edit a YAML file. The args were never meant to be mandatory: all three declare a default andCleanupServicedestructures with its own fallbacks. They are now optional and existing configs load unchanged. A guard test pins the count of args that are required despite declaring a default so the set cannot grow — each addition breaks every config written before it.
1.12.0
Minor release: a retention policy for Live Devices recordings — the one asset class nothing ever removed. Minor rather than patch because this is the first version that deletes recordings on a schedule; every earlier release kept them forever.
Added
- Recording retention and orphan sweep —
CleanupServicecovers Builds and Sessions (session.video_recordingis the Appium session video, a different model), and there is noDELETEroute for recordings, so the tree grew without bound. On one developer machine it had reached 313 MB across 78 directories, the oldest three months past the 30-day build window, with 265 MB of it unreachable — directories no DB row pointed at, left behind by failed starts. A third phase now runs on the existingbuildCleanupSchedulecron: expire rows by age and count, then sweep directories no surviving row can reach. Expiring first leaves those directories unreachable, so one pass reclaims both. Reachability is decided byfile_path, never by directory name — only 35 of 39 rows on that machine were named after their own id.
Changed
- New plugin args, shaped like their build counterparts:
recordingCleanupDays(30),recordingCleanupMaxCount(100), andrecordingFailedCleanupDays(2) — a failed recording holds no playable file, so it need not linger as long as real footage. - Recordings are now deleted automatically. Nothing was ever removed before,
so this changes behaviour on existing installs: raise
recordingCleanupDays, or set it very high, if recordings are retained as evidence. In-flight recordings are excluded from both rules, and the sweep refuses to run if theRecordingtable read returns no rows while directories exist.
1.11.2
Patch release: what a real device disconnected mid-recording turned up. The 1.11.1 fix worked, but it stopped one step short — and it made two dormant recording-bookkeeping gaps reachable.
Fixed
- A device that stopped answering served a frozen frame indefinitely — the
reuse health check in 1.11.1 tested whether a frame existed, not whether it
was recent, and
latestFrameis only ever assigned. The capture loop swallows capture errors without changing the session status, so a device that went away (unplugged, adb killed, reboot) left the MJPEG server rewriting the last good JPEG every 60ms: a frozen preview, and a recording thatffprobecalls perfectly healthy and which is a still photograph. That is worse than the 0-byte failure 1.11.1 addressed, because it looks valid. Capture health is now measured as how long the device has been silent — time, not a failure count, since a fastADB Exitand a 15sADB Timeoutare very different amounts of frozen video. Past a 10s grace window the session is refused for reuse and stream clients are disconnected, so ffmpeg finalises the footage it actually captured. Verified on hardware by disconnecting a device mid-recording: the frozen tail is bounded to the grace window instead of running until someone presses Stop. - A stalled device could still go unlogged — the warning for the above was emitted from the capture loop, but disconnecting clients is precisely what idles that loop, so the failure that would have crossed the threshold never happened. Measured on the device: 10 failures spanning 9.214s against a 10s threshold, then silence. Either path now claims the announcement, so a stall is logged exactly once no matter which notices it first.
- A recording stayed
RECORDINGwhen its ffmpeg exited on its own — nothing reconciled the row, so it kept noended_at, duration or size until a manual Stop, or until a server restart marked itFAILED. This was near-unreachable before — ffmpeg only exited early if it crashed — and the stall fix above makes a clean early exit a designed outcome. The row is now finalized from the exit (fail_reason=source_ended, so a short recording is visible as such rather than silently truncated), and the file gets the faststart remux the normal stop path performs. - Recording duration was wall-clock, not video —
duration_msmeasured the time between start and Stop, which matches the file only while capture keeps up. A disconnected device produced 335964ms for a 35.16s mp4, and the same number fed the recording-duration metric and the proof-bundle manifest. The finished file is now probed (via the bundled ffmpeg — there is deliberately noffprobedependency), falling back to wall-clock when it cannot be read. Nothing is lost: wall-clock remains derivable fromstarted_atandended_at.
1.11.1
Patch release: the Android counterpart to the stale-stream-session fixes that 1.11.0 made on the iOS side.
Fixed
- Android preview and recording could be handed a stream port that serves
nothing —
startStreamreused any session markedrunningorstartingwithout checking that anything was still serving it. A session whose HTTP server had closed, or one that went live without ever capturing a frame (startup warns after a 5s first-frame wait and continues anyway), was therefore reused indefinitely: ffmpeg exited 1 against the port and left a 0-byte mp4 — the same silent symptom as the 1.11.0 promise-map fix, reached by a different route. Reuse now requires the session to berunning, its own server to still be listening, and at least one frame to have been captured; anything else is torn down and restarted.GET /:udid/streamkept a second copy of the same short-circuit, so it now routes throughstartStreamas well — the Android analogue of the iOS route fix in 1.11.0.
1.11.0
Minor release: iOS live-streaming reliability, including the root-cause fix for WebDriverAgent being terminated a few minutes after launch.
Fixed
- WebDriverAgent terminated minutes after launch (iOS 17+) — the vendored
go-ios was pinned at v1.0.134, which does not keep the XCTest session alive.
iOS terminates the runner while the host-side
runwdaprocess stays alive withexitCode === null, so nothing on the host notices and it presents as a hang. Isolated by holding one WebDriverAgent build constant and varying only the launcher: v1.0.134 died at 2m51s,xcodebuildsurvived 11m+, v1.2.1 survived 12m+. Note the version bump alone would not have reached existing installs — the installer's cache check was version-blind — so it now records the installed version and upgrades in place. - iOS preview stuck on "Connection Failed" until a manual stop or restart — a
UniversalMjpegProxythat exhausted its reconnect budget stayed in the per-device cache, and because the upstream URL was unchanged it was reused indefinitely, short-circuiting every request to 503 even after the device recovered. Stopped proxies are now evicted and recreated. - A dead WDA went undetected for up to an hour —
GET /:udid/streamreused any session markedrunningwithout checking it, so recovery waited on the hourly watchdog. The stream path now health-checks before reuse and restarts on demand. - Android recordings silently produced 0-byte files until a server restart —
startStreamregistered its in-flight promise after invoking the task while releasing the key from inside the task's ownfinally. On the early-return path, which never awaited, the release ran before the registration and left a settled promise stuck in the map, so every later call returned a stale port for the life of the process. Both stream services now dedupe through aSingleFlighthelper in which the release cannot precede registration. - Perpetual empty-diff churn on the committed Prisma client —
@prisma/clientships some runtime.d.tsfiles with CRLF while the client is committed as LF, so everyprisma generaterewrote them. Generated output is normalised to LF and the freshness check now compares EOL-insensitively.
Changed
- Vendored go-ios pinned to v1.2.1 (was v1.0.134); the installer records the
installed version in
.go-ios-versionso existing caches upgrade rather than being skipped.
1.10.5
Patch release: Live Devices recording reliability and video-only downloads.
Fixed
- Empty / unplayable mosaic recordings — ensure MJPEG is running before ffmpeg
(stop Android H.264 preview when needed); persist orchestrator recording IDs in
the DB so Stop targets the correct ffmpeg; avoid macOS
taskpolicyEconomy wrap that broke VideoToolbox; remux to standard faststart mp4 on stop. - Composite download gate — only offer side-by-side when the server actually started a composite.
Added
- Video-only downloads —
GET /recordings/:groupId/video.mp4andGET /recordings/:groupId/videos.zip(mp4 files only; proof bundle remains for API clients). - Live Devices recording UX — elapsed
RECtimer, Starting/Stopping states, clearer Record N devices label, success/error banners, Download video / Download videos / Side-by-side actions.
1.8.1
Patch release: two real-device iOS / session-lifecycle fixes found while verifying the hosted-MCP lab path end-to-end on real Android and iOS hardware.
Fixed
- Real-device iOS sessions —
injectWDAUrlwrote the WebDriverAgent capabilities (webDriverAgentUrl,usePreinstalledWDA,updatedWDABundleId) into both the W3CalwaysMatchandfirstMatch[0]objects. The spec forbids a capability appearing in both, so appium-xcuitest rejected every real-device iOS session with "property 'webDriverAgentUrl' should not exist on both primary and secondary object" — even though WebDriverAgent itself launched fine. The injected WDA caps are now written to exactly one bucket. (#160) - Device stuck
busyafter a thrown session create — when the driver'screateSession(or the remote forward) threw, the device allocated for the session was left stuckbusy: truewith no session, unavailable until the hub restarted: the throw skipped bothfinalizeSessionandhandleSessionFailure(the latter only unblocks whencreateSessionreturns an error object, not when it throws). The session-creation block now releases the device on any thrown error before rethrowing. (#161)
1.8.0
First release since 1.7.10, covering 11 merged PRs. The headline is hosted-MCP
support: everything Xenon Studio's lab mode needs (granular MCP scopes, session
tokens, audit ingest, MCP plugin endpoints) is now available from a published
version instead of only from main.
Added
- Hosted MCP support — the
xenon-mcptoken audience is accepted on the REST bearer path so MCP plugin tool calls authenticate, plus the MCP plugin endpoints themselves. (#152, #153) - Granular MCP scopes — flat→granular scope mapping with a least-scope
default;
xenon-mcptokens carry granularscope/rolesclaims alongside a down-mapped flatscopesclaim, so a token's REST reach never exceeds its MCP grant. (#153) - Session tokens (R9) —
/auth/tokenmints a short-livedxenon-sessiontoken alongside thexenon-mcpone, andcreateSessiongained an opt-inxenon:options.sessionTokengate that closes the direct-to-Appium bypass. Off by default. (#153) - Capability flags —
/capabilitiesadvertisesmcpScopedTokensandsessionTokenGateso clients can detect support. (#153) - Audit ingest —
POST /xenon/api/audit/eventsfeedsEventLogService(mcp_auditevents), for gateway authz decisions. (#153) - Healing APIs —
GET /healing/selector-health(hotspots + etalon age), asessionIdfilter onGET /healing/events, and anxenon:options.healingTierstier-policy gate inHealingOrchestrator. (#152) - Socket bearer auth — the Socket.IO handshake accepts a hub-issued bearer JWT as a dashboard principal. (#151)
Fixed
- iOS shared-stream device lock — a manual stream lock (
manual_<actor>_<udid>) no longer overwrites a live Appium session'ssession_id, which previously made session teardown fail to release the device (leaving it stuckbusy: true) and caused the health monitor to skip reclamation. Session teardown now also stops an idle session-owned stream (when no one is watching) instead of letting it linger. (#157 — closes #149, #150) - Slow session creation — the device-availability wait now polls every 1s instead of 10s, so a create waiting on a briefly-busy device proceeds within ~1s of it freeing rather than quantizing into 10s chunks. (#155)
- Lease port allocation — hub node-pair credentials are wired into
LeaseService's port allocator. (#154) - Token minting —
/auth/tokenvalidates thatscopesis an array before minting, returning a clean 400 instead of failing opaquely. (#153) - Recordings — bearer principals can start recordings (
req.auth.userIdfallback). (#152) - mac-app hang diagnostics — app shutdown is no longer reported as a GPU crash, and normal suspension no longer trips the hang detector. (#145, #148, #156)
Changed
1.7.10 and earlier
Not tracked in this file — see the git history and PR list.