Skip to content

Commit ef016bd

Browse files
committed
fix: Add support for external BrowserStack Local tunnel handling and dynamic connection configurations
1 parent fb5cb66 commit ef016bd

4 files changed

Lines changed: 128 additions & 11 deletions

File tree

src/providers/cloud/browserstack.provider.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { promisify } from 'node:util';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
24
import { Local as BrowserstackTunnel } from 'browserstack-local';
35
import type { ConnectionConfig, SessionProvider, SessionResult } from '../types';
46

57
export class BrowserStackProvider implements SessionProvider {
68
name = 'browserstack';
79

8-
getConnectionConfig(_options: Record<string, unknown>): ConnectionConfig {
10+
getConnectionConfig(options: Record<string, unknown>): ConnectionConfig {
11+
const platform = options.platform as string;
12+
const hostname = platform === 'browser' ? 'hub.browserstack.com' : 'hub-cloud.browserstack.com';
913
return {
1014
protocol: 'https',
11-
hostname: 'hub.browserstack.com',
15+
hostname,
1216
port: 443,
1317
path: '/wd/hub',
1418
user: process.env.BROWSERSTACK_USERNAME,
@@ -87,9 +91,10 @@ export class BrowserStackProvider implements SessionProvider {
8791
const tunnel = new BrowserstackTunnel();
8892
const start = promisify(tunnel.start.bind(tunnel));
8993
try {
90-
await start({ key });
94+
const logFile = join(tmpdir(), 'browserstack-local.log');
95+
await start({ key, forceLocal: true, logFile });
9196
} catch (e: unknown) {
92-
const msg = e instanceof Error ? e.message : String(e);
97+
const msg = (e !== null && typeof e === 'object' ? (e as { message?: string }).message : undefined) ?? String(e);
9398
if (msg.includes('another browserstack local client is running') || msg.includes('server is listening on port')) {
9499
console.error('[BrowserStack] Tunnel already running — reusing existing tunnel');
95100
return null;

src/tools/session.tool.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const startSessionToolDefinition: ToolDefinition = {
5454
port: z.number().optional(),
5555
path: z.string().optional(),
5656
}).optional().describe('Appium server connection (local provider only)'),
57-
browserstackLocal: coerceBoolean.optional().default(false).describe('Enable BrowserStack Local tunnel for testing against local/internal URLs (BrowserStack only, default: false). When true, the tunnel is started automatically before the session and stopped when the session is closed.'),
57+
browserstackLocal: z.union([coerceBoolean, z.literal('external')]).optional().default(false).describe('Enable BrowserStack Local tunnel routing (BrowserStack only, default: false). true = auto-start tunnel before session and stop on close. "external" = tunnel already running externally, set local: true in capabilities only.'),
5858
navigationUrl: z.string().optional().describe('URL to navigate to after starting'),
5959
capabilities: z.record(z.string(), z.unknown()).optional().describe('Additional capabilities to merge'),
6060
},
@@ -87,7 +87,7 @@ type StartSessionArgs = {
8787
attach?: boolean;
8888
attachConfig?: { port?: number; host?: string };
8989
appiumConfig?: { host?: string; port?: number; path?: string };
90-
browserstackLocal?: boolean;
90+
browserstackLocal?: boolean | 'external';
9191
navigationUrl?: string;
9292
capabilities?: Record<string, unknown>;
9393
};
@@ -186,7 +186,7 @@ async function startBrowserSession(args: StartSessionArgs): Promise<CallToolResu
186186
capabilities: userCapabilities,
187187
});
188188

189-
const tunnelHandle = args.browserstackLocal
189+
const tunnelHandle = args.browserstackLocal === true
190190
? await provider.startTunnel?.(args as Record<string, unknown>)
191191
: undefined;
192192

@@ -250,7 +250,7 @@ async function startMobileSession(args: StartSessionArgs): Promise<CallToolResul
250250
const serverConfig = provider.getConnectionConfig(args as Record<string, unknown>);
251251
const mergedCapabilities = provider.buildCapabilities(args as Record<string, unknown>);
252252

253-
const tunnelHandle = args.browserstackLocal
253+
const tunnelHandle = args.browserstackLocal === true
254254
? await provider.startTunnel?.(args as Record<string, unknown>)
255255
: undefined;
256256

tests/providers/browserstack.provider.test.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
3+
const { mockTunnel } = vi.hoisted(() => ({
4+
mockTunnel: { start: vi.fn(), stop: vi.fn() },
5+
}));
6+
7+
vi.mock('browserstack-local', () => ({
8+
Local: class {
9+
start = mockTunnel.start;
10+
stop = mockTunnel.stop;
11+
},
12+
}));
13+
214
import { BrowserStackProvider } from '../../src/providers/cloud/browserstack.provider';
315

416
describe('BrowserStackProvider', () => {
@@ -9,14 +21,24 @@ describe('BrowserStackProvider', () => {
921
});
1022

1123
describe('getConnectionConfig', () => {
12-
it('returns BrowserStack hub connection details', () => {
13-
const config = provider.getConnectionConfig({});
24+
it('returns hub.browserstack.com for browser platform', () => {
25+
const config = provider.getConnectionConfig({ platform: 'browser' });
1426
expect(config.hostname).toBe('hub.browserstack.com');
1527
expect(config.protocol).toBe('https');
1628
expect(config.port).toBe(443);
1729
expect(config.path).toBe('/wd/hub');
1830
});
1931

32+
it('returns hub-cloud.browserstack.com for android platform', () => {
33+
const config = provider.getConnectionConfig({ platform: 'android' });
34+
expect(config.hostname).toBe('hub-cloud.browserstack.com');
35+
});
36+
37+
it('returns hub-cloud.browserstack.com for ios platform', () => {
38+
const config = provider.getConnectionConfig({ platform: 'ios' });
39+
expect(config.hostname).toBe('hub-cloud.browserstack.com');
40+
});
41+
2042
it('reads credentials from environment variables', () => {
2143
vi.stubEnv('BROWSERSTACK_USERNAME', 'myuser');
2244
vi.stubEnv('BROWSERSTACK_ACCESS_KEY', 'mykey');
@@ -164,6 +186,17 @@ describe('BrowserStackProvider', () => {
164186
const bstack = caps['bstack:options'] as Record<string, unknown>;
165187
expect(bstack.local).toBe(true);
166188
});
189+
190+
it('sets local: true in bstack:options when browserstackLocal is "external"', () => {
191+
const caps = provider.buildCapabilities({
192+
platform: 'android',
193+
deviceName: 'Pixel 7',
194+
app: 'bs://abc',
195+
browserstackLocal: 'external',
196+
});
197+
const bstack = caps['bstack:options'] as Record<string, unknown>;
198+
expect(bstack.local).toBe(true);
199+
});
167200
});
168201

169202
describe('getSessionType', () => {
@@ -185,4 +218,69 @@ describe('BrowserStackProvider', () => {
185218
expect(provider.shouldAutoDetach({})).toBe(false);
186219
});
187220
});
221+
222+
describe('startTunnel', () => {
223+
beforeEach(() => {
224+
vi.stubEnv('BROWSERSTACK_ACCESS_KEY', 'testkey');
225+
mockTunnel.start.mockReset();
226+
mockTunnel.stop.mockReset();
227+
});
228+
229+
it('returns the tunnel instance on successful start', async () => {
230+
mockTunnel.start.mockImplementation((opts: unknown, cb: (err: unknown) => void) => cb(null));
231+
232+
const handle = await provider.startTunnel({});
233+
expect(handle).toBeDefined();
234+
});
235+
236+
it('passes logFile pointing to os.tmpdir() to avoid polluting cwd', async () => {
237+
let capturedOpts: unknown;
238+
mockTunnel.start.mockImplementation((opts: unknown, cb: (err: unknown) => void) => {
239+
capturedOpts = opts;
240+
cb(null);
241+
});
242+
243+
await provider.startTunnel({});
244+
const logFile = (capturedOpts as Record<string, unknown>).logFile as string;
245+
expect(logFile).toBeDefined();
246+
expect(logFile).toContain('browserstack-local');
247+
});
248+
249+
it('passes forceLocal: true to tunnel start', async () => {
250+
let capturedOpts: unknown;
251+
mockTunnel.start.mockImplementation((opts: unknown, cb: (err: unknown) => void) => {
252+
capturedOpts = opts;
253+
cb(null);
254+
});
255+
256+
await provider.startTunnel({});
257+
expect((capturedOpts as Record<string, unknown>).forceLocal).toBe(true);
258+
});
259+
260+
it('returns null when tunnel is already running (plain object error with message)', async () => {
261+
mockTunnel.start.mockImplementation((opts: unknown, cb: (err: unknown) => void) =>
262+
cb({ message: 'another browserstack local client is running' }),
263+
);
264+
265+
const handle = await provider.startTunnel({});
266+
expect(handle).toBeNull();
267+
});
268+
269+
it('returns null when server is already listening (plain object error with message)', async () => {
270+
mockTunnel.start.mockImplementation((opts: unknown, cb: (err: unknown) => void) =>
271+
cb({ message: 'server is listening on port 45691' }),
272+
);
273+
274+
const handle = await provider.startTunnel({});
275+
expect(handle).toBeNull();
276+
});
277+
278+
it('rethrows unrecognised errors', async () => {
279+
mockTunnel.start.mockImplementation((opts: unknown, cb: (err: unknown) => void) =>
280+
cb({ message: 'some other fatal error' }),
281+
);
282+
283+
await expect(provider.startTunnel({})).rejects.toEqual({ message: 'some other fatal error' });
284+
});
285+
});
188286
});

tests/tools/start-session-browserstack.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describe('start_session with provider: browserstack', () => {
8888
});
8989

9090
expect(mockRemote).toHaveBeenCalledWith(expect.objectContaining({
91-
hostname: 'hub.browserstack.com',
91+
hostname: 'hub-cloud.browserstack.com',
9292
user: 'testuser',
9393
key: 'testkey',
9494
capabilities: expect.objectContaining({
@@ -142,6 +142,20 @@ describe('start_session with browserstackLocal: true', () => {
142142
expect(mockRemote).not.toHaveBeenCalled();
143143
});
144144

145+
it('does NOT call BrowserstackTunnel when browserstackLocal is "external"', async () => {
146+
await callTool({ provider: 'browserstack', platform: 'browser', browser: 'chrome', browserstackLocal: 'external' });
147+
148+
expect(BrowserstackTunnel).not.toHaveBeenCalled();
149+
});
150+
151+
it('sets local: true in bstack:options when browserstackLocal is "external"', async () => {
152+
await callTool({ provider: 'browserstack', platform: 'browser', browser: 'chrome', browserstackLocal: 'external' });
153+
154+
const [call] = mockRemote.mock.calls;
155+
const bstackOpts = call[0].capabilities['bstack:options'];
156+
expect(bstackOpts.local).toBe(true);
157+
});
158+
145159
it('creates a BrowserstackTunnel instance for mobile platform', async () => {
146160
await callTool({
147161
provider: 'browserstack',

0 commit comments

Comments
 (0)