Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pnpm run test:lint
1 change: 1 addition & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pnpm test
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const browser = await remote({
}
});

await browser.url('[https://webdriver.io](https://webdriver.io)');
await browser.url('https://webdriver.io');
console.log(await browser.getTitle());

// 4. Terminate process when finished
Expand All @@ -121,7 +121,7 @@ import safaridriver from 'safaridriver';
import { remote } from 'webdriverio';

// 1. Start Safaridriver server
await safaridriver.start({ port: 4444 });
safaridriver.start({ port: 4444 });

// 2. Connect WebdriverIO session
const browser = await remote({
Expand All @@ -130,7 +130,7 @@ const browser = await remote({
}
});

await browser.url('[https://webdriver.io](https://webdriver.io)');
await browser.url('https://webdriver.io');
console.log(await browser.getTitle());

// 3. Stop Safaridriver process
Expand All @@ -142,20 +142,36 @@ safaridriver.stop();

## Configuration & Options

### Options for `geckodriver` & `edgedriver`
### Options for `geckodriver`

Passed into the `start(options)` method:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `port` | `number` | — | Port to listen on. |
| `host` | `string` | `0.0.0.0` | Host IP address to bind server. |
| `customGeckoDriverPath` / `customEdgeDriverPath` | `string` | `process.env.GECKODRIVER_PATH` | Path to custom/cached driver binary. |
| `cacheDir` | `string` | `os.tmpdir()` | Root directory for caching downloaded binaries. |
| `customGeckoDriverPath` | `string` | `process.env.GECKODRIVER_PATH` | Path to custom/cached driver binary. |
| `cacheDir` | `string` | `process.env.GECKODRIVER_CACHE_DIR \|\| os.tmpdir()` | Root directory for caching downloaded binaries. |
| `spawnOpts` | `object` | `undefined` | Spawn options passed directly to Node.js `child_process.spawn`. |
| `allowHosts` | `string[]` | `[]` | List of explicit host names allowed to connect. |
| `allowOrigins` | `string[]` | `[]` | List of allowed request origins (`scheme://host:port`). |

See the [geckodriver README](packages/node-geckodriver/README.md) for the full list of options.

### Options for `edgedriver`

Passed into the `start(options)` method:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `port` | `number` | — | Port to listen on. |
| `customEdgeDriverPath` | `string` | `process.env.EDGEDRIVER_PATH` | Path to custom/cached driver binary. |
| `cacheDir` | `string` | `process.env.EDGEDRIVER_CACHE_DIR \|\| os.tmpdir()` | Root directory for caching downloaded binaries. |
| `allowedIps` | `string[]` | `['']` | List of remote IP addresses allowed to connect. |
| `allowedOrigins` | `string[]` | `['*']` | List of allowed request origins. Using `*` to allow any origin is dangerous! |

See the [edgedriver README](packages/node-edgedriver/README.md) for the full list of options.

### Options for `safaridriver`

Passed into `safaridriver.start(options)`:
Expand All @@ -172,8 +188,4 @@ Passed into `safaridriver.start(options)`:

## License

[MIT](https://www.google.com/search?q=LICENSE&utm_source=gemini)

```

```
[MIT](LICENSE)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
"checks:all": "pnpm run test:lint && pnpm run test:unit && pnpm run test:e2e"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^25.0.3",
"@types/which": "^3.0.4",
"@stylistic/eslint-plugin": "^5.10.0",
"@vitest/coverage-v8": "^4.1.11",
"husky": "^9.1.7",
"npm-run-all2": "^9.0.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/node-edgedriver/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ src
tests
coverage
*.tgz
*.tsbuildinfo
/.*
/*.mjs
/tsconfig.json
vitest.config.ts
6 changes: 3 additions & 3 deletions packages/node-edgedriver/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
EdgeDriver [![CI](https://github.com/webdriverio-community/node-edgedriver/actions/workflows/ci.yml/badge.svg)](https://github.com/webdriverio-community/node-edgedriver/actions/workflows/ci.yml) [![Audit](https://github.com/webdriverio-community/node-edgedriver/actions/workflows/audit.yml/badge.svg)](https://github.com/webdriverio-community/node-edgedriver/actions/workflows/audit.yml)
==========

An NPM wrapper for Microsofts' [EdgeDriver](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/). It manages to download various (or the latest) Edgedriver versions and provides a programmatic interface to start and stop it within Node.js. __Note:__ this is a wrapper module. If you discover any bugs with EdgeDriver, please report them in the [official repository](https://github.com/MicrosoftEdge/EdgeWebDriver).
An NPM wrapper for Microsoft's [EdgeDriver](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/). It manages to download various (or the latest) Edgedriver versions and provides a programmatic interface to start and stop it within Node.js. __Note:__ this is a wrapper module. If you discover any bugs with EdgeDriver, please report them in the [official repository](https://github.com/MicrosoftEdge/EdgeWebDriver).

# Installing

Expand All @@ -23,7 +23,7 @@ By default, this package downloads Edgedriver when used for the first time throu
EDGEDRIVER_AUTO_INSTALL=1 npm i
```

To get a list of available CLI options run `npx edgedriver --help`. By default this package tries to find the Mircosoft Edge version installed on a given system. If you prefer to have it install a custom EdgeDriver version you can define the environment variable `EDGEDRIVER_VERSION` when running in CLI, e.g.:
To get a list of available CLI options run `npx edgedriver --help`. By default this package tries to find the Microsoft Edge version installed on a given system. If you prefer to have it install a custom EdgeDriver version you can define the environment variable `EDGEDRIVER_VERSION` when running in CLI, e.g.:

```sh
$ npm i edgedriver
Expand Down Expand Up @@ -124,7 +124,7 @@ The `start` method offers the following options to be passed on to the actual Ed

### edgeDriverVersion

The version of EdgeDriver to start. See [Egdedriver directory list](https://msedgewebdriverstorage.z22.web.core.windows.net/) for all available versions, platforms and architecture.
The version of EdgeDriver to start. See [Edgedriver directory list](https://msedgewebdriverstorage.z22.web.core.windows.net/) for all available versions, platforms and architecture.

Type: `number`<br />
Default: `latest`
Expand Down
4 changes: 2 additions & 2 deletions packages/node-edgedriver/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "edgedriver",
"version": "6.3.1",
"description": "Microsofts' EdgeDriver for Node.js",
"description": "Microsoft's EdgeDriver for Node.js",
"homepage": "https://webdriver.io",
"license": "MIT",
"author": {
Expand Down Expand Up @@ -40,7 +40,7 @@
"scripts": {
"build": "tsc -b . && node ../../scripts/copy-cjs-pkg.mjs",
"clean": "rimraf ./dist",
"postinstall": "test -f ./dist/install.js && node ./dist/install.js || echo \"Skipping install, project not build!\"",
"postinstall": "test -f ./dist/install.js && node ./dist/install.js || echo \"Skipping install, project not built!\"",
"test": "run-s test:*",
"test:lint": "oxlint .",
"test:unit": "vitest --run",
Expand Down
23 changes: 22 additions & 1 deletion packages/node-edgedriver/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,20 @@ export async function fetchVersion (edgeVersion: string) {
async function downloadZip(res: Awaited<ReturnType<typeof fetch>>, cacheDir: string) {
const zipBlob = await res.blob()
const zip = new ZipReader(new BlobReader(zipBlob))
const resolvedCacheDir = path.resolve(cacheDir)
for (const entry of await zip.getEntries()) {
const unzippedFilePath = path.join(cacheDir, entry.filename)
if (entry.directory) {
continue
}
/**
* guard against Zip Slip: a malicious archive could contain entries
* with `../` or absolute paths that escape the cache directory
*/
const resolvedPath = path.resolve(unzippedFilePath)
if (resolvedPath !== resolvedCacheDir && !resolvedPath.startsWith(resolvedCacheDir + path.sep)) {
throw new Error(`Zip entry "${entry.filename}" resolves outside the cache directory`)
}
const fileEntry = entry as FileEntry
if (!await hasAccess(path.dirname(unzippedFilePath))) {
await fsp.mkdir(path.dirname(unzippedFilePath), { recursive: true })
Expand All @@ -227,10 +236,22 @@ function sanitizeVersion (version: string) {
return version.replace(/\0/g, '').slice(2).trim()
}

/**
* True when this module was invoked directly as the package's postinstall
* entrypoint (`dist/install.js`), as opposed to being imported as a dependency.
*/
export function isAutoInstallEntrypoint (argv1: string | undefined) {
if (!argv1) {
return false
}
const installJsPath = path.join('dist', 'install.js')
return path.normalize(argv1).endsWith(path.sep + installJsPath)
}

/**
* download on install
*/
if (process.argv[1] && process.argv[1].endsWith('/dist/install.js') && Boolean(process.env.EDGEDRIVER_AUTO_INSTALL)) {
if (isAutoInstallEntrypoint(process.argv[1]) && Boolean(process.env.EDGEDRIVER_AUTO_INSTALL)) {
await download().then(
() => log.info('Success!'),
(err) => log.error(`Failed to install Edgedriver: ${err.stack}`)
Expand Down
2 changes: 1 addition & 1 deletion packages/node-edgedriver/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface EdgedriverParameters {
allowedOrigins?: string[]
/**
* The path to the root of the cache directory.
* @default process.env.GECKODRIVER_CACHE_DIR || os.tmpdir()
* @default process.env.EDGEDRIVER_CACHE_DIR || os.tmpdir()
*/
cacheDir?: string
}
9 changes: 9 additions & 0 deletions packages/node-edgedriver/tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { test, expect } from 'vitest'

import * as pkgExports from '../src/index.js'

test('exports', () => {
expect(typeof pkgExports.download).toBe('function')
expect(typeof pkgExports.findEdgePath).toBe('function')
expect(typeof pkgExports.start).toBe('function')
})
Original file line number Diff line number Diff line change
@@ -1,18 +1,47 @@
import os from 'node:os'
import { vi, test, expect, describe } from 'vitest'
import path from 'node:path'
import fsp from 'node:fs/promises'
import { vi, test, expect, describe, beforeEach } from 'vitest'

import * as pkgExports from '../src/index.js'
import { fetchVersion } from '../src/install.js'
import { getNameByArchitecture, parseParams, extractBasicAuthFromUrl } from '../src/utils.js'
import { fetchVersion, download, isAutoInstallEntrypoint } from '../src/install.js'
import { EDGE_PRODUCTS_API } from '../src/constants.js'

// All vi.mock calls must be at module scope so Vitest hoists them before any
// imports — mocks inside test() bodies are not hoisted.
vi.mock('node:os', () => ({
default: {
arch: vi.fn(),
platform: vi.fn()
platform: vi.fn(),
tmpdir: vi.fn(() => '/tmp')
}
}))

vi.mock('node:fs/promises', () => ({
default: {
access: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
chmod: vi.fn().mockResolvedValue(undefined),
},
writeFile: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('../src/utils.js', async (original) => {
const actual: any = await original()
return {
...actual,
hasAccess: vi.fn(),
}
})

const zipState = vi.hoisted(() => ({ entries: [] as any[] }))
vi.mock('@zip.js/zip.js', () => ({
BlobReader: class { },
BlobWriter: class { },
ZipReader: class {
getEntries() { return Promise.resolve(zipState.entries) }
},
}))

// Mock the global fetch function
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
Expand Down Expand Up @@ -127,59 +156,50 @@ describe('fetchVersion', () => {
})
})

test('getNameByArchitecture', () => {
vi.mocked(os.arch).mockReturnValue('arm')
vi.mocked(os.platform).mockReturnValue('linux')
expect(getNameByArchitecture()).toBe('edgedriver_linux32')
vi.mocked(os.arch).mockReturnValue('arm64')
vi.mocked(os.platform).mockReturnValue('linux')
expect(getNameByArchitecture()).toBe('edgedriver_linux64')
vi.mocked(os.arch).mockReturnValue('arm')
vi.mocked(os.platform).mockReturnValue('win32')
expect(getNameByArchitecture()).toBe('edgedriver_win32')
vi.mocked(os.arch).mockReturnValue('arm64')
vi.mocked(os.platform).mockReturnValue('win32')
expect(getNameByArchitecture()).toBe('edgedriver_win64')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(os.platform).mockReturnValue('darwin')
expect(getNameByArchitecture()).toBe('edgedriver_mac64')
vi.mocked(os.arch).mockReturnValue('arm64')
vi.mocked(os.platform).mockReturnValue('darwin')
expect(getNameByArchitecture()).toBe('edgedriver_mac64_m1')
})

test('parseParams', () => {
expect(parseParams({ baseUrl: 'foobar', silent: true, verbose: false, allowedIps: ['123', '321'] }))
.toMatchSnapshot()
})

test('exports', () => {
expect(typeof pkgExports.download).toBe('function')
expect(typeof pkgExports.findEdgePath).toBe('function')
expect(typeof pkgExports.start).toBe('function')
})
describe('isAutoInstallEntrypoint', () => {
// uses the native `path` module (matching the implementation), so this
// only proves correctness for the host OS running the test — real
// cross-platform coverage (including Windows) comes from CI running this
// same file on the macOS/Ubuntu/Windows matrix, not from simulating
// another platform's paths in-process.
test('matches when argv[1] points at dist/install.js', () => {
const installJsPath = path.join('dist', 'install.js')
expect(isAutoInstallEntrypoint(path.join('/home/user/node_modules/edgedriver', installJsPath))).toBe(true)
})

test('extractBasicAuthFromUrl with credentials', () => {
const result = extractBasicAuthFromUrl('https://myuser:mypassword@cdn.example.com/path/file.zip')
expect(result.url).toBe('https://cdn.example.com/path/file.zip')
expect(result.authHeader).toBe('Basic ' + Buffer.from('myuser:mypassword').toString('base64'))
})
test('does not match when the package is only imported as a dependency', () => {
expect(isAutoInstallEntrypoint(path.join('/home/user/my-project/index.js'))).toBe(false)
})

test('extractBasicAuthFromUrl without credentials', () => {
const result = extractBasicAuthFromUrl('https://cdn.example.com/path/file.zip')
expect(result.url).toBe('https://cdn.example.com/path/file.zip')
expect(result.authHeader).toBeUndefined()
test('does not match when argv[1] is undefined', () => {
expect(isAutoInstallEntrypoint(undefined)).toBe(false)
})
})

test('extractBasicAuthFromUrl with only username', () => {
const result = extractBasicAuthFromUrl('https://myuser@cdn.example.com/path/file.zip')
expect(result.url).toBe('https://cdn.example.com/path/file.zip')
expect(result.authHeader).toBe('Basic ' + Buffer.from('myuser:').toString('base64'))
})
describe('download', () => {
const CACHE_DIR = path.resolve(os.tmpdir(), 'test-cache')
let hasAccess: ReturnType<typeof vi.fn>

beforeEach(async () => {
const utils = await import('../src/utils.js')
hasAccess = vi.mocked(utils.hasAccess)
hasAccess.mockResolvedValue(false) // cache miss
vi.mocked(fsp.mkdir).mockClear()
vi.mocked(fsp.chmod).mockClear()
mockFetch.mockReset()
})

test('extractBasicAuthFromUrl with invalid URL returns original', () => {
const result = extractBasicAuthFromUrl('not-a-valid-url')
expect(result.url).toBe('not-a-valid-url')
expect(result.authHeader).toBeUndefined()
test('rejects zip entries that escape the cache directory (Zip Slip)', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
body: {},
blob: vi.fn().mockResolvedValue(new Blob([])),
})
zipState.entries = [
{ filename: '../../evil.exe', directory: false, getData: vi.fn() },
]

await expect(download('123.456.789.0', CACHE_DIR)).rejects.toThrow('resolves outside the cache directory')
})
})

Loading
Loading