Skip to content

Commit 6c41763

Browse files
committed
feat: implement session step recording, history lifecycle, and WebdriverIO code generation
- Added `RecordedStep` and `SessionHistory` types for session tracking. - Introduced `withRecording` HOF and `appendStep` for step logging. - Extended browser and app session management to initialize and maintain session histories, including transition sentinels and lifecycle markers. - Developed WebdriverIO JavaScript code generator from recorded steps. - Registered MCP resources for session indexing and step inspection. - Added corresponding tests and updated design documentation.
1 parent 204c5b8 commit 6c41763

11 files changed

Lines changed: 670 additions & 9 deletions

File tree

src/recording/code-generator.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// src/recording/code-generator.ts
2+
import type { RecordedStep, SessionHistory } from '../types/recording';
3+
4+
/** Escape single quotes so generated JS string literals are valid. */
5+
function escapeStr(value: unknown): string {
6+
return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
7+
}
8+
9+
function formatParams(params: Record<string, unknown>): string {
10+
return Object.entries(params)
11+
.map(([k, v]) => `${k}="${v}"`)
12+
.join(' ');
13+
}
14+
15+
function generateStep(step: RecordedStep): string {
16+
if (step.tool === '__session_transition__') {
17+
const newId = (step.params.newSessionId as string) ?? 'unknown';
18+
return `// --- new session: ${newId} started at ${step.timestamp} ---`;
19+
}
20+
21+
if (step.status === 'error') {
22+
return `// [error] ${step.tool}: ${formatParams(step.params)}${step.error ?? 'unknown error'}`;
23+
}
24+
25+
const p = step.params;
26+
switch (step.tool) {
27+
case 'navigate':
28+
return `await browser.url('${escapeStr(p.url)}');`;
29+
case 'click_element':
30+
return `await $('${escapeStr(p.selector)}').click();`;
31+
case 'set_value':
32+
return `await $('${escapeStr(p.selector)}').setValue('${escapeStr(p.value)}');`;
33+
case 'scroll': {
34+
const scrollAmount = (p.direction as string) === 'down' ? (p.pixels as number) : -(p.pixels as number);
35+
return `await browser.execute(() => window.scrollBy(0, ${scrollAmount}));`;
36+
}
37+
case 'tap_element':
38+
if (p.selector !== undefined) {
39+
return `await $('${escapeStr(p.selector)}').click();`;
40+
}
41+
return `await browser.tap({ x: ${p.x}, y: ${p.y} });`;
42+
case 'swipe':
43+
return `await browser.execute('mobile: swipe', { direction: '${escapeStr(p.direction)}' });`;
44+
case 'drag_and_drop':
45+
if (p.targetSelector !== undefined) {
46+
return `await $('${escapeStr(p.sourceSelector)}').dragAndDrop($('${escapeStr(p.targetSelector)}'));`;
47+
}
48+
return `await $('${escapeStr(p.sourceSelector)}').dragAndDrop({ x: ${p.x}, y: ${p.y} });`;
49+
default:
50+
return `// [unknown tool] ${step.tool}`;
51+
}
52+
}
53+
54+
function buildHeader(history: SessionHistory): string {
55+
const caps = JSON.stringify(history.capabilities, null, 2)
56+
.split('\n')
57+
.map((line) => ` ${line}`)
58+
.join('\n');
59+
return `import { remote } from 'webdriverio';\nconst browser = await remote(\n${caps}\n);`;
60+
}
61+
62+
export function generateCode(history: SessionHistory): string {
63+
const header = buildHeader(history);
64+
const steps = history.steps.map(generateStep).join('\n');
65+
return `${header}\n\n${steps}\n\nawait browser.deleteSession();`;
66+
}

src/recording/resources.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// src/recording/resources.ts
2+
import type { SessionHistory } from '../types/recording';
3+
import { generateCode } from './code-generator';
4+
import { getSessionHistory } from './step-recorder';
5+
import { getBrowser } from '../tools/browser.tool';
6+
7+
function getCurrentSessionId(): string | null {
8+
return (getBrowser as any).__state?.currentSession ?? null;
9+
}
10+
11+
export function buildSessionsIndex(): string {
12+
const histories = getSessionHistory();
13+
if (histories.size === 0) return 'No sessions recorded.';
14+
15+
const currentId = getCurrentSessionId();
16+
const lines = [`Sessions (${histories.size} total):\n`];
17+
for (const [id, h] of histories) {
18+
const ended = h.endedAt ?? '-';
19+
const current = id === currentId ? ' [current]' : '';
20+
lines.push(`- ${id} ${h.type} started: ${h.startedAt} ended: ${ended} ${h.steps.length} steps${current}`);
21+
}
22+
return lines.join('\n');
23+
}
24+
25+
export function buildCurrentSessionSteps(): string {
26+
const currentId = getCurrentSessionId();
27+
if (!currentId) return 'No active session.';
28+
return buildSessionStepsById(currentId);
29+
}
30+
31+
export function buildSessionStepsById(sessionId: string): string {
32+
const history = getSessionHistory().get(sessionId);
33+
if (!history) return `Session not found: ${sessionId}`;
34+
return formatSessionSteps(history);
35+
}
36+
37+
function formatSessionSteps(history: SessionHistory): string {
38+
const header = `Session: ${history.sessionId} (${history.type}) — ${history.steps.length} steps\n`;
39+
40+
const stepLines = history.steps.map((step) => {
41+
if (step.tool === '__session_transition__') {
42+
return `--- session transitioned to ${step.params.newSessionId ?? 'unknown'} at ${step.timestamp} ---`;
43+
}
44+
const statusLabel = step.status === 'ok' ? '[ok] ' : '[error]';
45+
const params = Object.entries(step.params)
46+
.map(([k, v]) => `${k}="${v}"`)
47+
.join(' ');
48+
const errorSuffix = step.error ? ` — ${step.error}` : '';
49+
return `${step.index}. ${statusLabel} ${step.tool.padEnd(24)} ${params}${errorSuffix} ${step.durationMs}ms`;
50+
});
51+
52+
const stepsText = stepLines.length > 0 ? stepLines.join('\n') : '(no steps yet)';
53+
const jsCode = generateCode(history);
54+
return `${header}\nSteps:\n${stepsText}\n\n--- Generated WebdriverIO JS ---\n${jsCode}`;
55+
}

src/recording/step-recorder.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// src/recording/step-recorder.ts
2+
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp';
3+
import type { RecordedStep, SessionHistory } from '../types/recording';
4+
import { getBrowser } from '../tools/browser.tool';
5+
6+
function getState() {
7+
return (getBrowser as any).__state as {
8+
currentSession: string | null;
9+
sessionHistory: Map<string, SessionHistory>;
10+
};
11+
}
12+
13+
export function appendStep(
14+
toolName: string,
15+
params: Record<string, unknown>,
16+
status: 'ok' | 'error',
17+
durationMs: number,
18+
error?: string,
19+
): void {
20+
const state = getState();
21+
const sessionId = state.currentSession;
22+
if (!sessionId) return;
23+
24+
const history = state.sessionHistory.get(sessionId);
25+
if (!history) return;
26+
27+
const step: RecordedStep = {
28+
index: history.steps.length + 1,
29+
tool: toolName,
30+
params,
31+
status,
32+
durationMs,
33+
timestamp: new Date().toISOString(),
34+
...(error !== undefined && { error }),
35+
};
36+
history.steps.push(step);
37+
}
38+
39+
export function getSessionHistory(): Map<string, SessionHistory> {
40+
return getState().sessionHistory;
41+
}
42+
43+
function extractErrorText(result: Awaited<ReturnType<ToolCallback>>): string {
44+
const textContent = result.content.find((c: any) => c.type === 'text');
45+
return textContent ? (textContent as any).text : 'Unknown error';
46+
}
47+
48+
export function withRecording(toolName: string, callback: ToolCallback): ToolCallback {
49+
return async (params, extra) => {
50+
const start = Date.now();
51+
const result = await callback(params, extra);
52+
const isError = result.content.some(
53+
(c: any) => c.type === 'text' && typeof (c as any).text === 'string' && (c as any).text.startsWith('Error'),
54+
);
55+
appendStep(
56+
toolName,
57+
params as Record<string, unknown>,
58+
isError ? 'error' : 'ok',
59+
Date.now() - start,
60+
isError ? extractErrorText(result) : undefined,
61+
);
62+
return result;
63+
};
64+
}

src/server.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ import { executeScriptTool, executeScriptToolDefinition } from './tools/execute-
5757
import { attachBrowserTool, attachBrowserToolDefinition } from './tools/attach-browser.tool';
5858
import { emulateDeviceTool, emulateDeviceToolDefinition } from './tools/emulate-device.tool';
5959
import pkg from '../package.json' with { type: 'json' };
60+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
61+
import { withRecording } from './recording/step-recorder';
62+
import { buildSessionsIndex, buildCurrentSessionSteps, buildSessionStepsById } from './recording/resources';
6063

6164
// IMPORTANT: Redirect all console output to stderr to avoid messing with MCP protocol (Chrome writes to console)
6265
const _originalConsoleLog = console.log;
@@ -79,6 +82,7 @@ const server = new McpServer({
7982
instructions: 'MCP server for browser and mobile app automation using WebDriverIO. Supports Chrome, Firefox, Edge, and Safari browser control plus iOS/Android native app testing via Appium.',
8083
capabilities: {
8184
tools: {},
85+
resources: {},
8286
},
8387
});
8488

@@ -95,18 +99,18 @@ registerTool(startAppToolDefinition, startAppTool);
9599
registerTool(closeSessionToolDefinition, closeSessionTool);
96100
registerTool(attachBrowserToolDefinition, attachBrowserTool);
97101
registerTool(emulateDeviceToolDefinition, emulateDeviceTool);
98-
registerTool(navigateToolDefinition, navigateTool);
102+
registerTool(navigateToolDefinition, withRecording('navigate', navigateTool));
99103

100104
// Element Discovery
101105
registerTool(getVisibleElementsToolDefinition, getVisibleElementsTool);
102106
registerTool(getAccessibilityToolDefinition, getAccessibilityTreeTool);
103107

104108
// Scrolling
105-
registerTool(scrollToolDefinition, scrollTool);
109+
registerTool(scrollToolDefinition, withRecording('scroll', scrollTool));
106110

107111
// Element Interaction
108-
registerTool(clickToolDefinition, clickTool);
109-
registerTool(setValueToolDefinition, setValueTool);
112+
registerTool(clickToolDefinition, withRecording('click_element', clickTool));
113+
registerTool(setValueToolDefinition, withRecording('set_value', setValueTool));
110114

111115
// Screenshots
112116
registerTool(takeScreenshotToolDefinition, takeScreenshotTool);
@@ -117,9 +121,9 @@ registerTool(setCookieToolDefinition, setCookieTool);
117121
registerTool(deleteCookiesToolDefinition, deleteCookiesTool);
118122

119123
// Mobile Gesture Tools
120-
registerTool(tapElementToolDefinition, tapElementTool);
121-
registerTool(swipeToolDefinition, swipeTool);
122-
registerTool(dragAndDropToolDefinition, dragAndDropTool);
124+
registerTool(tapElementToolDefinition, withRecording('tap_element', tapElementTool));
125+
registerTool(swipeToolDefinition, withRecording('swipe', swipeTool));
126+
registerTool(dragAndDropToolDefinition, withRecording('drag_and_drop', dragAndDropTool));
123127

124128
// App Lifecycle Management
125129
registerTool(getAppStateToolDefinition, getAppStateTool);
@@ -138,6 +142,38 @@ registerTool(setGeolocationToolDefinition, setGeolocationTool);
138142
// Script Execution (Browser JS / Appium Mobile Commands)
139143
registerTool(executeScriptToolDefinition, executeScriptTool);
140144

145+
// Session Recording Resources
146+
server.registerResource(
147+
'sessions',
148+
'wdio://sessions',
149+
{ description: 'Index of all browser and app sessions with step counts' },
150+
async () => ({
151+
contents: [{ uri: 'wdio://sessions', mimeType: 'text/plain', text: buildSessionsIndex() }],
152+
}),
153+
);
154+
155+
server.registerResource(
156+
'session-current-steps',
157+
'wdio://session/current/steps',
158+
{ description: 'Steps for the currently active session with generated WebdriverIO JS' },
159+
async () => ({
160+
contents: [{ uri: 'wdio://session/current/steps', mimeType: 'text/plain', text: buildCurrentSessionSteps() }],
161+
}),
162+
);
163+
164+
server.registerResource(
165+
'session-steps',
166+
new ResourceTemplate('wdio://session/{sessionId}/steps', { list: undefined }),
167+
{ description: 'Steps for a specific session by ID with generated WebdriverIO JS' },
168+
async (uri, { sessionId }) => ({
169+
contents: [{
170+
uri: uri.href,
171+
mimeType: 'text/plain',
172+
text: buildSessionStepsById(sessionId as string),
173+
}],
174+
}),
175+
);
176+
141177
async function main() {
142178
const transport = new StdioServerTransport();
143179
await server.connect(transport);

src/tools/app-session.tool.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { remote } from 'webdriverio';
22
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp';
33
import type { CallToolResult } from '@modelcontextprotocol/sdk/types';
44
import type { ToolDefinition } from '../types/tool';
5+
import type { SessionHistory } from '../types/recording';
56
import { z } from 'zod';
67
import { buildAndroidCapabilities, buildIOSCapabilities, getAppiumServerConfig, } from '../config/appium.config';
78
import { getBrowser } from './browser.tool';
@@ -43,6 +44,7 @@ export const getState = () => {
4344
browsers: Map<string, WebdriverIO.Browser>;
4445
currentSession: string | null;
4546
sessionMetadata: Map<string, { type: 'browser' | 'ios' | 'android'; capabilities: any; isAttached: boolean }>;
47+
sessionHistory: Map<string, SessionHistory>;
4648
};
4749
};
4850

@@ -156,13 +158,38 @@ export const startAppTool: ToolCallback = async (args: {
156158
const shouldAutoDetach = noReset === true || !appPath;
157159
const state = getState();
158160
state.browsers.set(sessionId, browser);
159-
state.currentSession = sessionId;
160161
state.sessionMetadata.set(sessionId, {
161162
type: platform.toLowerCase() as 'ios' | 'android',
162163
capabilities: mergedCapabilities,
163164
isAttached: shouldAutoDetach,
164165
});
165166

167+
// If replacing an active session, close its history with transition sentinel
168+
if (state.currentSession && state.currentSession !== sessionId) {
169+
const outgoing = state.sessionHistory.get(state.currentSession);
170+
if (outgoing) {
171+
outgoing.steps.push({
172+
index: outgoing.steps.length + 1,
173+
tool: '__session_transition__',
174+
params: { newSessionId: sessionId },
175+
status: 'ok',
176+
durationMs: 0,
177+
timestamp: new Date().toISOString(),
178+
});
179+
outgoing.endedAt = new Date().toISOString();
180+
}
181+
}
182+
183+
state.sessionHistory.set(sessionId, {
184+
sessionId,
185+
type: platform.toLowerCase() as 'ios' | 'android',
186+
startedAt: new Date().toISOString(),
187+
capabilities: mergedCapabilities as Record<string, unknown>,
188+
steps: [],
189+
});
190+
191+
state.currentSession = sessionId;
192+
166193
const appInfo = appPath ? `\nApp: ${appPath}` : '\nApp: (connected to running app)';
167194
const detachNote = shouldAutoDetach
168195
? '\n\n(Auto-detach enabled: session will be preserved on close. Use close_session({ detach: false }) to force terminate.)'

0 commit comments

Comments
 (0)