PRACTICAL GUIDE / Selenium TypeScript ESM setup
Selenium TypeScript ESM Setup for WebDriver
Learn Selenium TypeScript ESM setup with working code, failure cases, debugging steps, and CI evidence for reliable QA automation in practice.
In this guide10 sections
- Define One Module and Execution Contract
- Choose Versions and a Runtime Strategy
- Configure package.json and tsconfig.json Together
- Write the First Typed WebDriver Test
- Handle Imports, Types, and Package Boundaries
- Design Local and Remote Browser Configuration
- Test the Boundaries and Expected Failures
- Debug ESM, Interop, and Open Handles in CI
- Frequently Asked Questions
- Which TypeScript module settings should a Node ESM Selenium project use?
- Why do TypeScript source imports use a .js extension?
- Does selenium-webdriver work from an ES module?
- Do I need @types/selenium-webdriver?
- Should Selenium TypeScript tests run source files or compiled JavaScript?
- How should a Selenium ESM test close the browser?
- Practice the Complete ESM Path
What you will learn
- Define One Module and Execution Contract
- Choose Versions and a Runtime Strategy
- Configure package.json and tsconfig.json Together
- Write the First Typed WebDriver Test
Selenium TypeScript ESM setup should mark the package with "type": "module", compile using TypeScript's paired nodenext module settings, write .js extensions in relative source imports, and execute emitted JavaScript in a supported Node release. Await every WebDriver command and quit() so correct module loading does not hide lifecycle defects.
This baseline has one runtime owner: Node executes the files that tsc emits. It does not assume a bundler, a test-runner transpiler, an experimental loader, or extensionless resolution. Those tools can be valid, but each changes what import specifiers mean and which configuration is authoritative.
Node's official ECMAScript modules documentation defines explicit ESM markers and requires file extensions on relative and absolute import specifiers. TypeScript's module theory guide explains why a project running on Node should model Node's dual module system rather than emit an abstract ES module format and hope runtime resolution agrees.
Define One Module and Execution Contract
Write the toolchain as a pipeline: TypeScript reads src/**/*.ts, resolves imports as Node would, emits ESM .js files under dist, and Node's built-in test runner imports those files. Selenium's JavaScript binding creates either a local browser session or a remote Grid session. The test runner owns completion and teardown.
That contract answers common configuration questions:
- The nearest
package.jsoncontains"type": "module", so.tsfiles undernodenextare treated as sources for ESM.jsoutput. moduleandmoduleResolutionboth usenodenext, so checking and emit model the same Node rules.- Relative TypeScript imports name the future
.jsfile because TypeScript emits the specifier unchanged. - Bare package imports such as
selenium-webdriveruse Node's package lookup and the dependency's public entry point. - Tests execute
dist, notsrc, so CI verifies the artifact Node will actually load.
Avoid mixing this contract with ts-node, tsx, Babel, Jest transforms, or a bundler until the baseline works. A source loader can make extension or interop mistakes disappear during development while emitted JavaScript still fails elsewhere. If the final production test command uses a loader, document that as the runtime and verify it separately.
Module correctness and WebDriver correctness are independent. An import can load while an unawaited command finishes after the test. A browser can work locally while a remote URL is read before environment validation. Pair this article with Selenium JavaScript async command ordering to keep lifecycle ownership visible.
The official Selenium JavaScript API is the source of truth for the binding's asynchronous classes and return values. Check it against the installed release when a builder, driver, element, or options method differs from an older example.
The first assertion should be tsc --noEmit success under the same config used to build. The second should execute one emitted test through Node. The first failure boundary is configuration parsing and source inclusion, followed by TypeScript resolution, emit, Node resolution, Selenium import interop, session creation, browser assertion, and teardown.
Choose Versions and a Runtime Strategy
Use a maintained Node release supported by the installed Selenium JavaScript binding, then pin it in the CI image or version manager. Install the current approved selenium-webdriver, TypeScript, Node declarations, and Selenium declarations through the package manager and commit the lockfile. Avoid tutorial-wide exact versions in copied commands when the project policy requires controlled upgrade reviews.
Code 1: Create the package and install the runtime plus declarations
npm init --yes
npm install selenium-webdriver
npm install --save-dev typescript @types/node @types/selenium-webdriver
npm pkg set type=module
npm pkg set private=true --jsonThe --json flag keeps private as the JSON Boolean true instead of the string "true". After installation, record node --version, npm --version, npx tsc --version, and npm ls selenium-webdriver @types/selenium-webdriver. The lockfile, not a developer's global package cache, should decide CI versions. Read the binding's declared Node support policy before upgrading Node.
Promote the toolchain as a tested unit. A Node upgrade can change ESM and CommonJS interop behavior that nodenext is designed to model. A TypeScript upgrade can change what that moving mode checks. A Selenium binding upgrade can change its Node support range or package surface. Open one dependency change at a time when possible, regenerate the lockfile in a controlled environment, and run typecheck plus emitted-code smoke before merging.
Pin the runtime in every execution surface, not only a developer version file. The CI container, local version manager, pre-commit job, browser image helper, and worker launcher should report the same approved Node line. Fail early when the runtime falls outside policy. A lockfile controls packages but cannot stop an older system Node from interpreting the emitted files.
Retain an upgrade record containing old and new versions, effective TypeScript config, emitted import sample, local smoke result, remote smoke result, and teardown inventory. That evidence makes rollback a version decision instead of an attempt to remember which flag happened to work on one laptop.
Use this decision table for the module strategy:
| Actual executor | TypeScript module strategy | Relative import rule | Suitable for this baseline |
|---|---|---|---|
Node executes tsc output | nodenext with package type: module | Include emitted .js extension | Yes |
| Node executes CommonJS output | nodenext with CommonJS package boundary or .cts | CommonJS rules apply by file | Migration case only |
| Bundler executes a bundle | preserve or esnext with bundler resolution as tool requires | Bundler contract | No, different runtime |
Source loader executes .ts | Match that loader's documented Node behavior | Loader-specific | No, add separate proof |
| Mixed ESM and CommonJS package | .mts and .cts or nested package boundaries | Explicit per format | Use only when required |
Do not set module: esnext with moduleResolution: node for a direct Node application. That combination can type-check imports under rules Node does not use. Do not choose bundler merely because it accepts extensionless imports; without a bundler at runtime, the emitted specifier still fails in Node.
The broader JavaScript ES modules framework guide helps decide package boundaries when several automation tools share one repository. Keep each package's nearest package.json explicit so a parent workspace cannot silently change module format.
Configure package.json and tsconfig.json Together
The package marker and compiler options form one contract. nodenext does not mean every file is automatically ESM. TypeScript checks the nearest package type for ordinary .ts files, just as Node will check it for emitted .js files. Omitting type: module can produce CommonJS output even when the setting sounds like ESM.
Code 2: Minimal package scripts for build and emitted tests
{
"name": "selenium-typescript-esm",
"private": true,
"type": "module",
"scripts": {
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"typecheck": "tsc --noEmit",
"build": "npm run clean && tsc -p tsconfig.json",
"test": "npm run build && node --test dist/webdriver.test.js"
}
}The inline clean command runs as CommonJS because node -e input is not controlled by package type unless an input-type flag is supplied. It uses only a built-in API and does not affect module output. A cross-platform cleanup package is also reasonable when already approved by the repository.
Code 3: TypeScript configuration for direct Node ESM execution
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"sourceMap": true,
"types": ["node", "selenium-webdriver"],
"skipLibCheck": false
},
"include": ["src/**/*.ts"]
}verbatimModuleSyntax keeps value and type imports honest and prevents TypeScript from silently rewriting ESM syntax into require in a file detected as CommonJS. esModuleInterop supports the deliberate default-import bridge to the CommonJS Selenium package. Test the emitted shape rather than assuming the compiler flag changes Node runtime semantics.
Add a configuration contract test that reads the emitted dist/webdriver.test.js and rejects accidental require( calls in files intended to be ESM. Also require the expected ./config.js specifier and verify the nearest output package marker still declares ESM. This is not a replacement for running Node, but it produces a focused failure when a nested package or compiler override changes format.
Use tsc --traceResolution only in a diagnostic job because its output is large. Filter the trace to the failing specifier and preserve the selected package declaration path, export condition, and resolved file. A clean trace for selenium-webdriver plus a failing runtime import usually points to package interop or stale output, while a trace that selects an unexpected declaration exposes workspace or lockfile drift.
Keep skipLibCheck false during setup and dependency upgrades so declaration incompatibilities are visible. A large mature repository may choose a temporary exception for known third-party conflicts, but it should not cast the entire Selenium import to any. That removes the typed Promise information needed to catch missing awaits.
The detailed TypeScript module-resolution article explains package exports, condition selection, and why paths aliases do not rewrite emitted specifiers. Prefer relative imports or package imports mappings that Node itself understands.
Write the First Typed WebDriver Test
Create src/config.ts and src/webdriver.test.ts. Notice that the test imports ./config.js, even though the source file is named config.ts. TypeScript resolves that specifier to the source during checking and emits the same .js text for Node.
Code 4: Runtime configuration with validated local or remote mode
// src/config.ts
export type RuntimeConfig = Readonly<{
gridUrl?: string;
}>;
export function readRuntimeConfig(
env: NodeJS.ProcessEnv = process.env,
): RuntimeConfig {
const candidate = env.SELENIUM_REMOTE_URL?.trim();
if (!candidate) {
return {};
}
const url = new URL(candidate);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("SELENIUM_REMOTE_URL must use HTTP or HTTPS");
}
return { gridUrl: url.toString().replace(/\/$/, "") };
}Code 5: Typed ESM smoke test with awaited teardown
// src/webdriver.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import webdriver from "selenium-webdriver";
import { readRuntimeConfig } from "./config.js";
const { Builder, Browser, By } = webdriver;
test("loads a page through Selenium", async (t) => {
const { gridUrl } = readRuntimeConfig();
const builder = new Builder().forBrowser(Browser.CHROME);
if (gridUrl) {
builder.usingServer(gridUrl);
}
const driver = await builder.build();
t.after(async () => {
await driver.quit();
});
await driver.get(
"data:text/html,<title>ESM%20smoke</title><p%20id=status>ready</p>",
);
assert.equal(await driver.getTitle(), "ESM smoke");
assert.equal(await driver.findElement(By.id("status")).getText(), "ready");
});Node always provides a default export for a CommonJS module containing its module.exports value. The default import avoids relying on Node's best-effort synthesis of individual named exports from CommonJS source. Destructuring Builder, Browser, and By after import keeps the runtime interop explicit while declarations preserve their types.
Verify that bridge with a small non-browser import smoke before provisioning infrastructure. Import the package, assert that Builder and Browser have the expected value types, and exit. Then run the browser smoke. This split tells an operator whether a failure belongs to Node package loading or WebDriver session creation. Keep the probe against the public package entry point so it also detects an incompatible package release.
Do not compensate for a failed default import by adding createRequire, dynamic import, and named imports together. Choose one interop form that matches the installed package, prove it in emitted JavaScript, and encapsulate it in a small module if many tests depend on it. A later package migration can then change one reviewed boundary rather than every test file.
The test uses a data URL so ESM validation does not depend on an external website. A production smoke should use a controlled application route, but module setup needs the smallest deterministic browser action first. The official Selenium WebDriver documentation provides the wider command model once the package boundary works.
Handle Imports, Types, and Package Boundaries
Relative ESM imports require extensions. Write ./config.js, not ./config, and do not write ./config.ts in a project that emits JavaScript for Node. Node receives only dist/config.js; it does not ask TypeScript to substitute source extensions at runtime.
Bare specifiers follow package rules. selenium-webdriver resolves through node_modules, so it does not need a file extension. Avoid deep imports into undocumented package files. A future package exports map can block private paths even when they exist on disk. Use the binding's public entry point and public documented subpaths only.
Separate type-only imports with import type when they do not exist at runtime. With verbatimModuleSyntax, that distinction prevents a type name from becoming an emitted value import. Avoid importing a declaration-only symbol and then debugging a runtime "does not provide an export" error.
Do not use TypeScript paths as a runtime alias unless Node or a real bundler has the same mapping. TypeScript documentation notes that paths does not change emitted imports. Package imports entries beginning with # are a standards-based alternative for internal aliases when configured and tested in both TypeScript and Node.
In a monorepo, give each directly executed package its own package.json, tsconfig.json, build output, and dependency boundary. A root type: module can affect nested .js files until another package marker appears. Project references can organize builds, but each emitted package still needs runtime-valid specifiers. The worker-thread test-tool architecture adds another reason to make package and worker entry points explicit.
If a CommonJS reporter or config must remain, name it .cjs or author its source as .cts so the exception is visible. Do not remove type: module and let every file fall back to CommonJS because one plugin is old. Isolate the compatibility boundary and add a test that imports it from the ESM caller.
Design Local and Remote Browser Configuration
Local and Grid execution should differ through validated configuration, not two module systems. Builder.usingServer() selects a remote server; omitting it lets the binding create a local session. Keep browser choice and capability construction in typed functions when the suite grows, but leave session ownership in the test or fixture.
Use this numbered workflow:
- Load environment values once at the test worker boundary and validate URL syntax before opening a browser.
- Build a fresh
Builderfor the selected browser and apply approved capabilities without mutating a shared object. - Apply the remote server only when a validated Grid URL exists; never log embedded credentials.
- Await
build()and immediately register an asynchronous teardown callback with the runner. - Await every navigation, lookup, action, script, and assertion-producing command.
- Attach the session ID and worker identity to failure evidence without publishing full capabilities.
- Await
quit()and fail teardown if it rejects; do not convert cleanup failure into a warning. - At suite end, confirm workers, browser processes, or Grid canary sessions return to baseline.
Keep secrets out of SELENIUM_REMOTE_URL where logs or process listings can reveal them. Prefer a trusted proxy, runner secret injection, or provider-supported authentication mechanism. Redact query strings and user information when recording target identity.
For parallel execution, build one driver per independently scheduled test or fixture. A shared driver creates command and state races that ESM configuration cannot solve. If worker processes each load the config module, remember that module caches are per process. Avoid mutable exported singleton capability objects.
WebDriver BiDi subscriptions add sockets and listeners that also need teardown. Learn the event ownership model through Selenium WebDriver BiDi scenarios, and keep module initialization free of automatic subscriptions. Imports should define helpers, not start browser work as a side effect.
Test the Boundaries and Expected Failures
A working happy path proves only one combination. Add tests that fail at each module and browser boundary with a recognizable reason.
| Injected defect | Expected first failure | Evidence to keep |
|---|---|---|
Remove type: module | TypeScript format or verbatim syntax error | nearest package path and compiler diagnostic |
Use ./config without extension | TypeScript or Node resolution failure | emitted import and Node error code |
Use ./config.ts | Node cannot load expected emitted target | source and dist trees |
Pair module: nodenext with wrong resolution | Compiler configuration error | effective tsc --showConfig |
| Import a nonexistent named CommonJS export | Compile or runtime import failure | dependency version and emitted import |
| Point to unavailable Grid | Awaited session creation rejects | sanitized URL, timeout, binding stack |
Forget await driver.quit() | Teardown or session-leak gate fails | session ID and worker exit timeline |
Omit one source file from include | Clean typecheck misses the file | file list from tsc --listFilesOnly |
Run npx tsc --showConfig to inspect merged options and npx tsc --listFilesOnly to prove tests are included. Inspect dist/webdriver.test.js and confirm it contains ESM imports, the .js relative specifier, and source-map reference. Then execute Node directly without a source loader.
Add a negative import fixture in a separate expected-failure job. Require a stable diagnostic category rather than matching an entire version-specific message. Add a runtime smoke that starts and quits one local or remote session. These two gates separate compiler resolution from browser infrastructure.
For application selectors, do not turn the module smoke into a large UI scenario. Once ESM is established, apply the locator practices in Selenium shadow DOM and locator scenarios. Keeping the first test small makes an import failure immediately distinguishable from application behavior.
Debug ESM, Interop, and Open Handles in CI
Start with the first owner. ERR_UNKNOWN_FILE_EXTENSION often means Node was asked to execute .ts without a configured loader. ERR_MODULE_NOT_FOUND with a relative path often means the emitted specifier lacks .js, points to the wrong output structure, or names a directory. "Cannot use import statement outside a module" usually means the nearest package marker does not identify ESM.
When TypeScript and Node disagree, capture the source file, emitted file, nearest package files, effective compiler config, Node command, and dependency package metadata. Do not switch several flags at once. The ESM and CommonJS conflict debugging guide provides a layer-by-layer method for mixed repositories.
When the Selenium import is undefined, inspect the actual package version and use a one-line emitted ESM probe to print safe export keys. Prefer the CommonJS default object for the package shape used here. Do not add a namespace, default, and named import combination until one runtime form is proven.
When tests pass locally but fail in CI, compare Node major and minor, lockfile installation, case-sensitive paths, working directory, package boundary, environment URL, browser availability, and whether CI ran dist or source. Rebuild from a clean output directory so stale CommonJS files cannot survive beside new ESM files.
When the process stays alive, inspect missing quit(), BiDi sockets, timers, reporters, and worker pools. A module import error and an open handle can occur in the same run, but they have different owners. Print active session inventory and runner handles only in protected diagnostics, then fix the lifecycle instead of forcing process.exit().
Run typecheck, build, emitted unit tests, and one browser smoke as separate CI stages with retained logs. A single npm test exit code is convenient for developers but insufficient incident evidence. Compare Playwright and Selenium carefully when sharing a TypeScript workspace; runner transforms and fixture ownership differ even if both suites use ESM syntax.
Build in an empty output directory and package cache at least once in the release pipeline. Incremental local builds can leave a deleted .js, .cjs, or source map under dist, and Node may load that stale file while TypeScript checks a different source graph. Archive the emitted file list and fail if CommonJS artifacts appear in the ESM package unexpectedly.
Run a small matrix with the approved Node runtime and the next candidate runtime before promotion. The current lane blocks regressions; the candidate lane provides upgrade evidence but should not silently become required until Selenium, TypeScript, and the organization support it. Use the same lockfile and browser image in both lanes so module behavior is the changed variable.
Frequently Asked Questions
Which TypeScript module settings should a Node ESM Selenium project use?
For TypeScript emitted directly and executed by Node, use paired module: nodenext and moduleResolution: nodenext, plus type: module in the nearest package file. Pin the compiler and Node versions because NodeNext follows current Node behavior. A real bundler requires a different resolution decision.
Why do TypeScript source imports use a .js extension?
TypeScript preserves module specifiers in emitted JavaScript, and Node ESM requires file extensions for relative imports. Therefore config.ts is imported as ./config.js in TypeScript source so the emitted file resolves at runtime. Importing ./config.ts is a different runtime contract and should not be used here.
Does selenium-webdriver work from an ES module?
Yes, Node can import CommonJS packages from ESM. A default import is a conservative interop choice because Node exposes the CommonJS module.exports value as the default. Pin the Selenium package and smoke-test the emitted import, since named export synthesis depends on analyzable CommonJS exports.
Do I need @types/selenium-webdriver?
Install the declaration package when the selected selenium-webdriver release does not provide the TypeScript declarations your project needs. Pin compatible versions in the lockfile and let tsc verify the import surface. Do not hide declaration conflicts with broad any casts or skip all library checking by default.
Should Selenium TypeScript tests run source files or compiled JavaScript?
Either can work when the runtime is configured deliberately, but this guide compiles with tsc and tests emitted JavaScript. That path verifies TypeScript's output, Node's real ESM resolver, package interop, and source maps. Source loaders add another owner and need their own production-parity checks.
How should a Selenium ESM test close the browser?
Register teardown immediately after build() succeeds and return or await driver.quit() through the test runner's lifecycle. Do not call quit without awaiting it, and do not rely on beforeExit for routine cleanup. A passing suite should also verify no Grid sessions or test workers remain.
Practice the Complete ESM Path
Build the sample from a clean directory, inspect the emitted imports, and run the data-URL smoke locally. Then set a staging Grid URL and run the same emitted file remotely. Break type: module, remove one .js specifier, and omit an awaited quit in separate negative cases. Require each control to fail for its own reason.
Use QABattle's automation battles to practice module and asynchronous boundary diagnosis. Continue with TypeScript module resolution, ES module framework design, and Selenium command ordering. The setup is ready when clean build output runs under plain Node, local and remote modes share one module contract, and every browser session has an awaited owner from creation through teardown.
// 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 selenium.dev reference
selenium.dev
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 typescriptlang.org reference
typescriptlang.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Which TypeScript module settings should a Node ESM Selenium project use?
For TypeScript emitted directly and executed by Node, use paired `module: nodenext` and `moduleResolution: nodenext`, plus `type: module` in the nearest package file. Pin the compiler and Node versions because NodeNext follows current Node behavior. A real bundler requires a different resolution decision.
Why do TypeScript source imports use a .js extension?
TypeScript preserves module specifiers in emitted JavaScript, and Node ESM requires file extensions for relative imports. Therefore `config.ts` is imported as `./config.js` in TypeScript source so the emitted file resolves at runtime. Importing `./config.ts` is a different runtime contract and should not be used here.
Does selenium-webdriver work from an ES module?
Yes, Node can import CommonJS packages from ESM. A default import is a conservative interop choice because Node exposes the CommonJS `module.exports` value as the default. Pin the Selenium package and smoke-test the emitted import, since named export synthesis depends on analyzable CommonJS exports.
Do I need @types/selenium-webdriver?
Install the declaration package when the selected `selenium-webdriver` release does not provide the TypeScript declarations your project needs. Pin compatible versions in the lockfile and let `tsc` verify the import surface. Do not hide declaration conflicts with broad `any` casts or skip all library checking by default.
Should Selenium TypeScript tests run source files or compiled JavaScript?
Either can work when the runtime is configured deliberately, but this guide compiles with `tsc` and tests emitted JavaScript. That path verifies TypeScript's output, Node's real ESM resolver, package interop, and source maps. Source loaders add another owner and need their own production-parity checks.
How should a Selenium ESM test close the browser?
Register teardown immediately after `build()` succeeds and return or await `driver.quit()` through the test runner's lifecycle. Do not call quit without awaiting it, and do not rely on `beforeExit` for routine cleanup. A passing suite should also verify no Grid sessions or test workers remain.
RELATED GUIDES
Continue the learning route
GUIDE 01
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 02
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 03
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.
GUIDE 04
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 05
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.