If you're upgrading an older WebdriverIO codebase to v9 rather than starting fresh, there's a breaking change that won't show up as a loud test failure — it shows up as a value that's suddenly Promise { <pending> } instead of the string you expected.
In v8 and earlier, an element returned by $() exposed its properties directly:
// v8 — worked
const elem = await $('elem');
console.log(elem.selector); // "elem" — a plain string
In v9, that same access no longer resolves synchronously:
// v9 — elem.selector is now a Promise, not a string
const elem = await $('elem');
console.log(elem.selector); // Promise { <pending> }
The fix is getElement() (or getElements() for a collection), which explicitly resolves the underlying element object before you read properties off it:
// v9 — correct
const elem = await $('elem').getElement();
console.log(elem.selector); // "elem" — back to being a plain string
Why this is easy to miss during an upgrade
Most day-to-day WebdriverIO code doesn't read element properties directly — it calls element methods: .click(), .getText(), .isDisplayed(). Those all still work exactly the same way in v9, because they're commands, not property reads, and the chainable promise handles them transparently either way. That's exactly why this breaking change is easy to walk past during a routine upgrade — your test suite can pass entirely, because the vast majority of code never touches this path, right up until you hit the one helper function or debug log line that was reading .selector, .elementId, or some other property directly off an element instead of calling a method.
The places I'd specifically go check in an inherited codebase before assuming an upgrade is clean:
- Custom logging or reporting utilities that print element metadata for debugging
- Any code that stores an element reference and later inspects it outside the normal command chain
- Third-party or in-house WebdriverIO plugins/services that reach into element internals
A quick way to find candidates before they surface as confusing runtime Promise values: grep for direct property access patterns on anything returned from $() or $$(), rather than waiting for a test to fail in a way that doesn't obviously point back to this cause.
The same release also removed a matcher family
While auditing for this, it's worth checking for the XXXContaining matchers in the same pass, since they were removed in the same version and have the same "silently becomes an error instead of degrading" profile:
// removed in v9
await expect($('elem')).toHaveTextContaining('Hello');
// v9 replacement — asymmetric matcher instead of a dedicated method
await expect($('elem')).toHaveText(expect.stringContaining('Hello'));
Neither of these changes is hard to fix once you know to look for it. The actual cost is in finding it — both fail in ways that look like ordinary bugs in your own test code rather than a documented breaking change, so if you're mid-upgrade and hitting an unexplained Promise value or a matcher that suddenly doesn't exist, this is worth checking before you go debugging the test logic itself.
