From d7212bf449b28fe4c7f2c4fd3d15c50b54e6edf5 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 17 Sep 2026 00:22:07 +0200 Subject: [PATCH 1/4] Add JavaScript examples for Remote WebDriver docs Every section of the Remote WebDriver doc page (Basic Example, Uploads, Downloads, Browser specific functionalities) had no JavaScript example, only a badge-code placeholder. Adds a remote_webdriver.spec.js covering the same ground as the existing Java/Python/.NET/Ruby examples, plus a gridServer.js helper that spins up a local standalone Grid the same way Python's conftest.py server fixture does, and wires the new examples into the docs via gh-codeblock line references. Co-Authored-By: Claude Sonnet 5 --- .../javascript/test/drivers/gridServer.js | 58 ++++++++++++ .../test/drivers/remote_webdriver.spec.js | 90 +++++++++++++++++++ .../webdriver/drivers/remote_webdriver.en.md | 17 ++-- 3 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 examples/javascript/test/drivers/gridServer.js create mode 100644 examples/javascript/test/drivers/remote_webdriver.spec.js diff --git a/examples/javascript/test/drivers/gridServer.js b/examples/javascript/test/drivers/gridServer.js new file mode 100644 index 000000000000..5ab9556268c0 --- /dev/null +++ b/examples/javascript/test/drivers/gridServer.js @@ -0,0 +1,58 @@ +const { spawn } = require('node:child_process') +const http = require('node:http') +const path = require('node:path') +const portprober = require('selenium-webdriver/net/portprober') + +const SERVER_JAR = path.join(__dirname, '..', '..', '..', 'selenium-server-4.46.0.jar') + +function waitForServer(url, timeout) { + const deadline = Date.now() + timeout + + return new Promise((resolve, reject) => { + function attempt() { + http + .get(url, (res) => { + res.resume() + resolve() + }) + .on('error', () => { + if (Date.now() > deadline) { + reject(new Error(`Selenium server did not start within ${timeout}ms`)) + return + } + setTimeout(attempt, 200) + }) + } + attempt() + }) +} + +async function startGrid() { + const port = await portprober.findFreePort() + const child = spawn('java', [ + '-jar', + SERVER_JAR, + 'standalone', + '--port', + String(port), + '--selenium-manager', + 'true', + '--enable-managed-downloads', + 'true', + '--log-level', + 'WARNING', + ]) + + const url = `http://localhost:${port}` + await waitForServer(`${url}/status`, 60000) + + return { url, process: child } +} + +function stopGrid(child) { + if (child) { + child.kill() + } +} + +module.exports = { startGrid, stopGrid } diff --git a/examples/javascript/test/drivers/remote_webdriver.spec.js b/examples/javascript/test/drivers/remote_webdriver.spec.js new file mode 100644 index 000000000000..2b0c61f2d7e6 --- /dev/null +++ b/examples/javascript/test/drivers/remote_webdriver.spec.js @@ -0,0 +1,90 @@ +const { Browser, Builder, By } = require('selenium-webdriver') +const Chrome = require('selenium-webdriver/chrome') +const remote = require('selenium-webdriver/remote') +const assert = require('node:assert') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { startGrid, stopGrid } = require('./gridServer') + +async function waitForFile(filePath, timeout) { + const deadline = Date.now() + timeout + while (!fs.existsSync(filePath)) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${filePath} to be written`) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + +describe('Remote WebDriver Test', function () { + let driver + let gridUrl + let gridProcess + + beforeEach(async function () { + const grid = await startGrid() + gridUrl = grid.url + gridProcess = grid.process + }) + + afterEach(async function () { + if (driver) { + await driver.quit() + driver = null + } + stopGrid(gridProcess) + }) + + it('Basic Example', async function () { + const options = new Chrome.Options().addArguments('--no-sandbox') + driver = new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).usingServer(gridUrl).build() + }) + + it('Uploads', async function () { + const options = new Chrome.Options().addArguments('--no-sandbox') + driver = new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).usingServer(gridUrl).build() + await driver.get('https://the-internet.herokuapp.com/upload') + const uploadFile = path.resolve('./test/resources/selenium-snapshot.png') + + await driver.setFileDetector(new remote.FileDetector()) + await driver.findElement(By.css('input[type=file]')).sendKeys(uploadFile) + await driver.findElement(By.id('file-submit')).click() + + const fileName = await driver.findElement(By.id('uploaded-files')).getText() + assert.strictEqual(fileName, 'selenium-snapshot.png') + }) + + it('Downloads', async function () { + const options = new Chrome.Options().addArguments('--no-sandbox').enableDownloads() + driver = new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).usingServer(gridUrl).build() + + const fileNames = ['file_1.txt', 'file_2.jpg'] + await driver.get('https://www.selenium.dev/selenium/web/downloads/download.html') + await driver.findElement(By.id('file-1')).click() + await driver.findElement(By.id('file-2')).click() + await driver.wait(async (d) => (await d.getDownloadableFiles()).includes('file_2.jpg'), 5000) + + const files = await driver.getDownloadableFiles() + assert.deepStrictEqual(files.sort(), fileNames.sort()) + + const targetDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'downloads-')) + const downloadableFile = files[0] + await driver.downloadFile(downloadableFile, targetDirectory) + + const downloadedFilePath = path.join(targetDirectory, downloadableFile) + await waitForFile(downloadedFilePath, 5000) + const fileContent = fs.readFileSync(downloadedFilePath, 'utf8') + assert.strictEqual(fileContent.trim(), 'Hello, World!') + + await driver.deleteDownloadableFiles() + assert.deepStrictEqual(await driver.getDownloadableFiles(), []) + }) + + it('Browser specific functionalities', async function () { + const options = new Chrome.Options().addArguments('--no-sandbox') + driver = new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).usingServer(gridUrl).build() + + assert.ok(driver instanceof Chrome.Driver) + }) +}) diff --git a/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md b/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md index eed4543c0da5..0cb43788f267 100644 --- a/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md +++ b/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md @@ -49,7 +49,7 @@ and an options instance are both required. {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L20-L21" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< badge-code >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L40-L41" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -85,7 +85,8 @@ Ruby adds a local file detector to remote webdriver instances by default, but yo {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L33-L36" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< badge-code >}} +JavaScript does not include a Local File Detector by default, so you must always add one to do uploads. +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L50-L52" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -126,7 +127,7 @@ Each of the bindings have a method in the options class to set this. {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L43-L44" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< badge-code >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L59-L60" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -152,7 +153,7 @@ so the list is an immediate snapshot of what file names are currently in the dir {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L52" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< badge-code >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L68" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -177,7 +178,7 @@ Selenium looks for the name of the provided file in the list and downloads it to {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L57" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< badge-code >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L73" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -203,7 +204,7 @@ but you can also delete all files during the session. {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L62" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< badge-code >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L80" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -239,7 +240,9 @@ Ruby uses mixins to add applicable browser specific methods to the Remote WebDri the methods should always just work for you. {{< /tab >}} {{< tab header="JavaScript" >}} -{{< badge-code >}} +JavaScript returns the browser-specific driver class (such as `chrome.Driver`) directly when connecting to a +Remote Session, so no augmenting step is needed to access browser specific functionality. +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L85-L88" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} From d0a4e813fba1fcc77830ca4f7e54fd0a42c7936d Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 17 Sep 2026 01:17:25 +0200 Subject: [PATCH 2/4] Address Qodo review findings on Remote WebDriver JS examples - gridServer.js: waitForServer now checks the HTTP status code and parses the /status JSON payload for value.ready instead of resolving on any response, and startGrid kills the spawned Java process if the readiness wait fails/times out instead of leaking it. - remote_webdriver.spec.js: the Downloads test now waits until every expected file name is present (not just the last one requested) before asserting the file list, avoiding a flaky race; afterEach now stops the Grid process in a finally block so a rejected driver.quit() can't skip cleanup. - Updated remote_webdriver.en.md gh-codeblock line ranges to match the shifted line numbers in the spec file. Verified: npx mocha test/drivers/*.spec.js -> 7/7 passing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ko4CniqqrtSVntSMKyQrqq --- .../javascript/test/drivers/gridServer.js | 39 ++++++++++++++----- .../test/drivers/remote_webdriver.spec.js | 16 +++++--- .../webdriver/drivers/remote_webdriver.en.md | 14 +++---- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/examples/javascript/test/drivers/gridServer.js b/examples/javascript/test/drivers/gridServer.js index 5ab9556268c0..694d4a0baf98 100644 --- a/examples/javascript/test/drivers/gridServer.js +++ b/examples/javascript/test/drivers/gridServer.js @@ -9,19 +9,33 @@ function waitForServer(url, timeout) { const deadline = Date.now() + timeout return new Promise((resolve, reject) => { + function retryOrFail() { + if (Date.now() > deadline) { + reject(new Error(`Selenium server did not start within ${timeout}ms`)) + return + } + setTimeout(attempt, 200) + } + function attempt() { http .get(url, (res) => { - res.resume() - resolve() - }) - .on('error', () => { - if (Date.now() > deadline) { - reject(new Error(`Selenium server did not start within ${timeout}ms`)) - return - } - setTimeout(attempt, 200) + let body = '' + res.on('data', (chunk) => (body += chunk)) + res.on('end', () => { + try { + const { value } = JSON.parse(body) + if (res.statusCode === 200 && value && value.ready) { + resolve() + return + } + } catch (err) { + // Not valid JSON yet (e.g. server still booting) - fall through to retry. + } + retryOrFail() + }) }) + .on('error', retryOrFail) } attempt() }) @@ -44,7 +58,12 @@ async function startGrid() { ]) const url = `http://localhost:${port}` - await waitForServer(`${url}/status`, 60000) + try { + await waitForServer(`${url}/status`, 60000) + } catch (err) { + child.kill() + throw err + } return { url, process: child } } diff --git a/examples/javascript/test/drivers/remote_webdriver.spec.js b/examples/javascript/test/drivers/remote_webdriver.spec.js index 2b0c61f2d7e6..92f5de495282 100644 --- a/examples/javascript/test/drivers/remote_webdriver.spec.js +++ b/examples/javascript/test/drivers/remote_webdriver.spec.js @@ -29,11 +29,14 @@ describe('Remote WebDriver Test', function () { }) afterEach(async function () { - if (driver) { - await driver.quit() - driver = null + try { + if (driver) { + await driver.quit() + driver = null + } + } finally { + stopGrid(gridProcess) } - stopGrid(gridProcess) }) it('Basic Example', async function () { @@ -63,7 +66,10 @@ describe('Remote WebDriver Test', function () { await driver.get('https://www.selenium.dev/selenium/web/downloads/download.html') await driver.findElement(By.id('file-1')).click() await driver.findElement(By.id('file-2')).click() - await driver.wait(async (d) => (await d.getDownloadableFiles()).includes('file_2.jpg'), 5000) + await driver.wait(async (d) => { + const downloaded = await d.getDownloadableFiles() + return fileNames.every((name) => downloaded.includes(name)) + }, 5000) const files = await driver.getDownloadableFiles() assert.deepStrictEqual(files.sort(), fileNames.sort()) diff --git a/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md b/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md index 0cb43788f267..56106e8ff66f 100644 --- a/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md +++ b/website_and_docs/content/documentation/webdriver/drivers/remote_webdriver.en.md @@ -49,7 +49,7 @@ and an options instance are both required. {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L20-L21" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L40-L41" >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L43-L44" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -86,7 +86,7 @@ Ruby adds a local file detector to remote webdriver instances by default, but yo {{< /tab >}} {{< tab header="JavaScript" >}} JavaScript does not include a Local File Detector by default, so you must always add one to do uploads. -{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L50-L52" >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L53-L55" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -127,7 +127,7 @@ Each of the bindings have a method in the options class to set this. {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L43-L44" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L59-L60" >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L62-L63" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -153,7 +153,7 @@ so the list is an immediate snapshot of what file names are currently in the dir {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L52" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L68" >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L74" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -178,7 +178,7 @@ Selenium looks for the name of the provided file in the list and downloads it to {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L57" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L73" >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L79" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -204,7 +204,7 @@ but you can also delete all files during the session. {{< gh-codeblock path="/examples/ruby/spec/drivers/remote_webdriver_spec.rb#L62" >}} {{< /tab >}} {{< tab header="JavaScript" >}} -{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L80" >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L86" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} @@ -242,7 +242,7 @@ the methods should always just work for you. {{< tab header="JavaScript" >}} JavaScript returns the browser-specific driver class (such as `chrome.Driver`) directly when connecting to a Remote Session, so no augmenting step is needed to access browser specific functionality. -{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L85-L88" >}} +{{< gh-codeblock path="/examples/javascript/test/drivers/remote_webdriver.spec.js#L91-L94" >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} From c9c1843329e818d01e0b9b051b4a9360f1d97394 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 17 Sep 2026 08:43:52 +0200 Subject: [PATCH 3/4] Fix jar reference after 4.49.0 bump and harden grid readiness polling - gridServer.js still pointed at selenium-server-4.46.0.jar after the merge from trunk (#2830), which renamed the bundled jar to 4.49.0 and didn't touch this file since it didn't exist on trunk at merge time. - Addressed a new Qodo finding surfaced by the previous readiness-check fix: waitForServer only advanced past its overall deadline after an HTTP response ended or errored, so a connection that stalled or aborted mid-response could hang startGrid indefinitely past the declared 60s timeout, with cleanup never reached. Added a per-request timeout (2s) that destroys the stalled request and retries, plus an 'aborted' handler on the response. Verified: npx mocha test/drivers/*.spec.js -> 7/7 passing against the real selenium-server-4.49.0.jar. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ko4CniqqrtSVntSMKyQrqq --- .../javascript/test/drivers/gridServer.js | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/examples/javascript/test/drivers/gridServer.js b/examples/javascript/test/drivers/gridServer.js index 694d4a0baf98..f0ea09260969 100644 --- a/examples/javascript/test/drivers/gridServer.js +++ b/examples/javascript/test/drivers/gridServer.js @@ -3,7 +3,7 @@ const http = require('node:http') const path = require('node:path') const portprober = require('selenium-webdriver/net/portprober') -const SERVER_JAR = path.join(__dirname, '..', '..', '..', 'selenium-server-4.46.0.jar') +const SERVER_JAR = path.join(__dirname, '..', '..', '..', 'selenium-server-4.49.0.jar') function waitForServer(url, timeout) { const deadline = Date.now() + timeout @@ -18,24 +18,28 @@ function waitForServer(url, timeout) { } function attempt() { - http - .get(url, (res) => { - let body = '' - res.on('data', (chunk) => (body += chunk)) - res.on('end', () => { - try { - const { value } = JSON.parse(body) - if (res.statusCode === 200 && value && value.ready) { - resolve() - return - } - } catch (err) { - // Not valid JSON yet (e.g. server still booting) - fall through to retry. + const req = http.get(url, { timeout: 2000 }, (res) => { + let body = '' + res.on('data', (chunk) => (body += chunk)) + res.on('end', () => { + try { + const { value } = JSON.parse(body) + if (res.statusCode === 200 && value && value.ready) { + resolve() + return } - retryOrFail() - }) + } catch (err) { + // Not valid JSON yet (e.g. server still booting) - fall through to retry. + } + retryOrFail() }) - .on('error', retryOrFail) + res.on('aborted', retryOrFail) + }) + req.on('timeout', () => { + req.destroy() + retryOrFail() + }) + req.on('error', retryOrFail) } attempt() }) From 94c2a461a470233311fdddaaae5c69254c5e7cc7 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 17 Sep 2026 08:48:40 +0200 Subject: [PATCH 4/4] Guard grid readiness polling against duplicate/unhandled retry events Two findings from the previous readiness-check hardening: - The response had no 'error' listener, only 'aborted'. A response that closes mid-stream (e.g. ECONNRESET) emits an unhandled 'error' event, which crashes the Node process instead of triggering a retry. - A single stalled attempt could call retryOrFail() twice: once from the request's 'timeout' handler (which also destroys the request), and again from the resulting response 'aborted' event triggered by that destroy. Each call scheduled its own follow-up attempt, so a stalled server could multiply concurrent polling requests toward the deadline. Added a per-attempt 'settled' guard (retryOnce) shared by the response end/error/aborted handlers and the request timeout/error handlers, so exactly one outcome (resolve or a single retry) is produced per attempt regardless of how many of those events fire. Verified: npx mocha test/drivers/*.spec.js -> 7/7 passing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ko4CniqqrtSVntSMKyQrqq --- examples/javascript/test/drivers/gridServer.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/examples/javascript/test/drivers/gridServer.js b/examples/javascript/test/drivers/gridServer.js index f0ea09260969..3d617996498b 100644 --- a/examples/javascript/test/drivers/gridServer.js +++ b/examples/javascript/test/drivers/gridServer.js @@ -18,28 +18,38 @@ function waitForServer(url, timeout) { } function attempt() { + let settled = false + const retryOnce = () => { + if (settled) return + settled = true + retryOrFail() + } + const req = http.get(url, { timeout: 2000 }, (res) => { let body = '' res.on('data', (chunk) => (body += chunk)) + res.on('error', retryOnce) + res.on('aborted', retryOnce) res.on('end', () => { + if (settled) return try { const { value } = JSON.parse(body) if (res.statusCode === 200 && value && value.ready) { + settled = true resolve() return } } catch (err) { // Not valid JSON yet (e.g. server still booting) - fall through to retry. } - retryOrFail() + retryOnce() }) - res.on('aborted', retryOrFail) }) req.on('timeout', () => { req.destroy() - retryOrFail() + retryOnce() }) - req.on('error', retryOrFail) + req.on('error', retryOnce) } attempt() })