Skip to content

Commit 9864253

Browse files
committed
fix: Address issues with session management and code generation of new session tool
- Simplify session metadata handling by directly using merged capabilities.
1 parent 2a39893 commit 9864253

8 files changed

Lines changed: 111 additions & 73 deletions

File tree

.github/workflows/lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ on:
88
branches:
99
- main
1010

11+
permissions:
12+
contents: read
13+
1114
jobs:
1215
lint:
1316
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
node_modules
55
lib
66

7+
settings.local.json
78
*.tgz

src/recording/code-generator.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ function generateStep(step: RecordedStep, history: SessionHistory): string {
3131

3232
const p = step.params;
3333
switch (step.tool) {
34-
case 'start_browser': {
35-
const nav = p.navigationUrl ? `\nawait browser.url('${escapeStr(p.navigationUrl)}');` : '';
36-
return `const browser = await remote({\n capabilities: ${indentJson(history.capabilities)}\n});${nav}`;
37-
}
38-
case 'start_app_session': {
34+
case 'start_session': {
35+
const platform = p.platform as string;
36+
if (platform === 'browser') {
37+
const nav = p.navigationUrl ? `\nawait browser.url('${escapeStr(p.navigationUrl)}');` : '';
38+
return `const browser = await remote({\n capabilities: ${indentJson(history.capabilities)}\n});${nav}`;
39+
}
40+
// Mobile (ios/android)
3941
const config: Record<string, unknown> = {
4042
protocol: 'http',
4143
hostname: history.appiumConfig?.hostname ?? 'localhost',
@@ -45,10 +47,8 @@ function generateStep(step: RecordedStep, history: SessionHistory): string {
4547
};
4648
return `const browser = await remote(${indentJson(config)});`;
4749
}
48-
case 'attach_browser': {
49-
const nav = p.navigationUrl ? `\nawait browser.url('${escapeStr(p.navigationUrl)}');` : '';
50-
return `const browser = await remote({\n capabilities: ${indentJson(history.capabilities)}\n});${nav}`;
51-
}
50+
case 'close_session':
51+
return 'await browser.deleteSession();';
5252
case 'navigate':
5353
return `await browser.url('${escapeStr(p.url)}');`;
5454
case 'click_element':
@@ -78,5 +78,5 @@ function generateStep(step: RecordedStep, history: SessionHistory): string {
7878

7979
export function generateCode(history: SessionHistory): string {
8080
const steps = history.steps.map(step => generateStep(step, history)).join('\n');
81-
return `import { remote } from 'webdriverio';\n\n${steps}\n\nawait browser.deleteSession();`;
81+
return `import { remote } from 'webdriverio';\n\n${steps}`;
8282
}

src/session/lifecycle.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,30 @@ export function registerSession(
2727
historyEntry: SessionHistory,
2828
): void {
2929
const state = getState();
30-
if (state.currentSession && state.currentSession !== sessionId) {
30+
const oldSessionId = state.currentSession;
31+
if (oldSessionId && oldSessionId !== sessionId) {
3132
handleSessionTransition(sessionId);
3233
}
3334
state.browsers.set(sessionId, browser);
3435
state.sessionMetadata.set(sessionId, metadata);
3536
state.sessionHistory.set(sessionId, historyEntry);
3637
state.currentSession = sessionId;
38+
39+
// If there was a previous session, terminate it to prevent orphaning
40+
if (oldSessionId && oldSessionId !== sessionId) {
41+
const oldBrowser = state.browsers.get(oldSessionId);
42+
if (oldBrowser) {
43+
// Fire and forget — don't block registration on close
44+
oldBrowser.deleteSession().catch(() => {
45+
// Ignore errors during force-close of orphaned session
46+
});
47+
state.browsers.delete(oldSessionId);
48+
state.sessionMetadata.delete(oldSessionId);
49+
}
50+
}
3751
}
3852

39-
export async function closeSession(sessionId: string, detach: boolean, isAttached: boolean): Promise<void> {
53+
export async function closeSession(sessionId: string, detach: boolean, isAttached: boolean, force?: boolean): Promise<void> {
4054
const state = getState();
4155
const browser = state.browsers.get(sessionId);
4256
if (!browser) return;
@@ -46,12 +60,18 @@ export async function closeSession(sessionId: string, detach: boolean, isAttache
4660
history.endedAt = new Date().toISOString();
4761
}
4862

49-
// Only terminate the WebDriver session if we created it (not attached/borrowed)
50-
if (!detach && !isAttached) {
63+
// Terminate the WebDriver session if:
64+
// - force is true (override), OR
65+
// - detach is false AND isAttached is false (normal close)
66+
if (force || (!detach && !isAttached)) {
5167
await browser.deleteSession();
5268
}
5369

5470
state.browsers.delete(sessionId);
5571
state.sessionMetadata.delete(sessionId);
56-
state.currentSession = null;
72+
73+
// Only clear currentSession if it matches the session being closed
74+
if (state.currentSession === sessionId) {
75+
state.currentSession = null;
76+
}
5777
}

src/tools/session.tool.ts

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,17 @@ async function startBrowserSession(args: StartSessionArgs): Promise<CallToolResu
166166
const wdioBrowser = await remote({ capabilities: mergedCapabilities });
167167
const { sessionId } = wdioBrowser;
168168

169-
registerSession(sessionId, wdioBrowser, {
169+
const sessionMetadata: SessionMetadata = {
170170
type: 'browser',
171-
capabilities: wdioBrowser.capabilities as Record<string, unknown>,
171+
capabilities: mergedCapabilities,
172172
isAttached: false,
173-
}, {
173+
};
174+
175+
registerSession(sessionId, wdioBrowser, sessionMetadata, {
174176
sessionId,
175177
type: 'browser',
176178
startedAt: new Date().toISOString(),
177-
capabilities: wdioBrowser.capabilities as Record<string, unknown>,
179+
capabilities: mergedCapabilities,
178180
steps: [],
179181
});
180182

@@ -267,41 +269,36 @@ async function attachBrowserSession(args: StartSessionArgs): Promise<CallToolRes
267269
await waitForCDP(host, port);
268270
const { activeTabUrl, allTabUrls } = await closeStaleMappers(host, port);
269271

272+
const capabilities = {
273+
browserName: 'chrome',
274+
unhandledPromptBehavior: 'dismiss',
275+
webSocketUrl: false,
276+
'goog:chromeOptions': {
277+
debuggerAddress: `${host}:${port}`,
278+
},
279+
};
280+
270281
const browser = await remote({
271282
connectionRetryTimeout: 30000,
272283
connectionRetryCount: 3,
273-
capabilities: {
274-
browserName: 'chrome',
275-
unhandledPromptBehavior: 'dismiss',
276-
webSocketUrl: false,
277-
'goog:chromeOptions': {
278-
debuggerAddress: `${host}:${port}`,
279-
},
280-
},
284+
capabilities,
281285
});
282286

283287
const { sessionId } = browser;
284-
registerSession(
288+
289+
const sessionMetadata: SessionMetadata = {
290+
type: 'browser',
291+
capabilities,
292+
isAttached: true,
293+
};
294+
295+
registerSession(sessionId, browser, sessionMetadata, {
285296
sessionId,
286-
browser,
287-
{
288-
type: 'browser',
289-
capabilities: browser.capabilities as Record<string, unknown>,
290-
isAttached: true,
291-
},
292-
{
293-
sessionId,
294-
type: 'browser',
295-
startedAt: new Date().toISOString(),
296-
capabilities: {
297-
browserName: 'chrome',
298-
'goog:chromeOptions': {
299-
debuggerAddress: `${host}:${port}`,
300-
},
301-
},
302-
steps: [],
303-
},
304-
);
297+
type: 'browser',
298+
startedAt: new Date().toISOString(),
299+
capabilities,
300+
steps: [],
301+
});
305302

306303
if (navigationUrl) {
307304
await browser.url(navigationUrl);
@@ -341,11 +338,17 @@ export const closeSessionTool: ToolCallback = async (args: { detach?: boolean }
341338
const sessionId = state.currentSession;
342339
const metadata = state.sessionMetadata.get(sessionId);
343340

344-
const effectiveDetach = args.detach || !!metadata?.isAttached;
345-
await closeSession(sessionId, args.detach ?? false, !!metadata?.isAttached);
341+
const isAttached = !!metadata?.isAttached;
342+
const detach = args.detach ?? false;
343+
344+
// Determine if this is a force-close (auto-detached session + explicit close)
345+
const force = !detach && isAttached;
346+
347+
const effectiveDetach = detach || isAttached;
348+
await closeSession(sessionId, detach, isAttached, force);
346349

347-
const action = effectiveDetach ? 'detached from' : 'closed';
348-
const note = args.detach && !metadata?.isAttached
350+
const action = effectiveDetach && !force ? 'detached from' : 'closed';
351+
const note = detach && !isAttached
349352
? '\nNote: Session will remain active on Appium server.'
350353
: '';
351354

tests/recording/code-generator.test.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { SessionHistory, RecordedStep } from '../../src/types/recording';
55

66
const START_BROWSER_STEP: RecordedStep = {
77
index: 1,
8-
tool: 'start_browser',
9-
params: { browser: 'chrome', headless: true, windowWidth: 1920, windowHeight: 1080 },
8+
tool: 'start_session',
9+
params: { platform: 'browser', browser: 'chrome', headless: true, windowWidth: 1920, windowHeight: 1080 },
1010
status: 'ok',
1111
durationMs: 0,
1212
timestamp: '2026-01-01T00:00:00.000Z',
@@ -35,14 +35,13 @@ function makeHistory(steps: Partial<RecordedStep>[]): SessionHistory {
3535
}
3636

3737
describe('generateCode - header', () => {
38-
it('wraps output in remote() setup and deleteSession', () => {
38+
it('wraps output in remote() setup', () => {
3939
const code = generateCode(makeHistory([]));
4040
expect(code).toContain("import { remote } from 'webdriverio';");
41-
expect(code).toContain('await browser.deleteSession();');
4241
expect(code).toContain('browserName');
4342
});
4443

45-
it('generates start_browser using history.capabilities, not reconstructed from params', () => {
44+
it('generates start_session (browser) using history.capabilities', () => {
4645
const history: SessionHistory = {
4746
sessionId: 'caps-123',
4847
type: 'browser',
@@ -54,8 +53,8 @@ describe('generateCode - header', () => {
5453
},
5554
steps: [{
5655
index: 1,
57-
tool: 'start_browser',
58-
params: { browser: 'chrome', headless: true },
56+
tool: 'start_session',
57+
params: { platform: 'browser', browser: 'chrome', headless: true },
5958
status: 'ok',
6059
durationMs: 100,
6160
timestamp: '2026-01-01T00:00:00.000Z',
@@ -64,10 +63,10 @@ describe('generateCode - header', () => {
6463
const code = generateCode(history);
6564
expect(code).toContain('const browser = await remote(');
6665
expect(code).toContain('"browserName": "chrome"');
67-
expect(code).toContain('--custom-flag'); // only present in history.capabilities
66+
expect(code).toContain('--custom-flag');
6867
});
6968

70-
it('generates attach_browser using history.capabilities', () => {
69+
it('generates start_session (attach mode) using history.capabilities', () => {
7170
const history: SessionHistory = {
7271
sessionId: 'attach-123',
7372
type: 'browser',
@@ -78,8 +77,8 @@ describe('generateCode - header', () => {
7877
},
7978
steps: [{
8079
index: 1,
81-
tool: 'attach_browser',
82-
params: { port: 9222, host: 'localhost', userDataDir: '/tmp/chrome-debug' },
80+
tool: 'start_session',
81+
params: { platform: 'browser', attach: true, port: 9222, host: 'localhost', userDataDir: '/tmp/chrome-debug' },
8382
status: 'ok',
8483
durationMs: 100,
8584
timestamp: '2026-01-01T00:00:00.000Z',
@@ -91,16 +90,16 @@ describe('generateCode - header', () => {
9190
expect(code).toContain('--user-data-dir=/tmp/chrome-debug');
9291
});
9392

94-
it('appends browser.url() when navigationUrl is set on start_browser', () => {
93+
it('appends browser.url() when navigationUrl is set on start_session (browser)', () => {
9594
const history: SessionHistory = {
9695
sessionId: 'nav-123',
9796
type: 'browser',
9897
startedAt: '2026-01-01T00:00:00.000Z',
9998
capabilities: { browserName: 'chrome' },
10099
steps: [{
101100
index: 1,
102-
tool: 'start_browser',
103-
params: { browser: 'chrome', headless: false, windowWidth: 1920, windowHeight: 1080, navigationUrl: 'https://github.com/login' },
101+
tool: 'start_session',
102+
params: { platform: 'browser', browser: 'chrome', headless: false, windowWidth: 1920, windowHeight: 1080, navigationUrl: 'https://github.com/login' },
104103
status: 'ok',
105104
durationMs: 0,
106105
timestamp: '2026-01-01T00:00:00.000Z',
@@ -110,7 +109,7 @@ describe('generateCode - header', () => {
110109
expect(code).toContain("await browser.url('https://github.com/login');");
111110
});
112111

113-
it('generates start_app_session using history.appiumConfig for connection config', () => {
112+
it('generates start_session (mobile) using history.appiumConfig for connection config', () => {
114113
const history: SessionHistory = {
115114
sessionId: 'app-123',
116115
type: 'android',
@@ -123,15 +122,15 @@ describe('generateCode - header', () => {
123122
appiumConfig: { hostname: '127.0.0.1', port: 4723, path: '/' },
124123
steps: [{
125124
index: 1,
126-
tool: 'start_app_session',
127-
params: { platform: 'Android', deviceName: 'emulator-5554' }, // no appiumHost in params
125+
tool: 'start_session',
126+
params: { platform: 'android', deviceName: 'emulator-5554' },
128127
status: 'ok',
129128
durationMs: 100,
130129
timestamp: '2026-01-01T00:00:00.000Z',
131130
}],
132131
};
133132
const code = generateCode(history);
134-
expect(code).toContain('"hostname": "127.0.0.1"'); // from history.appiumConfig, not params fallback
133+
expect(code).toContain('"hostname": "127.0.0.1"');
135134
expect(code).toContain('"port": 4723');
136135
expect(code).toContain('"platformName": "Android"');
137136
});

tests/session/lifecycle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { closeSession, registerSession } from '../../src/session/lifecycle';
55
import type { SessionHistory } from '../../src/types/recording';
66

77
function makeBrowser(overrides: Record<string, unknown> = {}) {
8-
return { deleteSession: vi.fn(), ...overrides } as unknown as WebdriverIO.Browser;
8+
return { deleteSession: vi.fn().mockResolvedValue(undefined), ...overrides } as unknown as WebdriverIO.Browser;
99
}
1010

1111
beforeEach(() => {

tests/tools/close-session.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,27 @@ describe('close_session', () => {
4141
expect(mockDeleteSession).toHaveBeenCalledOnce();
4242
});
4343

44-
it('skips deleteSession when isAttached is true', async () => {
44+
it('calls deleteSession when isAttached is true (force-close)', async () => {
4545
setupSession('sess-attached', true);
4646
await callClose({});
47-
expect(mockDeleteSession).not.toHaveBeenCalled();
47+
expect(mockDeleteSession).toHaveBeenCalledOnce();
4848
});
4949

50-
it('returns "detached from" message when isAttached is true and detach is false', async () => {
50+
it('returns "closed" message when isAttached is true (force-close)', async () => {
5151
setupSession('sess-attached', true);
5252
const result = await callClose({});
53+
expect(result.content[0].text).toContain('closed');
54+
});
55+
56+
it('skips deleteSession when detach is explicitly true', async () => {
57+
setupSession('sess-attached', true);
58+
await callClose({ detach: true });
59+
expect(mockDeleteSession).not.toHaveBeenCalled();
60+
});
61+
62+
it('returns "detached from" message when detach is explicitly true', async () => {
63+
setupSession('sess-attached', true);
64+
const result = await callClose({ detach: true });
5365
expect(result.content[0].text).toContain('detached from');
5466
});
5567

0 commit comments

Comments
 (0)