webdriverio/webdriverio

[Bug]: ContextManager keeps a stale #currentContext after a browsing context is destroyed (e.g. window.close()) — commands fail with "Cannot find context"

Ouverte

#15 476 ouverte le 7 août 2026

 (6 commentaires) (0 réaction) (0 personne assignée)JavaScript (1 793 forks)batch import
Bug 🐛Protocol Relatedgood first pickhelp wanted

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 / switchToWindow commands.

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.

Guide contributeur