PRACTICAL GUIDE / Selenium JavaScript unhandled WebDriver promise detection
Detect Selenium JavaScript Unhandled WebDriver Promises
Learn Selenium JavaScript unhandled WebDriver promise detection with code, failure cases, debugging steps, and CI evidence for reliable QA automation.
In this guide10 sections
- Define What the Detection System Must Prove
- Choose Controls by Failure Mode
- Make the WebDriver Lifecycle Fully Awaited
- Add Type-Aware Static Detection
- Make Node Rejection Policy Explicit in CI
- Use a Diagnostic Rejection Tracker Without Masking Failure
- Build a Repeatable Detection Workflow
- Debug Late Failures and False Confidence
- Frequently Asked Questions
- Does Node.js fail automatically on an unhandled WebDriver rejection?
- Can runtime rejection handling detect every missing await?
- Should tests add a global unhandledRejection listener?
- How should intentionally detached asynchronous work be written?
- Why can a Selenium test pass before the browser action finishes?
- What evidence helps debug a floating WebDriver Promise?
- Practice Promise Ownership Under Failure
What you will learn
- Define What the Detection System Must Prove
- Choose Controls by Failure Mode
- Make the WebDriver Lifecycle Fully Awaited
- Add Type-Aware Static Detection
Selenium JavaScript unhandled WebDriver promise detection needs three controls: type-aware linting for floating operations, a test runner that receives every returned Promise, and explicit Node rejection policy in CI. Runtime rejection events catch only Promises that reject without a timely handler; they cannot detect a forgotten await when the WebDriver command eventually succeeds.
That limitation explains many confusing passes. A test calls driver.get(), findElement(), click(), or quit() and then returns. If the Promise is not awaited or returned, the runner may close the test before the command settles. A later rejection can crash another test, appear as an unowned process warning, or disappear under a listener that logs and continues.
The official Selenium JavaScript API models WebDriver operations as asynchronous values. For example, quit() returns a Promise that resolves when termination completes, while Builder.build() returns a thenable WebDriver whose underlying session creation can reject. Treat each returned asynchronous value as owned work with a visible completion boundary.
Define What the Detection System Must Prove
The release claim should be stronger than "no warning appeared." Require that every WebDriver Promise started by a test is awaited, returned to the runner, combined into an awaited group, or transferred to a named background owner that handles rejection and joins during teardown. Also require a negative fixture that exits nonzero when a deliberately floating command rejects.
Separate four outcomes:
- Handled success: the test awaits the command and asserts its observable result.
- Handled failure: the awaited rejection is asserted or fails the owning test with its original stack.
- Unhandled rejection: the command rejects after losing its owner; strict Node policy fails the process or a diagnostic collector fails the run.
- Floating success: the command resolves after the test lifecycle ends. No rejection event exists, so static analysis and ordering evidence must detect the defect.
The fourth outcome is why adding process.on("unhandledRejection") is not a complete solution. It observes rejection state, not promise ownership. It can also change Node behavior: in the default throw mode, an installed listener receives the event instead of Node automatically raising the rejection as an uncaught exception. A listener that only prints a message can turn a reliable crash into a green CI job.
Write the first assertion at the runner boundary: the test function returns a Promise that settles only after its final browser assertion and awaited teardown. The first failure boundary is the statement that creates a Promise without transferring ownership. The guide to Selenium JavaScript async and await command order is a useful companion for making that chronology visible.
Record Node version, runner version, worker model, and process launch flags in every diagnostic job. Rejection behavior is process policy. A developer invoking node, a test runner spawning workers, and an IDE extension can use different arguments even when they execute the same file.
Choose Controls by Failure Mode
No single detector covers asynchronous ownership. Use this decision table to select evidence rather than enabling a broad hook and hoping it catches everything.
| Failure mode | Best first control | Runtime evidence | Main limitation |
|---|---|---|---|
| WebDriver Promise used as a bare statement | Type-aware no-floating-promises | None if it resolves | Requires accurate project types |
Async callback passed where void is expected | no-misused-promises | Late rejection or ordering drift | Some framework callback types need adapters |
| Rejected Promise without handler | --unhandled-rejections=strict | Nonzero process and stack | Process may stop before normal teardown |
| Handler attached one turn late | Rejection diagnostic collector | unhandledRejection then rejectionHandled | Process-global attribution is difficult |
| Promise array never joined | Lint plus Promise.all policy | Several late results | Concurrency still needs bounded ownership |
driver.quit() not awaited | Teardown assertion and session inventory | Open session or process handle | Rejection hook sees only failed cleanup |
| Worker thread rejection | Worker-local strict policy and parent exit check | Worker error and exit code | Main process hook does not own every worker |
| Intentional background task | Named supervisor with catch, cancel, and drain | Supervisor failure record | void alone handles nothing |
Compile-time, lint-time, runner-time, and process-time controls answer different questions. TypeScript can describe a Promise return type but does not require the caller to await it by itself. The runner waits only for work returned through its lifecycle API. Node handles only rejections that remain unhandled according to event-loop timing. Selenium cannot know whether the test author intended to await a client-side Promise.
For JavaScript files, type-aware linting can still work when the project enables checked JavaScript and has accurate declarations. For TypeScript, configure the parser against the same project used by CI. Avoid a separate permissive lint tsconfig that omits test files, because every omitted file becomes an unexamined promise boundary.
If the suite uses worker threads or process workers, apply policy inside each worker and make the parent fail when any worker exits unexpectedly. The worker-thread architecture guide covers ownership boundaries that a process-global map cannot cross.
Make the WebDriver Lifecycle Fully Awaited
Use one visible lifecycle per test: build, exercise, assert, and quit. Register cleanup immediately after session creation so later setup failures still release the driver. Await the runner's cleanup callback rather than calling quit() from an unowned finally branch or process exit handler.
Code 1: Node test with owned Selenium promises
import assert from "node:assert/strict";
import { test } from "node:test";
import { Builder, Browser, By, until } from "selenium-webdriver";
test("search status becomes visible", async (t) => {
const driver = await new Builder()
.forBrowser(Browser.CHROME)
.build();
t.after(async () => {
await driver.quit();
});
await driver.get("https://example.test/search");
const input = await driver.findElement(By.name("query"));
await input.sendKeys("webdriver promises");
await driver.findElement(By.css("button[type=submit]")).click();
const status = await driver.wait(
until.elementLocated(By.css("[role=status]")),
5_000,
);
assert.match(await status.getText(), /results/i);
});The async test returns its Promise to node:test. Every command is awaited, the assertion reads a settled value, and t.after returns the cleanup Promise. If quit() rejects, teardown fails rather than being detached. Adapt the cleanup API to the runner, but preserve those ownership properties.
Do not solve sequencing with arbitrary sleeps. A sleep can keep the process alive long enough for a floating command to finish while leaving the ownership bug intact. Await the command and then wait for a domain condition when the application changes asynchronously. The distinction between browser command completion and application readiness remains important.
Avoid mixing callbacks and Promises around WebDriver. A helper such as forEach(async item => ...) returns immediately because forEach ignores callback Promises. Use for...of for ordered actions or await Promise.all(...) for intentionally concurrent independent work. Sharing one driver across concurrent actions usually creates a second problem: command order and browser state become difficult to reason about.
Keep sessions local to tests or fixtures. A module-global driver can outlive a failed hook, receive commands from another test, and make a late rejection look unconnected. The comparison of Playwright and Selenium for beginners can help teams understand differing fixture and auto-wait expectations before copying lifecycle patterns across tools.
Add Type-Aware Static Detection
The official no-floating-promises rule reports Promise-valued statements that are not awaited, returned, rejected-handled, or otherwise explicitly marked. Its documentation also warns that void does not change runtime behavior or handle rejection. Enable the rule with type information and pair it with no-misused-promises.
Code 2: Flat ESLint configuration for Promise ownership
// eslint.config.mjs
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import tseslint from "typescript-eslint";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig(
eslint.configs.recommended,
{
files: ["src/**/*.ts", "test/**/*.ts"],
extends: [tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir,
},
},
rules: {
"@typescript-eslint/no-floating-promises": [
"error",
{ ignoreVoid: false },
],
"@typescript-eslint/no-misused-promises": "error",
},
},
);The files scope keeps type-aware parsing on TypeScript source and test files that belong to the project, while root JavaScript files such as eslint.config.mjs use the ordinary JavaScript preset. The portable fileURLToPath form also works before import.meta.dirname, which requires Node 20.11 or newer. Adjust the globs to the repository's real TypeScript roots instead of broadening the default project silently.
Setting ignoreVoid: false makes void driver.click() visible rather than treating void as proof of safety. If the project permits truly detached work, route it through one reviewed supervisor helper and add a narrow rule exception for that helper only. Broad safe-call lists can recreate the blind spot under a new name.
Run lint on test files, fixtures, hooks, page objects, reporters, and custom runner adapters. Missing awaits often sit in beforeEach, data setup, or teardown rather than the assertion body. Include generated code only if engineers maintain it; otherwise validate the generator output through a separate compile and runtime gate.
Type-aware lint depends on correct module resolution and declarations. A CommonJS/ESM mismatch can cause files to drop out of the project or degrade types to any, which weakens Promise detection. Review TypeScript module resolution for ESM test projects and debug ESM and CommonJS conflicts before trusting a clean lint result from a misconfigured project.
Add a lint regression fixture with one deliberate floating WebDriver command and require the lint command to reject it. Keep that fixture outside normal source inclusion or run ESLint with an expected-failure harness. A configuration file existing in the repository is not evidence the CI path loaded it.
Make Node Rejection Policy Explicit in CI
Node's official command-line documentation says --unhandled-rejections=throw is the current default. In throw, Node emits unhandledRejection and raises the rejection as an uncaught exception only when no hook is installed. strict raises it as an uncaught exception, then emits unhandledRejection if the exception is handled. State the desired mode in the test command rather than inheriting a launch environment.
Code 3: Package scripts with an explicit strict rejection policy
{
"scripts": {
"lint": "eslint . --max-warnings=0",
"test": "node --unhandled-rejections=strict --test test/webdriver.test.js",
"test:promise-probe": "node test/run-negative-promise-probe.js"
}
}Place Node flags before the test entry point. Check runner documentation when it spawns child processes, because a wrapper may not forward every parent argument. NODE_OPTIONS=--unhandled-rejections=strict can cover child processes that honor it, but make the environment visible in CI evidence and avoid allowing unrelated jobs to override security-sensitive Node flags.
Do not install an uncaughtException handler that treats the process as safe to continue. Node documentation warns that uncaught exceptions leave an application in an undefined state and are intended for synchronous cleanup before shutdown. In test workers, preserve the stack and exit nonzero. Let an external supervisor clean disposable browsers or containers after a deliberate crash probe.
Create a negative subprocess fixture that starts a controlled rejected Promise and expects the worker to exit nonzero. For a WebDriver-specific integration, use an isolated session and issue an invalid script command without awaiting it, then keep the worker alive briefly. Run that destructive proof in a disposable browser pool because strict termination may prevent quit(). The parent harness should validate the rejection marker in stderr, exit status, and external session cleanup.
Do not run the negative fixture in the ordinary green suite as a test expected to pass inside one process. Its evidence is the child process failure. This separation prevents test-runner exception interception from disguising actual Node policy.
Use two negative probes because rejection and ownership are different properties. The first probe starts a WebDriver command that is guaranteed to reject, deliberately drops its Promise, and keeps the child alive long enough for Node to apply the strict policy. The parent requires a nonzero exit, the expected probe marker, and external cleanup of the disposable session. This proves the runtime backstop is active in the same launch path CI uses.
The second probe deliberately drops a command that succeeds. Instrument a small test-only wrapper around the command boundary with started, settled, testFinished, and teardownFinished timestamps. The expected defective trace has testFinished before settled, even though the process emits no rejection. Require the lint fixture to fail and the trace assertion to expose that ordering. Then add await and require settled before testFinished. This proves the static and lifecycle controls cover the blind spot strict mode cannot see.
Keep these probes deterministic. Use a locally controlled page and a harmless script result for the success case. Use a script that throws a unique marker for the rejection case. Do not rely on an absent element, remote website, or network timeout, because those can resolve differently after browser, driver, or environment changes. Store the child command line and Node version with both results.
Run the pair after upgrades to Node, Selenium, the test runner, process launch wrappers, or lint configuration. The probes are control tests for the detection system, not product scenarios. A normal application failure cannot substitute for them because its own timing and expected outcome may be disputed during diagnosis.
Use a Diagnostic Rejection Tracker Without Masking Failure
Sometimes the team needs attribution instead of immediate process death. Node's official process event documentation describes a growing and shrinking set: unhandledRejection adds a Promise after it lacks a handler for an event-loop turn, and rejectionHandled removes it when a handler is attached later. A diagnostic run can mirror that model, but it must fail after collecting evidence.
Code 4: Owned diagnostic tracker for a serial worker
import process from "node:process";
import { setImmediate as nextTurn } from "node:timers/promises";
export function installRejectionTracker() {
const pending = new Map();
const onUnhandled = (reason, promise) => {
pending.set(promise, reason);
};
const onHandled = (promise) => {
pending.delete(promise);
};
process.on("unhandledRejection", onUnhandled);
process.on("rejectionHandled", onHandled);
return {
async drainErrors() {
await nextTurn();
const errors = [...pending.values()].map((reason) =>
reason instanceof Error ? reason : new Error(String(reason)),
);
pending.clear();
return errors;
},
dispose() {
process.off("unhandledRejection", onUnhandled);
process.off("rejectionHandled", onHandled);
},
};
}Install this once per isolated serial worker, drain after a test, fail with the captured errors, and remove both listeners at worker shutdown. Do not run several tests concurrently against one global map and assign every rejection to whichever afterEach runs first. For parallel suites, use process or worker isolation and report worker identity.
The tracker is a diagnostic alternative to fail-fast strict execution, not an extra listener to add blindly to the same policy. Its listener changes default throw behavior, so the harness must guarantee a failed result. Add a self-test that invokes Promise.reject(new Error("tracker-probe")), observes one error, and confirms listener counts return to baseline after disposal.
Sanitize errors before broad artifact upload. A rejected script or navigation can include URLs, JavaScript, cookies in surrounding logs, or provider details. Preserve the first stack and stable identifiers while limiting application data. When remote Grid commands are involved, attach the session ID and a trace key rather than an entire capability payload.
Build a Repeatable Detection Workflow
Apply the controls in this order so a late runtime symptom does not replace an earlier ownership defect:
- Inventory every Selenium API and helper that returns a Promise or thenable, including build and quit.
- Make test, hook, fixture, and teardown callbacks return their asynchronous lifecycle to the runner.
- Enable type-aware
no-floating-promisesandno-misused-promisesacross all maintained test code. - Fix findings by awaiting, returning, joining, or transferring work to a named supervisor; do not mass-prefix statements with
void. - Run tests under an explicit
--unhandled-rejections=strictpolicy in each process or worker. - Execute a negative subprocess probe and require nonzero exit plus the expected rejection evidence.
- Run the suite repeatedly and in parallel while checking sessions and workers return to zero at teardown.
- Use the diagnostic tracker only for isolated attribution runs, then remove it and repeat the strict gate.
- Preserve Node flags, worker identity, session IDs, first stacks, and cleanup results for failures.
- Block release when a floating operation, unowned rejection, or leaked session remains unexplained.
Add the static gate before browser provisioning to fail quickly. Run the negative runtime probe with one browser after dependency or runner changes, not for every source edit. Run the main strict suite at the risk-appropriate browser matrix. This ordering reduces infrastructure cost without weakening the contract.
For BiDi listeners and event buffers, command promises are only part of cleanup. Subscription removal, listener disposal, and buffered callback promises also need ownership. The Selenium WebDriver BiDi interview scenarios provide useful prompts for testing event-lifecycle reasoning.
Debug Late Failures and False Confidence
When a rejection appears after a test passes, preserve the first stack before rerunning. Compare test completion time, command start, rejection time, and teardown time. Search for a bare call, an async callback passed to forEach, a missing return in a helper wrapper, or cleanup invoked without await. Do not increase the test timeout until ownership is corrected.
When strict mode does not fail, print process.execArgv, NODE_OPTIONS, Node version, and listener counts from the worker. Check whether the runner starts subprocesses with different flags or installs its own unhandledRejection handler. Confirm the negative probe executes inside the same worker model as the real tests.
When lint does not report an obvious floating command, inspect file inclusion, parser project service output, dependency declarations, and inferred type. A value typed as any can bypass Promise-aware analysis. ESM and CommonJS boundary errors often cause exactly that degradation. The JavaScript ES modules framework guide helps map the runtime package boundary.
When the suite hangs after tests complete, inspect awaited quit(), active sessions, timers, sockets, event subscriptions, and worker shutdown. An open handle is not necessarily a floating Promise, and a clean rejection map is not proof of cleanup. Add session inventory at suite end and make the owner of any remaining session explicit.
When selectors fail only in the negative probe, make sure the probe targets a deterministic rejection rather than an application race. Ordinary locator behavior should follow the practices in Selenium locator and shadow DOM scenarios. A rejection-policy test should not depend on a fragile page element disappearing at the right millisecond.
Finally, distinguish expected rejection assertions from unhandled rejection. await assert.rejects(...) owns and verifies a failure. A .catch() that swallows every error technically handles rejection but can still hide a failed test. Require assertions or typed error classification inside expected-failure branches.
Frequently Asked Questions
Does Node.js fail automatically on an unhandled WebDriver rejection?
Current Node releases default --unhandled-rejections to throw, but an unhandledRejection listener changes that path, and project launch flags can change behavior. Make CI policy explicit with --unhandled-rejections=strict, avoid recovery-style uncaught-exception handlers, and run a negative probe that proves the job exits nonzero.
Can runtime rejection handling detect every missing await?
No. A floating WebDriver Promise that resolves successfully creates no unhandled rejection, yet the test can finish before the command or assertion. Use type-aware no-floating-promises, return or await every owned operation, assert observable ordering, and keep runtime strict mode as a rejection backstop rather than the only detector.
Should tests add a global unhandledRejection listener?
Only for a deliberate diagnostic policy that records the rejection and fails the run. A listener can suppress Node's default throw behavior, so logging and continuing is dangerous. Keep its install and removal owned, account for rejectionHandled, and avoid attributing process-global events to parallel tests without isolation.
How should intentionally detached asynchronous work be written?
Give detached work an owner, terminal rejection handler, cancellation rule, and shutdown join. Wrap it in a named helper that records failures and is drained during teardown. The void operator only silences some lint configurations; it does not handle a rejection or make background WebDriver commands safe.
Why can a Selenium test pass before the browser action finishes?
JavaScript test runners usually treat a returned Promise as the test lifecycle. If a callback starts a WebDriver command but neither awaits nor returns it, the runner can mark the test complete while the command remains pending. Later failure may be assigned to another test or to the process.
What evidence helps debug a floating WebDriver Promise?
Keep the first rejection stack, test file and worker identity, session ID, command name, test start and finish times, teardown result, Node version, launch flags, and runner configuration. Correlate Grid or browser evidence when remote execution is involved, while redacting URLs, scripts, credentials, and customer data.
Practice Promise Ownership Under Failure
Create three tiny cases: an awaited rejecting command asserted with assert.rejects, a deliberately floating rejection in a disposable child process, and a floating command that succeeds after its test returns. Prove that strict runtime policy catches the second while lint or ordering evidence catches the third. Then make all three outcomes belong to the right test.
Use QABattle's automation battles to practice asynchronous failure attribution, then revisit Selenium async command order and worker-thread ownership. The system is ready when a missing await cannot create a green test, every rejecting Promise produces a named failure, and every session closes before its worker reports success.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official nodejs.org reference
nodejs.org
Primary documentation selected and verified for the claims in this guide.
- 03Official nodejs.org reference
nodejs.org
Primary documentation selected and verified for the claims in this guide.
- 04Official typescript-eslint.io reference
typescript-eslint.io
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Does Node.js fail automatically on an unhandled WebDriver rejection?
Current Node releases default `--unhandled-rejections` to `throw`, but an `unhandledRejection` listener changes that path, and project launch flags can change behavior. Make CI policy explicit with `--unhandled-rejections=strict`, avoid recovery-style uncaught-exception handlers, and run a negative probe that proves the job exits nonzero.
Can runtime rejection handling detect every missing await?
No. A floating WebDriver Promise that resolves successfully creates no unhandled rejection, yet the test can finish before the command or assertion. Use type-aware `no-floating-promises`, return or await every owned operation, assert observable ordering, and keep runtime strict mode as a rejection backstop rather than the only detector.
Should tests add a global unhandledRejection listener?
Only for a deliberate diagnostic policy that records the rejection and fails the run. A listener can suppress Node's default throw behavior, so logging and continuing is dangerous. Keep its install and removal owned, account for `rejectionHandled`, and avoid attributing process-global events to parallel tests without isolation.
How should intentionally detached asynchronous work be written?
Give detached work an owner, terminal rejection handler, cancellation rule, and shutdown join. Wrap it in a named helper that records failures and is drained during teardown. The `void` operator only silences some lint configurations; it does not handle a rejection or make background WebDriver commands safe.
Why can a Selenium test pass before the browser action finishes?
JavaScript test runners usually treat a returned Promise as the test lifecycle. If a callback starts a WebDriver command but neither awaits nor returns it, the runner can mark the test complete while the command remains pending. Later failure may be assigned to another test or to the process.
What evidence helps debug a floating WebDriver Promise?
Keep the first rejection stack, test file and worker identity, session ID, command name, test start and finish times, teardown result, Node version, launch flags, and runner configuration. Correlate Grid or browser evidence when remote execution is involved, while redacting URLs, scripts, credentials, and customer data.
RELATED GUIDES
Continue the learning route
GUIDE 01
Selenium JavaScript Async/Await Patterns That Preserve Command Order
Preserve Selenium JavaScript command order with explicit async/await, sequential iteration, failure-safe helpers, and awaited WebDriver cleanup in Node tests.
GUIDE 02
Worker Thread Architecture for JavaScript Test Tools
Master node worker thread test architecture with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 03
JavaScript ES Modules for Automation Frameworks
Master JavaScript modules test framework with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 04
TypeScript Module Resolution for ESM Test Projects
Master TypeScript module resolution testing with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 05
Debug ESM and CommonJS Conflicts in TypeScript Tests
Master debug TypeScript ESM CommonJS tests with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.