webdriverio/webdriverio

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

開放

#15,476 建立於 2026年8月7日

 (6 則留言) (0 個反應) (0 位負責人)JavaScript (1,793 個分叉)batch import
Bug 🐛Protocol Relatedgood first pickhelp wanted

倉庫指標

星標
 (6,029 顆星)
PR 合併指標
 (平均合併 15天 15小時) (30 天內合併 48 個 PR)

描述

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.

貢獻者指南