Skip to content

Commit 5827a99

Browse files
committed
feat: Integrate auto-start BrowserStack Local tunnel on session start & management
- Replace BrowserStack-specific tunnel handling with generic provider-based lifecycle hooks - Add BrowserStack user:pass session setup in code generation
1 parent 59ec970 commit 5827a99

11 files changed

Lines changed: 486 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@toon-format/toon": "^2.1.0",
4949
"@wdio/protocols": "^9.27.0",
5050
"@xmldom/xmldom": "^0.8.12",
51+
"browserstack-local": "^1.5.12",
5152
"puppeteer-core": "^24.40.0",
5253
"sharp": "^0.34.5",
5354
"webdriverio": "^9.27.0",

pnpm-lock.yaml

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/providers/cloud/browserstack.provider.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { ConnectionConfig, SessionProvider } from '../types';
1+
import { promisify } from 'node:util';
2+
import { Local as BrowserstackTunnel } from 'browserstack-local';
3+
import type { ConnectionConfig, SessionProvider, SessionResult } from '../types';
24

35
export class BrowserStackProvider implements SessionProvider {
46
name = 'browserstack';
@@ -79,6 +81,43 @@ export class BrowserStackProvider implements SessionProvider {
7981
shouldAutoDetach(_options: Record<string, unknown>): boolean {
8082
return false;
8183
}
84+
85+
async startTunnel(_options: Record<string, unknown>): Promise<unknown> {
86+
const key = process.env.BROWSERSTACK_ACCESS_KEY ?? '';
87+
const tunnel = new BrowserstackTunnel();
88+
const start = promisify(tunnel.start.bind(tunnel));
89+
await start({ key });
90+
return tunnel;
91+
}
92+
93+
async onSessionClose(
94+
sessionId: string,
95+
sessionType: 'browser' | 'ios' | 'android',
96+
result: SessionResult,
97+
tunnelHandle?: unknown,
98+
): Promise<void> {
99+
if (tunnelHandle) {
100+
const tunnel = tunnelHandle as InstanceType<typeof BrowserstackTunnel>;
101+
const stop = promisify(tunnel.stop.bind(tunnel));
102+
await stop();
103+
}
104+
105+
const user = process.env.BROWSERSTACK_USERNAME;
106+
const key = process.env.BROWSERSTACK_ACCESS_KEY;
107+
if (!user || !key) return;
108+
109+
const baseUrl = sessionType === 'browser'
110+
? 'https://api.browserstack.com/automate/sessions'
111+
: 'https://api-cloud.browserstack.com/app-automate/sessions';
112+
113+
const auth = Buffer.from(`${user}:${key}`).toString('base64');
114+
const body: Record<string, string> = { status: result.status, ...(result.reason ? { reason: result.reason } : {}) };
115+
await fetch(`${baseUrl}/${sessionId}.json`, {
116+
method: 'PUT',
117+
headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' },
118+
body: JSON.stringify(body),
119+
});
120+
}
82121
}
83122

84123
export const browserStackProvider = new BrowserStackProvider();

src/providers/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@ export interface ConnectionConfig {
88
services?: unknown[];
99
}
1010

11+
export interface SessionResult {
12+
status: 'passed' | 'failed';
13+
reason?: string;
14+
}
15+
1116
export interface SessionProvider {
1217
name: string;
1318
getConnectionConfig(options: Record<string, unknown>): ConnectionConfig;
1419
buildCapabilities(options: Record<string, unknown>): Record<string, unknown>;
1520
getSessionType(options: Record<string, unknown>): 'browser' | 'ios' | 'android';
1621
shouldAutoDetach(options: Record<string, unknown>): boolean;
22+
startTunnel?(options: Record<string, unknown>): Promise<unknown>;
23+
onSessionClose?(sessionId: string, sessionType: 'browser' | 'ios' | 'android', result: SessionResult, tunnelHandle?: unknown): Promise<void>;
1724
}

src/recording/code-generator.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,30 @@ function generateStep(step: RecordedStep, history: SessionHistory): string {
3333
switch (step.tool) {
3434
case 'start_session': {
3535
const platform = p.platform as string;
36+
const isBrowserStack = 'bstack:options' in history.capabilities;
37+
const capJson = indentJson(history.capabilities)
38+
.split('\n')
39+
.map((line, i) => (i > 0 ? ` ${line}` : line))
40+
.join('\n');
41+
42+
if (isBrowserStack) {
43+
const nav =
44+
platform === 'browser' && p.navigationUrl
45+
? `\nawait browser.url('${escapeStr(p.navigationUrl)}');`
46+
: '';
47+
return [
48+
'const browser = await remote({',
49+
" protocol: 'https',",
50+
" hostname: 'hub.browserstack.com',",
51+
' port: 443,',
52+
" path: '/wd/hub',",
53+
' user: process.env.BS_USER,',
54+
' key: process.env.BS_KEY,',
55+
` capabilities: ${capJson}`,
56+
`});${nav}`,
57+
].join('\n');
58+
}
59+
3660
if (platform === 'browser') {
3761
const nav = p.navigationUrl ? `\nawait browser.url('${escapeStr(p.navigationUrl)}');` : '';
3862
return `const browser = await remote({\n capabilities: ${indentJson(history.capabilities)}\n});${nav}`;
@@ -81,12 +105,77 @@ function generateStep(step: RecordedStep, history: SessionHistory): string {
81105
}
82106
}
83107

108+
function bsStatusUpdateLines(sessionType: 'browser' | 'ios' | 'android'): string[] {
109+
const apiUrl = sessionType === 'browser'
110+
? 'https://api.browserstack.com/automate/sessions/'
111+
: 'https://api-cloud.browserstack.com/app-automate/sessions/';
112+
return [
113+
" const bsAuth = Buffer.from(`${process.env.BS_USER}:${process.env.BS_KEY}`).toString('base64');",
114+
` await fetch('${apiUrl}' + browser.sessionId + '.json', {`,
115+
" method: 'PUT',",
116+
" headers: { Authorization: 'Basic ' + bsAuth, 'Content-Type': 'application/json' },",
117+
' body: JSON.stringify({ status: bsStatus, ...(bsReason ? { reason: bsReason } : {}) })',
118+
' });',
119+
];
120+
}
121+
84122
export function generateCode(history: SessionHistory): string {
123+
const bstackOptions = history.capabilities['bstack:options'] as Record<string, unknown> | undefined;
124+
const isBrowserStack = bstackOptions !== undefined;
125+
const usesBrowserstackLocal = bstackOptions?.local === true;
126+
85127
const steps = history.steps
86128
.map(step => generateStep(step, history))
87129
.join('\n')
88130
.split('\n')
89131
.map(line => ` ${line}`)
90132
.join('\n');
133+
134+
if (isBrowserStack) {
135+
const bsSteps = steps.replace(/const browser = await remote\(/g, 'browser = await remote(');
136+
const statusUpdate = bsStatusUpdateLines(history.type).join('\n');
137+
const preamble = 'let browser;\nlet bsStatus = \'passed\';\nlet bsReason;';
138+
const catchBlock = '} catch (e) {\n bsStatus = \'failed\';\n bsReason = String(e);\n throw e;';
139+
const finallyBody = ` if (browser) {\n${statusUpdate}\n await browser.deleteSession();\n }`;
140+
141+
if (usesBrowserstackLocal) {
142+
const tunnelSetup = [
143+
'',
144+
'const tunnel = new BrowserstackTunnel();',
145+
'const startTunnel = promisify(tunnel.start.bind(tunnel));',
146+
'const stopTunnel = promisify(tunnel.stop.bind(tunnel));',
147+
'await startTunnel({ key: process.env.BROWSERSTACK_ACCESS_KEY });',
148+
'',
149+
].join('\n');
150+
151+
return [
152+
"import { remote } from 'webdriverio';",
153+
"import { Local as BrowserstackTunnel } from 'browserstack-local';",
154+
"import { promisify } from 'node:util';",
155+
tunnelSetup,
156+
preamble,
157+
'try {',
158+
bsSteps,
159+
catchBlock,
160+
'} finally {',
161+
finallyBody,
162+
' await stopTunnel();',
163+
'}',
164+
].join('\n');
165+
}
166+
167+
return [
168+
"import { remote } from 'webdriverio';",
169+
'',
170+
preamble,
171+
'try {',
172+
bsSteps,
173+
catchBlock,
174+
'} finally {',
175+
finallyBody,
176+
'}',
177+
].join('\n');
178+
}
179+
91180
return `import { remote } from 'webdriverio';\n\ntry {\n${steps}\n} finally {\n await browser.deleteSession();\n}`;
92181
}

src/session/lifecycle.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import type { SessionHistory } from '../types/recording';
2+
import type { SessionResult } from '../providers/types';
23
import type { SessionMetadata } from './state';
34
import { getState } from './state';
5+
import { getProvider } from '../providers/registry';
6+
7+
function getSessionResult(history: SessionHistory | undefined): SessionResult {
8+
const errorStep = history?.steps.find(s => s.status === 'error');
9+
return errorStep
10+
? { status: 'failed', reason: errorStep.error }
11+
: { status: 'passed' };
12+
}
413

514
export function handleSessionTransition(newSessionId: string): void {
615
const state = getState();
@@ -39,11 +48,19 @@ export function registerSession(
3948
// If there was a previous session, terminate it to prevent orphaning
4049
if (oldSessionId && oldSessionId !== sessionId) {
4150
const oldBrowser = state.browsers.get(oldSessionId);
51+
const oldMetadata = state.sessionMetadata.get(oldSessionId);
4252
if (oldBrowser) {
4353
// Fire and forget — don't block registration on close
44-
oldBrowser.deleteSession().catch(() => {
45-
// Ignore errors during force-close of orphaned session
46-
});
54+
void (async () => {
55+
if (oldMetadata?.provider) {
56+
const oldHistory = state.sessionHistory.get(oldSessionId);
57+
const provider = getProvider(oldMetadata.provider, oldMetadata.type);
58+
await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle).catch(() => {});
59+
}
60+
await oldBrowser.deleteSession().catch(() => {
61+
// Ignore errors during force-close of orphaned session
62+
});
63+
})();
4764
state.browsers.delete(oldSessionId);
4865
state.sessionMetadata.delete(oldSessionId);
4966
}
@@ -60,10 +77,20 @@ export async function closeSession(sessionId: string, detach: boolean, isAttache
6077
history.endedAt = new Date().toISOString();
6178
}
6279

80+
const metadata = state.sessionMetadata.get(sessionId);
81+
6382
// Terminate the WebDriver session if:
6483
// - force is true (override), OR
6584
// - detach is false AND isAttached is false (normal close)
6685
if (force || (!detach && !isAttached)) {
86+
if (metadata?.provider) {
87+
try {
88+
const provider = getProvider(metadata.provider, metadata.type);
89+
await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle);
90+
} catch (e) {
91+
console.error('[WARN] Failed to run provider onSessionClose:', e);
92+
}
93+
}
6794
await browser.deleteSession();
6895
}
6996

src/session/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export interface SessionMetadata {
44
type: 'browser' | 'ios' | 'android';
55
capabilities: Record<string, unknown>;
66
isAttached: boolean;
7+
provider?: 'local' | 'browserstack';
8+
tunnelHandle?: unknown;
79
}
810

911
const state = {

0 commit comments

Comments
 (0)