[Bug]: ContextManager keeps a stale #currentContext after a browsing context is destroyed (e.g. window.close()) — commands fail with "Cannot find context"
#15 476 ouverte le 7 août 2026
Métriques du dépôt
- Stars
- (6 029 étoiles)
- Métriques de merge PR
- (Merge moyen 15j 15h) (48 PRs mergées en 30 j)
Description
WebdriverIO Version
9.30.1 (present on main)
Node.js Version
v24.18.0
Mode
WDIO Testrunner
Which capabilities are you using?
{
"browserName": "chrome",
"browserVersion": "stable"
}
What happened?
ContextManager (packages/webdriverio/src/session/context.ts) caches the current browsing context in #currentContext and only ever revalidates it:
- on
browsingContext.navigationStarted, and - on WDIO-initiated
closeWindow/switchToWindowcommands.
When a browsing context is destroyed outside those paths — e.g. a popup self-closes via window.close() after an OAuth redirect, or an SPA removes a context — no event is handled, so #currentContext keeps referencing a dead context id. browsingContext.navigationStarted never fires for a destroyed context, so the stale context persists. Every subsequent BiDi command (browsingContext.locateNodes, etc.) fails with no such frame - Context ... not found, and the Classic fallback then throws no such window: target window already closed.
What is your expected behavior?
Subscribe to browsingContext.contextDestroyed and, when the destroyed context is the current one, reset the cached context/window handle and switch to a live window.
How to reproduce the bug.
Serve main.html and popup.html locally (e.g. python3 -m http.server 8081), then run the WDIO spec below.
main.html:
<!doctype html>
<html>
<body>
<button id="open">Open popup</button>
<script>
document.getElementById('open').onclick = () => window.open('popup.html', 'popup', 'width=400,height=400');
</script>
</body>
</html>
popup.html (self-closes shortly after opening):
<!doctype html>
<html>
<body>
<div id="popup-only">popup</div>
<script>setTimeout(() => window.close(), 500);</script>
</body>
</html>
test.js:
const { browser, expect } = require('@wdio/globals')
describe('current context after popup self-close', () => {
it('command runs while #currentContext still references the destroyed popup', async () => {
await browser.url('http://localhost:8081/main.html')
const mainHandle = await browser.getWindowHandle()
await $('#open').click()
const handles = await browser.getWindowHandles()
const popupHandle = handles.find((h) => h !== mainHandle)
expect(popupHandle).toBeDefined()
await browser.switchToWindow(popupHandle)
await browser.pause(1500) // popup self-closes
await expect($('#popup-only')).not.toBeExisting() // runs in the stale, destroyed context
})
})
Relevant log output
The last assertion fails the test:
[0-0] WARN webdriverio: Failed to execute browser.browsingContextLocateNodes({ ... }) due to Error: WebDriver Bidi command "browsingContext.locateNodes" failed with error: no such frame - Context D4E4C439E0D7D2971195AA465599BDA5 not found, falling back to regular WebDriver Classic command
[0-0] ERROR webdriver: WebDriverError: no such window: target window already closed
no such window: WebDriverError: no such window: target window already closed
1 failing (3.9s)
Environment: Chrome 151.0.7922.72 + ChromeDriver 151.0.7922.76 on macOS.
patch-package fix
Save as patches/webdriverio+9.30.1.patch and run npx patch-package webdriverio. (Source-level equivalent in packages/webdriverio/src/session/context.ts: add browsingContext.contextDestroyed to the subscription and handle it.)
diff --git a/node_modules/webdriverio/build/node.js b/node_modules/webdriverio/build/node.js
--- a/node_modules/webdriverio/build/node.js
+++ b/node_modules/webdriverio/build/node.js
@@ -309,6 +309,7 @@
#onCommandListener;
#onCommandResultMobileListener;
#navigationStartedListener;
+ #destroyedListener;
constructor(browser) {
super(browser, _ContextManager.name);
this.#browser = browser;
@@ -323,6 +324,7 @@
this.#onCommandListener = this.#onCommand.bind(this);
this.#onCommandResultMobileListener = this.#onCommandResultMobile.bind(this);
this.#navigationStartedListener = this.#navigationStarted.bind(this);
+ this.#destroyedListener = this.#contextDestroyed.bind(this);
this.#browser.on("result", this.#onCommandResultBidiAndClassicListener);
if (!this.isEnabled() && !this.#browser.isMobile) {
return;
@@ -332,9 +334,10 @@
this.#browser.on("result", this.#onCommandResultMobileListener);
} else {
this.#browser.sessionSubscribe({
- events: ["browsingContext.navigationStarted"]
+ events: ["browsingContext.navigationStarted", "browsingContext.contextDestroyed"]
});
this.#browser.on("browsingContext.navigationStarted", this.#navigationStartedListener);
+ this.#browser.on("browsingContext.contextDestroyed", this.#destroyedListener);
}
}
removeListeners() {
@@ -345,6 +348,7 @@
this.#browser.off("result", this.#onCommandResultMobileListener);
} else {
this.#browser.off("browsingContext.navigationStarted", this.#navigationStartedListener);
+ this.#browser.off("browsingContext.contextDestroyed", this.#destroyedListener);
}
}
async #navigationStarted(nav) {
@@ -360,6 +364,19 @@
return;
}
}
+ async #contextDestroyed(destroyed) {
+ if (!this.#currentContext || destroyed.context !== this.#currentContext) {
+ return;
+ }
+ this.#currentWindowHandle = void 0;
+ this.#currentContext = void 0;
+ const windowHandles = await this.#browser.getWindowHandles();
+ const handle = windowHandles.find((windowHandle) => windowHandle !== destroyed.context) ?? windowHandles[0];
+ if (handle) {
+ this.setCurrentContext(handle);
+ await this.#browser.switchToWindow(handle);
+ }
+ }
#onCommandResultBidiAndClassic(event) {
Related issues
- #15467 — sibling bug in the same BiDi session-manager layer (stale shadow-root cache after page-initiated navigations). Different root cause and file.