From fbc17e792c5e678a2c1774f1d603bd45c5dbd785 Mon Sep 17 00:00:00 2001 From: Vince Graics Date: Thu, 19 Mar 2026 13:40:55 +0100 Subject: [PATCH 1/2] feat: Add `launch_chrome` tool for launching Chrome with remote debugging - Introduced `launch_chrome` tool to simplify launching Chrome instances with remote debugging enabled - Supports "newInstance" and "freshSession" modes, with an option to copy profile files for use during debugging - Updated `attach_browser` to utilize the new `launch_chrome` tool for streamlined workflow - Enhanced session handling, cleanup logic, and active tab management during `attach_browser` operations - Updated corresponding tests and documentation --- src/server.ts | 34 ++++-- src/tools/attach-browser.tool.ts | 106 +++++++++++------ src/tools/launch-chrome.tool.ts | 147 ++++++++++++++++++++++++ tests/tools/attach-browser-tool.test.ts | 103 ++++++++++++++--- 4 files changed, 329 insertions(+), 61 deletions(-) create mode 100644 src/tools/launch-chrome.tool.ts diff --git a/src/server.ts b/src/server.ts index 29a34ed..60612b6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import pkg from '../package.json' with { type: 'json' }; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { ToolDefinition } from './types/tool'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; @@ -55,11 +55,10 @@ import { } from './tools/device.tool'; import { executeScriptTool, executeScriptToolDefinition } from './tools/execute-script.tool'; import { attachBrowserTool, attachBrowserToolDefinition } from './tools/attach-browser.tool'; +import { launchChromeTool, launchChromeToolDefinition } from './tools/launch-chrome.tool'; import { emulateDeviceTool, emulateDeviceToolDefinition } from './tools/emulate-device.tool'; -import pkg from '../package.json' with { type: 'json' }; -import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { withRecording } from './recording/step-recorder'; -import { buildSessionsIndex, buildCurrentSessionSteps, buildSessionStepsById } from './recording/resources'; +import { buildCurrentSessionSteps, buildSessionsIndex, buildSessionStepsById } from './recording/resources'; // IMPORTANT: Redirect all console output to stderr to avoid messing with MCP protocol (Chrome writes to console) const _originalConsoleLog = console.log; @@ -97,6 +96,7 @@ const registerTool = (definition: ToolDefinition, callback: ToolCallback) => registerTool(startBrowserToolDefinition, withRecording('start_browser', startBrowserTool)); registerTool(startAppToolDefinition, withRecording('start_app_session', startAppTool)); registerTool(closeSessionToolDefinition, closeSessionTool); +registerTool(launchChromeToolDefinition, withRecording('launch_chrome', launchChromeTool)); registerTool(attachBrowserToolDefinition, withRecording('attach_browser', attachBrowserTool)); registerTool(emulateDeviceToolDefinition, emulateDeviceTool); registerTool(navigateToolDefinition, withRecording('navigate', navigateTool)); @@ -159,7 +159,11 @@ server.registerResource( async () => { const payload = buildCurrentSessionSteps(); return { - contents: [{ uri: 'wdio://session/current/steps', mimeType: 'application/json', text: payload?.stepsJson ?? '{"error":"No active session"}' }], + contents: [{ + uri: 'wdio://session/current/steps', + mimeType: 'application/json', + text: payload?.stepsJson ?? '{"error":"No active session"}' + }], }; }, ); @@ -171,7 +175,11 @@ server.registerResource( async () => { const payload = buildCurrentSessionSteps(); return { - contents: [{ uri: 'wdio://session/current/code', mimeType: 'text/plain', text: payload?.generatedJs ?? '// No active session' }], + contents: [{ + uri: 'wdio://session/current/code', + mimeType: 'text/plain', + text: payload?.generatedJs ?? '// No active session' + }], }; }, ); @@ -183,7 +191,11 @@ server.registerResource( async (uri, { sessionId }) => { const payload = buildSessionStepsById(sessionId as string); return { - contents: [{ uri: uri.href, mimeType: 'application/json', text: payload?.stepsJson ?? `{"error":"Session not found: ${sessionId}"}` }], + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: payload?.stepsJson ?? `{"error":"Session not found: ${sessionId}"}` + }], }; }, ); @@ -195,7 +207,11 @@ server.registerResource( async (uri, { sessionId }) => { const payload = buildSessionStepsById(sessionId as string); return { - contents: [{ uri: uri.href, mimeType: 'text/plain', text: payload?.generatedJs ?? `// Session not found: ${sessionId}` }], + contents: [{ + uri: uri.href, + mimeType: 'text/plain', + text: payload?.generatedJs ?? `// Session not found: ${sessionId}` + }], }; }, ); diff --git a/src/tools/attach-browser.tool.ts b/src/tools/attach-browser.tool.ts index e299871..e576b05 100644 --- a/src/tools/attach-browser.tool.ts +++ b/src/tools/attach-browser.tool.ts @@ -9,67 +9,106 @@ export const attachBrowserToolDefinition: ToolDefinition = { name: 'attach_browser', description: `Attach to a Chrome instance already running with --remote-debugging-port. -Start Chrome first (quit any running Chrome instance before launching): - - macOS — with real profile (preserves extensions, cookies, logins): - pkill -x "Google Chrome" && sleep 1 - /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir="$HOME/Library/Application Support/Google/Chrome" --profile-directory=Default & - - macOS — with fresh profile (lightweight, no extensions): - pkill -x "Google Chrome" && sleep 1 - /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug & - - Linux — with real profile: - google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/google-chrome" --profile-directory=Default & - - Linux — with fresh profile: - google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug & - -Verify Chrome is ready: curl http://localhost:9222/json/version - -Then call attach_browser() to hand control to the AI. All other tools (navigate, click, get_visible_elements, etc.) will work on the attached session. Use close_session() to detach without closing Chrome.`, +Use launch_chrome() first to prepare and launch Chrome with remote debugging enabled.`, inputSchema: { port: z.number().default(9222).describe('Chrome remote debugging port (default: 9222)'), host: z.string().default('localhost').describe('Host where Chrome is running (default: localhost)'), - userDataDir: z.string().default('/tmp/chrome-debug').describe('Chrome user data directory — must match the --user-data-dir used when launching Chrome. Use your real profile path (e.g. "$HOME/Library/Application Support/Google/Chrome") to preserve extensions and logins, or /tmp/chrome-debug for a fresh profile (default: /tmp/chrome-debug)'), navigationUrl: z.string().optional().describe('URL to navigate to immediately after attaching'), }, }; -async function getActiveTabUrl(host: string, port: number): Promise { +type TabSnapshot = { activeTabUrl: string | undefined; allTabUrls: string[] }; + +// ChromeDriver injects a BiDi-CDP Mapper page when creating a session. If the previous session +// was detached without proper cleanup, this target remains and causes "unexpected alert open" on +// the next attach attempt. Close any stale mappers before creating a new session. +// Returns the active tab URL (first real page tab) and all page tab URLs — Chrome lists the +// active/focused tab first in /json. +async function closeStaleMappers(host: string, port: number): Promise { try { const res = await fetch(`http://${host}:${port}/json`); - const tabs = await res.json() as { type: string; url: string }[]; - const page = tabs.find((t) => t.type === 'page' && t.url && !t.url.startsWith('devtools://')); - return page?.url ?? null; + const targets = await res.json() as { id: string; title: string; type: string; url: string }[]; + const mappers = targets.filter((t) => t.title?.includes('BiDi')); + await Promise.all(mappers.map((t) => fetch(`http://${host}:${port}/json/close/${t.id}`))); + const pages = targets.filter((t) => t.type === 'page' && !t.title?.includes('BiDi')); + return { activeTabUrl: pages[0]?.url, allTabUrls: pages.map((t) => t.url) }; } catch { - return null; + return { activeTabUrl: undefined, allTabUrls: [] }; + } +} + +// After CDP session init, Chrome blanks the first tab it takes over. This restores any tabs +// that became about:blank and then switches focus to the originally active tab. +async function restoreAndSwitchToActiveTab( + browser: WebdriverIO.Browser, + activeTabUrl: string, + allTabUrls: string[], +): Promise { + const handles = await browser.getWindowHandles(); + const currentUrls: string[] = []; + for (const handle of handles) { + await browser.switchToWindow(handle); + currentUrls.push(await browser.getUrl()); + } + + // Restore blank tabs that had a known URL before attaching. + const missingUrls = allTabUrls.filter((u) => !currentUrls.includes(u)); + let missingIdx = 0; + for (let i = 0; i < handles.length; i++) { + if (currentUrls[i] === 'about:blank' && missingIdx < missingUrls.length) { + await browser.switchToWindow(handles[i]); + await browser.url(missingUrls[missingIdx]); + currentUrls[i] = missingUrls[missingIdx++]; + } + } + + // Switch to the originally active tab. + for (let i = 0; i < handles.length; i++) { + if (currentUrls[i] === activeTabUrl) { + await browser.switchToWindow(handles[i]); + break; + } + } +} + +async function waitForCDP(host: string, port: number, timeoutMs = 10000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://${host}:${port}/json/version`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 300)); } + throw new Error(`Chrome did not expose CDP on ${host}:${port} within ${timeoutMs}ms`); } export const attachBrowserTool: ToolCallback = async ({ port = 9222, host = 'localhost', - userDataDir = '/tmp/chrome-debug', navigationUrl, }: { port?: number; host?: string; - userDataDir?: string; navigationUrl?: string; }): Promise => { try { const state = (getBrowser as any).__state; - // Capture the active tab URL before WebDriver blanks it - const activeUrl = navigationUrl ?? await getActiveTabUrl(host, port); + await waitForCDP(host, port); + const { activeTabUrl, allTabUrls } = await closeStaleMappers(host, port); const browser = await remote({ + connectionRetryTimeout: 30000, + connectionRetryCount: 3, capabilities: { browserName: 'chrome', + unhandledPromptBehavior: 'dismiss', + webSocketUrl: false, 'goog:chromeOptions': { debuggerAddress: `${host}:${port}`, - args: [`--user-data-dir=${userDataDir}`], }, }, }); @@ -90,14 +129,15 @@ export const attachBrowserTool: ToolCallback = async ({ browserName: 'chrome', 'goog:chromeOptions': { debuggerAddress: `${host}:${port}`, - args: [`--user-data-dir=${userDataDir}`], }, }, steps: [], }); - if (activeUrl) { - await browser.url(activeUrl); + if (navigationUrl) { + await browser.url(navigationUrl); + } else if (activeTabUrl) { + await restoreAndSwitchToActiveTab(browser, activeTabUrl, allTabUrls); } const title = await browser.getTitle(); diff --git a/src/tools/launch-chrome.tool.ts b/src/tools/launch-chrome.tool.ts new file mode 100644 index 0000000..b0e2fcc --- /dev/null +++ b/src/tools/launch-chrome.tool.ts @@ -0,0 +1,147 @@ +import { spawn } from 'node:child_process'; +import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, platform, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import type { ToolDefinition } from '../types/tool'; +import { z } from 'zod'; + +const USER_DATA_DIR = join(tmpdir(), 'chrome-debug'); + +export const launchChromeToolDefinition: ToolDefinition = { + name: 'launch_chrome', + description: `Prepares and launches Chrome with remote debugging enabled so attach_browser() can connect. + +Two modes: + + newInstance (default): Opens a Chrome window alongside your existing one using a separate + profile dir. Your current Chrome session is untouched. + + freshSession: Launches Chrome with an empty profile (no cookies, no logins). + +Use copyProfileFiles: true to carry over your cookies and logins into the debug session. +Note: changes made during the session won't sync back to your main profile. + +After this tool succeeds, call attach_browser(prepareBrowserProfile: false) to connect.`, + inputSchema: { + port: z.number().default(9222).describe('Remote debugging port (default: 9222)'), + mode: z.enum(['newInstance', 'freshSession']).default('newInstance').describe( + 'newInstance: open alongside existing Chrome | freshSession: clean profile' + ), + copyProfileFiles: z.boolean().default(false).describe( + 'Copy your Default Chrome profile (cookies, logins) into the debug session.' + ), + }, +}; + +function isMac(): boolean { + return platform() === 'darwin'; +} + +function chromeExec(): string { + if (isMac()) return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + if (platform() === 'win32') { + const candidates = [ + join('C:', 'Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), + join('C:', 'Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), + ]; + return candidates.find((p) => existsSync(p)) ?? candidates[0]; + } + return 'google-chrome'; +} + +function defaultProfileDir(): string { + const home = homedir(); + if (isMac()) return join(home, 'Library', 'Application Support', 'Google', 'Chrome'); + if (platform() === 'win32') return join(home, 'AppData', 'Local', 'Google', 'Chrome', 'User Data'); + return join(home, '.config', 'google-chrome'); +} + +function copyProfile(): void { + const srcDir = defaultProfileDir(); + rmSync(USER_DATA_DIR, { recursive: true, force: true }); + mkdirSync(USER_DATA_DIR, { recursive: true }); + copyFileSync(join(srcDir, 'Local State'), join(USER_DATA_DIR, 'Local State')); + cpSync(join(srcDir, 'Default'), join(USER_DATA_DIR, 'Default'), { recursive: true }); + + // Remove singleton/lock files from the source Chrome instance. + for (const f of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) { + rmSync(join(USER_DATA_DIR, f), { force: true }); + } + + // Remove session files — they reference the original profile's state and trigger + // "Something went wrong when opening your profile" when Chrome opens the copy. + for (const f of ['Current Session', 'Current Tabs', 'Last Session', 'Last Tabs']) { + rmSync(join(USER_DATA_DIR, 'Default', f), { force: true }); + } + + // First Run sentinel tells Chrome this is a fresh start — suppresses first-run dialogs. + writeFileSync(join(USER_DATA_DIR, 'First Run'), ''); +} + +function launchChrome(port: number): void { + spawn(chromeExec(), [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${USER_DATA_DIR}`, + '--profile-directory=Default', + '--no-first-run', + '--disable-session-crashed-bubble', + ], { detached: true, stdio: 'ignore' }).unref(); +} + +async function waitForCDP(port: number, timeoutMs = 15000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://localhost:${port}/json/version`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`Chrome did not expose CDP on port ${port} within ${timeoutMs}ms`); +} + +export const launchChromeTool: ToolCallback = async ({ + port = 9222, + mode = 'newInstance', + copyProfileFiles = false, +}: { + port?: number; + mode?: 'newInstance' | 'freshSession'; + copyProfileFiles?: boolean; +}): Promise => { + const warnings: string[] = []; + const notes: string[] = []; + + try { + if (copyProfileFiles) { + warnings.push('⚠️ Cookies and logins were copied at this moment. Changes during this session won\'t sync back to your main profile.'); + copyProfile(); + } else { + notes.push(mode === 'newInstance' + ? 'No profile copied — this instance starts with no cookies or logins.' + : 'Fresh profile — no existing cookies or logins.'); + rmSync(USER_DATA_DIR, { recursive: true, force: true }); + mkdirSync(USER_DATA_DIR, { recursive: true }); + } + + launchChrome(port); + await waitForCDP(port); + + const lines = [ + `Chrome launched on port ${port} (mode: ${mode}).`, + ...warnings, + ...notes, + ]; + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } catch (e) { + return { + isError: true, + content: [{ type: 'text', text: `Error launching Chrome: ${e}` }], + }; + } +}; \ No newline at end of file diff --git a/tests/tools/attach-browser-tool.test.ts b/tests/tools/attach-browser-tool.test.ts index 5783a9f..71f63a7 100644 --- a/tests/tools/attach-browser-tool.test.ts +++ b/tests/tools/attach-browser-tool.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -// Stub fetch so getActiveTabUrl doesn't make real network requests +// Stub fetch so getActiveTabUrl / closeStaleMappers / waitForCDP don't make real network requests vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, json: vi.fn().mockResolvedValue([{ type: 'page', url: 'https://example.com' }]), })); @@ -11,6 +12,8 @@ const mockBrowser = vi.hoisted(() => ({ getTitle: vi.fn().mockResolvedValue('My App'), url: vi.fn().mockResolvedValue(undefined), getUrl: vi.fn().mockResolvedValue('https://example.com'), + getWindowHandles: vi.fn().mockResolvedValue(['handle-1']), + switchToWindow: vi.fn().mockResolvedValue(undefined), })); vi.mock('webdriverio', () => ({ @@ -38,7 +41,8 @@ import { getBrowser } from '../../src/tools/browser.tool'; import { attachBrowserTool } from '../../src/tools/attach-browser.tool'; type ToolFn = (args: Record) => Promise<{ content: { text: string }[] }>; -const callTool = attachBrowserTool as unknown as ToolFn; +const callTool = (args: Record = {}) => + (attachBrowserTool as unknown as ToolFn)(args); const mockRemote = remote as ReturnType; @@ -50,39 +54,37 @@ beforeEach(() => { state.sessionHistory.clear(); state.currentSession = null; mockRemote.mockResolvedValue(mockBrowser); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([{ type: 'page', url: 'https://example.com' }]), + })); }); describe('attach_browser', () => { it('calls remote() with debuggerAddress using default port 9222', async () => { - await callTool({}); + await callTool(); expect(mockRemote).toHaveBeenCalledWith({ + connectionRetryTimeout: 30000, + connectionRetryCount: 3, capabilities: expect.objectContaining({ browserName: 'chrome', - 'goog:chromeOptions': { debuggerAddress: 'localhost:9222', args: ['--user-data-dir=/tmp/chrome-debug'] }, + unhandledPromptBehavior: 'dismiss', + 'goog:chromeOptions': { debuggerAddress: 'localhost:9222' }, }), }); }); it('uses provided host and port', async () => { await callTool({ host: '192.168.1.1', port: 9333 }); - expect(mockRemote).toHaveBeenCalledWith({ + expect(mockRemote).toHaveBeenCalledWith(expect.objectContaining({ capabilities: expect.objectContaining({ 'goog:chromeOptions': expect.objectContaining({ debuggerAddress: '192.168.1.1:9333' }), }), - }); - }); - - it('uses provided userDataDir', async () => { - await callTool({ userDataDir: '/custom/profile' }); - expect(mockRemote).toHaveBeenCalledWith({ - capabilities: expect.objectContaining({ - 'goog:chromeOptions': expect.objectContaining({ args: ['--user-data-dir=/custom/profile'] }), - }), - }); + })); }); it('registers session in state with isAttached: true', async () => { - await callTool({}); + await callTool(); const state = (getBrowser as any).__state; expect(state.currentSession).toBe('attached-session-id'); expect(state.sessionMetadata.get('attached-session-id')).toMatchObject({ @@ -92,7 +94,7 @@ describe('attach_browser', () => { }); it('returns session id, title and url', async () => { - const result = await callTool({}); + const result = await callTool(); const text = result.content[0].text; expect(text).toContain('attached-session-id'); expect(text).toContain('My App'); @@ -102,17 +104,80 @@ describe('attach_browser', () => { it('navigates to navigationUrl if provided', async () => { await callTool({ navigationUrl: 'https://app.example.com' }); expect(mockBrowser.url).toHaveBeenCalledWith('https://app.example.com'); + expect(mockBrowser.switchToWindow).not.toHaveBeenCalled(); + }); + + it('switches to the active tab (from /json) when no navigationUrl', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { type: 'page', url: 'https://active.example.com', title: 'Active', id: 't1' }, + { type: 'page', url: 'https://other.example.com', title: 'Other', id: 't2' }, + ]), + })); + mockBrowser.getWindowHandles.mockResolvedValue(['handle-1', 'handle-2']); + mockBrowser.getUrl + .mockResolvedValueOnce('https://other.example.com') // handle-1 + .mockResolvedValueOnce('https://active.example.com') // handle-2 — match + .mockResolvedValue('https://active.example.com'); // subsequent calls + + await callTool(); + + expect(mockBrowser.switchToWindow).toHaveBeenCalledWith('handle-1'); + expect(mockBrowser.switchToWindow).toHaveBeenCalledWith('handle-2'); + expect(mockBrowser.url).not.toHaveBeenCalled(); + }); + + it('restores single blanked tab when remote() blanks it during session init', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { type: 'page', url: 'https://active.example.com', title: 'Active', id: 't1' }, + ]), + })); + mockBrowser.getWindowHandles.mockResolvedValue(['handle-1']); + mockBrowser.getUrl + .mockResolvedValueOnce('about:blank') // scan: handle-1 blanked + .mockResolvedValue('https://active.example.com'); // final getUrl for result + + await callTool(); + + expect(mockBrowser.url).toHaveBeenCalledWith('https://active.example.com'); + expect(mockBrowser.switchToWindow).toHaveBeenCalledWith('handle-1'); + }); + + it('restores blanked first tab and switches to it when multiple tabs exist', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { type: 'page', url: 'https://active.example.com', title: 'Active', id: 't1' }, + { type: 'page', url: 'https://other.example.com', title: 'Other', id: 't2' }, + ]), + })); + mockBrowser.getWindowHandles.mockResolvedValue(['handle-1', 'handle-2']); + mockBrowser.getUrl + .mockResolvedValueOnce('about:blank') // scan: handle-1 blanked by CDP + .mockResolvedValueOnce('https://other.example.com') // scan: handle-2 intact + .mockResolvedValue('https://active.example.com'); // final getUrl for result + + await callTool(); + + // Restores handle-1 to the active URL + expect(mockBrowser.url).toHaveBeenCalledWith('https://active.example.com'); + // Switches to handle-1 (originally active tab, now restored) + const calls = mockBrowser.switchToWindow.mock.calls.map((c: unknown[]) => c[0]); + expect(calls[calls.length - 1]).toBe('handle-1'); }); it('initialises sessionHistory with constructed caps and empty steps', async () => { - await callTool({ host: 'myhost', port: 9333, userDataDir: '/my/profile' }); + await callTool({ host: 'myhost', port: 9333 }); const state = (getBrowser as any).__state; const history = state.sessionHistory.get('attached-session-id'); expect(history).toBeDefined(); expect(history.steps).toEqual([]); expect(history.capabilities).toMatchObject({ browserName: 'chrome', - 'goog:chromeOptions': { debuggerAddress: 'myhost:9333', args: ['--user-data-dir=/my/profile'] }, + 'goog:chromeOptions': { debuggerAddress: 'myhost:9333' }, }); }); From 51461948338564929a5fe40378680a6998d883c8 Mon Sep 17 00:00:00 2001 From: Vince Graics Date: Thu, 19 Mar 2026 16:28:30 +0100 Subject: [PATCH 2/2] feat!: Initial commit for v3.0.0 - Introduced the `execute_sequence` tool for executing sequences of actions atomically, with optional stability waits and state delta generation. - Added `registerSession` and `closeSession` for enhanced session lifecycle management and tracking. - Implemented Local Appium and Browser providers with capability handling, session type determination, and auto-detach options. - Added CI linting workflow and new tests for tools and providers for improved quality assurance. BREAKING CHANGE: Deleted tools --- .github/workflows/lint.yml | 31 +++ CLAUDE.md | 66 +++--- docs/architecture/mcp-resources-notes.md | 12 + src/providers/local-appium.provider.ts | 101 ++++++++ src/providers/local-browser.provider.ts | 118 ++++++++++ src/providers/types.ts | 14 ++ src/recording/resources.ts | 4 +- src/recording/step-recorder.ts | 9 +- src/server.ts | 160 ++++++++++--- src/session/lifecycle.ts | 57 +++++ src/session/state.ts | 26 +++ src/tools/app-actions.tool.ts | 36 +-- src/tools/app-session.tool.ts | 123 ++-------- src/tools/attach-browser.tool.ts | 6 +- src/tools/browser.tool.ts | 217 ++++++------------ src/tools/click.tool.ts | 4 +- src/tools/context.tool.ts | 55 +---- src/tools/cookies.tool.ts | 40 +--- src/tools/device.tool.ts | 41 +--- src/tools/emulate-device.tool.ts | 11 +- src/tools/execute-script.tool.ts | 2 +- src/tools/execute-sequence.tool.ts | 205 +++++++++++++++++ src/tools/gestures.tool.ts | 30 ++- src/tools/get-accessibility-tree.tool.ts | 46 +--- src/tools/get-visible-elements.tool.ts | 56 +---- src/tools/navigate.tool.ts | 7 +- src/tools/scroll.tool.ts | 11 +- src/tools/set-value.tool.ts | 24 +- src/tools/take-screenshot.tool.ts | 42 +--- src/utils/parse-variables.ts | 19 ++ src/utils/stability-detector.ts | 47 ++++ src/utils/state-diff.ts | 46 ++++ tests/providers/local-appium.provider.test.ts | 39 ++++ .../providers/local-browser.provider.test.ts | 38 +++ tests/recording/resources.test.ts | 8 +- tests/recording/step-recorder.test.ts | 8 +- tests/session/lifecycle.test.ts | 106 +++++++++ tests/tools/accessibility-tree-tool.test.ts | 42 ++-- tests/tools/attach-browser-tool.test.ts | 49 ++-- tests/tools/close-session.test.ts | 17 +- tests/tools/emulate-device-tool.test.ts | 38 +-- tests/tools/execute-sequence.test.ts | 171 ++++++++++++++ tests/tools/switch-tab.test.ts | 88 +++++++ tests/utils/stability-detector.test.ts | 43 ++++ tests/utils/state-diff.test.ts | 63 +++++ 45 files changed, 1695 insertions(+), 681 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 docs/architecture/mcp-resources-notes.md create mode 100644 src/providers/local-appium.provider.ts create mode 100644 src/providers/local-browser.provider.ts create mode 100644 src/providers/types.ts create mode 100644 src/session/lifecycle.ts create mode 100644 src/session/state.ts create mode 100644 src/tools/execute-sequence.tool.ts create mode 100644 src/utils/parse-variables.ts create mode 100644 src/utils/stability-detector.ts create mode 100644 src/utils/state-diff.ts create mode 100644 tests/providers/local-appium.provider.test.ts create mode 100644 tests/providers/local-browser.provider.test.ts create mode 100644 tests/session/lifecycle.test.ts create mode 100644 tests/tools/execute-sequence.test.ts create mode 100644 tests/tools/switch-tab.test.ts create mode 100644 tests/utils/stability-detector.test.ts create mode 100644 tests/utils/state-diff.test.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e5ab6cb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: Lint + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Setup pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + + - name: Setup Node.js + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install + + - name: Lint & Type Check + run: pnpm run lint diff --git a/CLAUDE.md b/CLAUDE.md index 7623396..462b36c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,12 +16,22 @@ npm start # Run built server from lib/server.js ``` src/ ├── server.ts # MCP server entry, registers all tools + MCP resources +├── session/ +│ ├── state.ts # Session state maps, getBrowser(), getState(), SessionMetadata +│ └── lifecycle.ts # registerSession(), handleSessionTransition(), closeSession() +├── providers/ +│ ├── types.ts # SessionProvider interface, ConnectionConfig +│ ├── local-browser.provider.ts # Chrome/Firefox/Edge/Safari capability building +│ └── local-appium.provider.ts # iOS/Android via appium.config.ts ├── tools/ -│ ├── browser.tool.ts # Session state + start_browser + getBrowser() +│ ├── browser.tool.ts # start_browser, close_session, readTabs(), switch_tab │ ├── app-session.tool.ts # start_app_session (iOS/Android via Appium) -│ ├── navigate.tool.ts # URL navigation -│ ├── get-visible-elements.tool.ts # Element detection (web + mobile) -│ ├── click.tool.ts # Click/tap actions +│ ├── navigate.tool.ts # navigateAction() + navigateTool +│ ├── click.tool.ts # clickAction() + clickTool +│ ├── set-value.tool.ts # setValueAction() + setValueTool +│ ├── scroll.tool.ts # scrollAction() + scrollTool +│ ├── gestures.tool.ts # tapAction(), swipeAction(), dragAndDropAction() +│ ├── execute-sequence.tool.ts # Batch action sequencing with stability + state delta │ └── ... # Other tools follow same pattern ├── recording/ │ ├── step-recorder.ts # withRecording HOF, appendStep, session history access @@ -34,7 +44,11 @@ src/ │ ├── generate-all-locators.ts # Multi-strategy selector generation │ └── source-parsing.ts # XML page source parsing for mobile ├── config/ -│ └── appium.config.ts # iOS/Android capability builders +│ └── appium.config.ts # iOS/Android capability builders (used by local-appium.provider) +├── utils/ +│ ├── parse-variables.ts # URI template variable parsing (parseBool, parseNumber, etc.) +│ ├── stability-detector.ts # Page stability polling (signature-based, 200ms/500ms/5s) +│ └── state-diff.ts # Element before/after diff (appeared, disappeared, changed) └── types/ ├── tool.ts # ToolDefinition interface └── recording.ts # RecordedStep, SessionHistory interfaces @@ -42,19 +56,19 @@ src/ ### Session State -Single active session model in `browser.tool.ts`: +Single active session model in `src/session/state.ts`: ```typescript -const browsers: Map = new Map(); -let currentSession: string | null = null; -const sessionMetadata: Map = new Map(); - -export function getBrowser(): WebdriverIO.Browser { - // Returns current active session or throws -} +// Private state — access via getState() or getBrowser() +export function getBrowser(): WebdriverIO.Browser { ... } +export function getState() { return state; } +export interface SessionMetadata { type: 'browser' | 'ios' | 'android'; capabilities: Record; isAttached: boolean; } ``` -State shared with `app-session.tool.ts` via `(getBrowser as any).__state`. +Session lifecycle managed via `src/session/lifecycle.ts`: +- `registerSession()` — registers browser + metadata + history, handles transition sentinel +- `handleSessionTransition()` — appends `__session_transition__` step to outgoing session +- `closeSession()` — terminates or detaches, marks endedAt, cleans up maps ### Tool Pattern @@ -103,14 +117,21 @@ MCP resources expose history without tool calls: | File | Purpose | |----------------------------------------------------|-----------------------------------------------| -| `src/server.ts` | MCP server init, tool registration | -| `src/tools/browser.tool.ts` | Session state management, `getBrowser()` | +| `src/server.ts` | MCP server init, tool + resource registration | +| `src/session/state.ts` | Session state maps, `getBrowser()`, `getState()` | +| `src/session/lifecycle.ts` | `registerSession()`, `closeSession()`, session transitions | +| `src/tools/browser.tool.ts` | `start_browser`, `close_session`, `switch_tab`, `readTabs()` | | `src/tools/app-session.tool.ts` | Appium session creation | +| `src/tools/execute-sequence.tool.ts` | Batch action sequencing with stability + delta | +| `src/providers/local-browser.provider.ts` | Chrome/Firefox/Edge/Safari capability building | +| `src/providers/local-appium.provider.ts` | iOS/Android capabilities via appium.config.ts | | `src/scripts/get-interactable-browser-elements.ts` | Browser-context element detection | | `src/locators/` | Mobile element detection + locator generation | | `src/recording/step-recorder.ts` | `withRecording(toolName, cb)` HOF — wraps every tool for step logging | | `src/recording/code-generator.ts` | Generates runnable WebdriverIO JS from `SessionHistory` | | `src/recording/resources.ts` | Builds text for `wdio://sessions` and `wdio://session/*/steps` resources | +| `src/utils/stability-detector.ts` | Page stability detection (signature polling) | +| `src/utils/state-diff.ts` | Element state diff (appeared/disappeared/changed) | | `tsup.config.ts` | Build configuration | ## Gotchas @@ -129,16 +150,6 @@ console.log = (...args) => process.stderr.write(util.format(...args) + '\n'); `get-interactable-browser-elements.ts` executes in browser context via `browser.execute()`. Cannot use Node.js APIs or external imports. -### Mobile State Sharing Hack - -`app-session.tool.ts` accesses browser.tool.ts state via: - -```typescript -const state = (getBrowser as any).__state; -``` - -This maintains single-session behavior across browser and mobile. - ### Auto-Detach Behavior Sessions created with `noReset: true` or without `appPath` automatically detach on close (don't terminate on Appium @@ -179,6 +190,5 @@ catch (e) { See `docs/architecture/` for proposals: -- `session-configuration-proposal.md` — Cloud provider pattern (BrowserStack, SauceLabs) -- `interaction-sequencing-proposal.md` — Batch actions with state delta detection +- `session-configuration-proposal.md` — Cloud provider pattern (BrowserStack, SauceLabs) — providers/types.ts is the extension point - `multi-session-proposal.md` — Parallel sessions for sub-agent coordination \ No newline at end of file diff --git a/docs/architecture/mcp-resources-notes.md b/docs/architecture/mcp-resources-notes.md new file mode 100644 index 0000000..186ace8 --- /dev/null +++ b/docs/architecture/mcp-resources-notes.md @@ -0,0 +1,12 @@ +# MCP Resources — Notes + +## Template resources are not discoverable via ListResources + +The `session-steps` resource uses a URI template (`wdio://session/{sessionId}/steps`) and does +not appear in `ListMcpResourcesTool` output. Only fixed-URI resources (`wdio://sessions`, +`wdio://session/current/steps`) are listed. + +Template resources must be read directly by constructing the URI — clients cannot discover them +through the standard list call. If client discoverability matters, consider documenting the +template pattern in the fixed `wdio://sessions` index response, or exposing a separate resource +that advertises available URI templates. diff --git a/src/providers/local-appium.provider.ts b/src/providers/local-appium.provider.ts new file mode 100644 index 0000000..d4ce07a --- /dev/null +++ b/src/providers/local-appium.provider.ts @@ -0,0 +1,101 @@ +import type { SessionProvider, ConnectionConfig } from './types'; +import { buildIOSCapabilities, buildAndroidCapabilities, getAppiumServerConfig } from '../config/appium.config'; + +export type LocalAppiumOptions = { + platform: 'iOS' | 'Android'; + appPath?: string; + deviceName: string; + platformVersion?: string; + automationName?: string; + appiumHost?: string; + appiumPort?: number; + appiumPath?: string; + autoGrantPermissions?: boolean; + autoAcceptAlerts?: boolean; + autoDismissAlerts?: boolean; + appWaitActivity?: string; + udid?: string; + noReset?: boolean; + fullReset?: boolean; + newCommandTimeout?: number; + capabilities?: Record; +}; + +export class LocalAppiumProvider implements SessionProvider { + name = 'local-appium'; + + getConnectionConfig(options: Record): ConnectionConfig { + const config = getAppiumServerConfig({ + hostname: options.appiumHost as string | undefined, + port: options.appiumPort as number | undefined, + path: options.appiumPath as string | undefined, + }); + return { protocol: 'http', ...config }; + } + + buildCapabilities(options: Record): Record { + const platform = options.platform as string; + const appPath = options.appPath as string | undefined; + const deviceName = options.deviceName as string; + const platformVersion = options.platformVersion as string | undefined; + const autoGrantPermissions = options.autoGrantPermissions as boolean | undefined; + const autoAcceptAlerts = options.autoAcceptAlerts as boolean | undefined; + const autoDismissAlerts = options.autoDismissAlerts as boolean | undefined; + const udid = options.udid as string | undefined; + const noReset = options.noReset as boolean | undefined; + const fullReset = options.fullReset as boolean | undefined; + const newCommandTimeout = options.newCommandTimeout as number | undefined; + const appWaitActivity = options.appWaitActivity as string | undefined; + const userCapabilities = (options.capabilities as Record | undefined) ?? {}; + + const capabilities: Record = platform === 'iOS' + ? buildIOSCapabilities(appPath, { + deviceName, + platformVersion, + automationName: (options.automationName as 'XCUITest') || 'XCUITest', + autoGrantPermissions, + autoAcceptAlerts, + autoDismissAlerts, + udid, + noReset, + fullReset, + newCommandTimeout, + }) + : buildAndroidCapabilities(appPath, { + deviceName, + platformVersion, + automationName: (options.automationName as 'UiAutomator2' | 'Espresso') || 'UiAutomator2', + autoGrantPermissions, + autoAcceptAlerts, + autoDismissAlerts, + appWaitActivity, + noReset, + fullReset, + newCommandTimeout, + }); + + const mergedCapabilities = { + ...capabilities, + ...userCapabilities, + }; + + for (const [key, value] of Object.entries(mergedCapabilities)) { + if (value === undefined) { + delete mergedCapabilities[key]; + } + } + + return mergedCapabilities; + } + + getSessionType(options: Record): 'ios' | 'android' { + const platform = options.platform as string; + return platform.toLowerCase() as 'ios' | 'android'; + } + + shouldAutoDetach(options: Record): boolean { + return options.noReset === true || !options.appPath; + } +} + +export const localAppiumProvider = new LocalAppiumProvider(); diff --git a/src/providers/local-browser.provider.ts b/src/providers/local-browser.provider.ts new file mode 100644 index 0000000..50bf303 --- /dev/null +++ b/src/providers/local-browser.provider.ts @@ -0,0 +1,118 @@ +import type { SessionProvider, ConnectionConfig } from './types'; + +export type LocalBrowserOptions = { + browser?: 'chrome' | 'firefox' | 'edge' | 'safari'; + headless?: boolean; + windowWidth?: number; + windowHeight?: number; + capabilities?: Record; +}; + +export class LocalBrowserProvider implements SessionProvider { + name = 'local-browser'; + + getConnectionConfig(_options: Record): ConnectionConfig { + return {}; // local — use WebdriverIO defaults + } + + buildCapabilities(options: Record): Record { + const selectedBrowser = (options.browser as string | undefined) ?? 'chrome'; + const headless = (options.headless as boolean | undefined) ?? true; + const windowWidth = (options.windowWidth as number | undefined) ?? 1920; + const windowHeight = (options.windowHeight as number | undefined) ?? 1080; + const userCapabilities = (options.capabilities as Record | undefined) ?? {}; + + const headlessSupported = selectedBrowser !== 'safari'; + const effectiveHeadless = headless && headlessSupported; + + const chromiumArgs = [ + `--window-size=${windowWidth},${windowHeight}`, + '--no-sandbox', + '--disable-search-engine-choice-screen', + '--disable-infobars', + '--log-level=3', + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + '--disable-web-security', + '--allow-running-insecure-content', + ]; + + if (effectiveHeadless) { + chromiumArgs.push('--headless=new'); + chromiumArgs.push('--disable-gpu'); + chromiumArgs.push('--disable-dev-shm-usage'); + } + + const firefoxArgs: string[] = []; + if (effectiveHeadless && selectedBrowser === 'firefox') { + firefoxArgs.push('-headless'); + } + + const capabilities: Record = { + acceptInsecureCerts: true, + }; + + switch (selectedBrowser) { + case 'chrome': + capabilities.browserName = 'chrome'; + capabilities['goog:chromeOptions'] = { args: chromiumArgs }; + break; + case 'edge': + capabilities.browserName = 'msedge'; + capabilities['ms:edgeOptions'] = { args: chromiumArgs }; + break; + case 'firefox': + capabilities.browserName = 'firefox'; + if (firefoxArgs.length > 0) { + capabilities['moz:firefoxOptions'] = { args: firefoxArgs }; + } + break; + case 'safari': + capabilities.browserName = 'safari'; + break; + } + + const mergedCapabilities: Record = { + ...capabilities, + ...userCapabilities, + 'goog:chromeOptions': this.mergeCapabilityOptions(capabilities['goog:chromeOptions'], userCapabilities['goog:chromeOptions']), + 'ms:edgeOptions': this.mergeCapabilityOptions(capabilities['ms:edgeOptions'], userCapabilities['ms:edgeOptions']), + 'moz:firefoxOptions': this.mergeCapabilityOptions(capabilities['moz:firefoxOptions'], userCapabilities['moz:firefoxOptions']), + }; + + for (const [key, value] of Object.entries(mergedCapabilities)) { + if (value === undefined) { + delete mergedCapabilities[key]; + } + } + + return mergedCapabilities; + } + + getSessionType(_options: Record): 'browser' { + return 'browser'; + } + + shouldAutoDetach(_options: Record): boolean { + return false; + } + + private mergeCapabilityOptions(defaultOptions: unknown, customOptions: unknown): unknown { + if (!defaultOptions || typeof defaultOptions !== 'object' || !customOptions || typeof customOptions !== 'object') { + return customOptions ?? defaultOptions; + } + + const defaultRecord = defaultOptions as Record; + const customRecord = customOptions as Record; + const merged = { ...defaultRecord, ...customRecord }; + if (Array.isArray(defaultRecord.args) || Array.isArray(customRecord.args)) { + merged.args = [ + ...(Array.isArray(defaultRecord.args) ? defaultRecord.args : []), + ...(Array.isArray(customRecord.args) ? customRecord.args : []), + ]; + } + return merged; + } +} + +export const localBrowserProvider = new LocalBrowserProvider(); diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..816ba39 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,14 @@ +export interface ConnectionConfig { + hostname?: string; + port?: number; + path?: string; + protocol?: string; +} + +export interface SessionProvider { + name: string; + getConnectionConfig(options: Record): ConnectionConfig; + buildCapabilities(options: Record): Record; + getSessionType(options: Record): 'browser' | 'ios' | 'android'; + shouldAutoDetach(options: Record): boolean; +} diff --git a/src/recording/resources.ts b/src/recording/resources.ts index 8040361..104babd 100644 --- a/src/recording/resources.ts +++ b/src/recording/resources.ts @@ -2,10 +2,10 @@ import type { SessionHistory } from '../types/recording'; import { generateCode } from './code-generator'; import { getSessionHistory } from './step-recorder'; -import { getBrowser } from '../tools/browser.tool'; +import { getState } from '../session/state'; function getCurrentSessionId(): string | null { - return (getBrowser as any).__state?.currentSession ?? null; + return getState().currentSession; } export interface SessionStepsPayload { diff --git a/src/recording/step-recorder.ts b/src/recording/step-recorder.ts index d841c45..d87b0a5 100644 --- a/src/recording/step-recorder.ts +++ b/src/recording/step-recorder.ts @@ -1,14 +1,7 @@ // src/recording/step-recorder.ts import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { RecordedStep, SessionHistory } from '../types/recording'; -import { getBrowser } from '../tools/browser.tool'; - -function getState() { - return (getBrowser as any).__state as { - currentSession: string | null; - sessionHistory: Map; - }; -} +import { getState } from '../session/state'; export function appendStep( toolName: string, diff --git a/src/server.ts b/src/server.ts index 60612b6..0ef7c3a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,21 +7,23 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import { closeSessionTool, closeSessionToolDefinition, + readTabs, startBrowserTool, - startBrowserToolDefinition + startBrowserToolDefinition, + switchTabTool, + switchTabToolDefinition, } from './tools/browser.tool'; import { navigateTool, navigateToolDefinition } from './tools/navigate.tool'; import { clickTool, clickToolDefinition } from './tools/click.tool'; import { setValueTool, setValueToolDefinition } from './tools/set-value.tool'; import { scrollTool, scrollToolDefinition } from './tools/scroll.tool'; -import { getVisibleElementsTool, getVisibleElementsToolDefinition } from './tools/get-visible-elements.tool'; -import { getAccessibilityToolDefinition, getAccessibilityTreeTool } from './tools/get-accessibility-tree.tool'; -import { takeScreenshotTool, takeScreenshotToolDefinition } from './tools/take-screenshot.tool'; +import { readVisibleElements } from './tools/get-visible-elements.tool'; +import { readAccessibilityTree } from './tools/get-accessibility-tree.tool'; +import { readScreenshot } from './tools/take-screenshot.tool'; import { deleteCookiesTool, deleteCookiesToolDefinition, - getCookiesTool, - getCookiesToolDefinition, + readCookies, setCookieTool, setCookieToolDefinition, } from './tools/cookies.tool'; @@ -34,31 +36,25 @@ import { tapElementTool, tapElementToolDefinition, } from './tools/gestures.tool'; -import { getAppStateTool, getAppStateToolDefinition } from './tools/app-actions.tool'; +import { readAppState } from './tools/app-actions.tool'; +import { readContexts, readCurrentContext, switchContextTool, switchContextToolDefinition, } from './tools/context.tool'; import { - getContextsTool, - getContextsToolDefinition, - getCurrentContextTool, - getCurrentContextToolDefinition, - switchContextTool, - switchContextToolDefinition -} from './tools/context.tool'; -import { - getGeolocationTool, - getGeolocationToolDefinition, hideKeyboardTool, hideKeyboardToolDefinition, + readGeolocation, rotateDeviceTool, rotateDeviceToolDefinition, setGeolocationTool, setGeolocationToolDefinition, } from './tools/device.tool'; import { executeScriptTool, executeScriptToolDefinition } from './tools/execute-script.tool'; +import { executeSequenceTool, executeSequenceToolDefinition } from './tools/execute-sequence.tool'; import { attachBrowserTool, attachBrowserToolDefinition } from './tools/attach-browser.tool'; import { launchChromeTool, launchChromeToolDefinition } from './tools/launch-chrome.tool'; import { emulateDeviceTool, emulateDeviceToolDefinition } from './tools/emulate-device.tool'; import { withRecording } from './recording/step-recorder'; import { buildCurrentSessionSteps, buildSessionsIndex, buildSessionStepsById } from './recording/resources'; +import { parseBool, parseNumber, parseStringArray } from './utils/parse-variables'; // IMPORTANT: Redirect all console output to stderr to avoid messing with MCP protocol (Chrome writes to console) const _originalConsoleLog = console.log; @@ -101,9 +97,8 @@ registerTool(attachBrowserToolDefinition, withRecording('attach_browser', attach registerTool(emulateDeviceToolDefinition, emulateDeviceTool); registerTool(navigateToolDefinition, withRecording('navigate', navigateTool)); -// Element Discovery -registerTool(getVisibleElementsToolDefinition, getVisibleElementsTool); -registerTool(getAccessibilityToolDefinition, getAccessibilityTreeTool); +// Tab Management +registerTool(switchTabToolDefinition, switchTabTool); // Scrolling registerTool(scrollToolDefinition, withRecording('scroll', scrollTool)); @@ -112,11 +107,7 @@ registerTool(scrollToolDefinition, withRecording('scroll', scrollTool)); registerTool(clickToolDefinition, withRecording('click_element', clickTool)); registerTool(setValueToolDefinition, withRecording('set_value', setValueTool)); -// Screenshots -registerTool(takeScreenshotToolDefinition, takeScreenshotTool); - -// Cookies -registerTool(getCookiesToolDefinition, getCookiesTool); +// Cookies (write operations only; read via resource) registerTool(setCookieToolDefinition, setCookieTool); registerTool(deleteCookiesToolDefinition, deleteCookiesTool); @@ -125,23 +116,20 @@ registerTool(tapElementToolDefinition, withRecording('tap_element', tapElementTo registerTool(swipeToolDefinition, withRecording('swipe', swipeTool)); registerTool(dragAndDropToolDefinition, withRecording('drag_and_drop', dragAndDropTool)); -// App Lifecycle Management -registerTool(getAppStateToolDefinition, getAppStateTool); - // Context Switching (Native/WebView) -registerTool(getContextsToolDefinition, getContextsTool); -registerTool(getCurrentContextToolDefinition, getCurrentContextTool); registerTool(switchContextToolDefinition, switchContextTool); // Device Interaction registerTool(rotateDeviceToolDefinition, rotateDeviceTool); registerTool(hideKeyboardToolDefinition, hideKeyboardTool); -registerTool(getGeolocationToolDefinition, getGeolocationTool); registerTool(setGeolocationToolDefinition, setGeolocationTool); // Script Execution (Browser JS / Appium Mobile Commands) registerTool(executeScriptToolDefinition, executeScriptTool); +// Sequence Execution +registerTool(executeSequenceToolDefinition, withRecording('execute_sequence', executeSequenceTool)); + // Session Recording Resources server.registerResource( 'sessions', @@ -216,6 +204,116 @@ server.registerResource( }, ); +// Resource: visible elements +server.registerResource( + 'session-current-elements', + new ResourceTemplate('wdio://session/current/elements{?inViewportOnly,includeContainers,includeBounds,limit,offset}', { list: undefined }), + { description: 'Interactable elements on the current page' }, + async (uri, variables) => { + const result = await readVisibleElements({ + inViewportOnly: parseBool(variables.inViewportOnly as string | undefined, true), + includeContainers: parseBool(variables.includeContainers as string | undefined, false), + includeBounds: parseBool(variables.includeBounds as string | undefined, false), + limit: parseNumber(variables.limit as string | undefined, 0), + offset: parseNumber(variables.offset as string | undefined, 0), + }); + return { contents: [{ uri: uri.href, mimeType: result.mimeType, text: result.text }] }; + }, +); + +// Resource: accessibility tree +server.registerResource( + 'session-current-accessibility', + new ResourceTemplate('wdio://session/current/accessibility{?limit,offset,roles}', { list: undefined }), + { description: 'Accessibility tree for the current page' }, + async (uri, variables) => { + const result = await readAccessibilityTree({ + limit: parseNumber(variables.limit as string | undefined, 100), + offset: parseNumber(variables.offset as string | undefined, 0), + roles: parseStringArray(variables.roles as string | undefined), + }); + return { contents: [{ uri: uri.href, mimeType: result.mimeType, text: result.text }] }; + }, +); + +// Resource: screenshot +server.registerResource( + 'session-current-screenshot', + 'wdio://session/current/screenshot', + { description: 'Screenshot of the current page' }, + async () => { + const result = await readScreenshot(); + return { contents: [{ uri: 'wdio://session/current/screenshot', mimeType: result.mimeType, blob: result.blob }] }; + }, +); + +// Resource: cookies +server.registerResource( + 'session-current-cookies', + new ResourceTemplate('wdio://session/current/cookies{?name}', { list: undefined }), + { description: 'Cookies for the current session' }, + async (uri, variables) => { + const result = await readCookies(variables.name as string | undefined); + return { contents: [{ uri: uri.href, mimeType: result.mimeType, text: result.text }] }; + }, +); + +// Resource: app state +server.registerResource( + 'session-current-app-state', + new ResourceTemplate('wdio://session/current/app-state/{bundleId}', { list: undefined }), + { description: 'App state for a given bundle ID' }, + async (uri, variables) => { + const result = await readAppState(variables.bundleId as string); + return { contents: [{ uri: uri.href, mimeType: result.mimeType, text: result.text }] }; + }, +); + +// Resource: contexts +server.registerResource( + 'session-current-contexts', + 'wdio://session/current/contexts', + { description: 'Available contexts (NATIVE_APP, WEBVIEW)' }, + async () => { + const result = await readContexts(); + return { contents: [{ uri: 'wdio://session/current/contexts', mimeType: result.mimeType, text: result.text }] }; + }, +); + +// Resource: current context +server.registerResource( + 'session-current-context', + 'wdio://session/current/context', + { description: 'Currently active context' }, + async () => { + const result = await readCurrentContext(); + return { contents: [{ uri: 'wdio://session/current/context', mimeType: result.mimeType, text: result.text }] }; + }, +); + +// Resource: geolocation +server.registerResource( + 'session-current-geolocation', + 'wdio://session/current/geolocation', + { description: 'Current device geolocation' }, + async () => { + const result = await readGeolocation(); + return { contents: [{ uri: 'wdio://session/current/geolocation', mimeType: result.mimeType, text: result.text }] }; + }, +); + +// Resource: browser tabs +server.registerResource( + 'session-current-tabs', + 'wdio://session/current/tabs', + { description: 'Browser tabs in the current session' }, + + async () => { + const result = await readTabs(); + return { contents: [{ uri: 'wdio://session/current/tabs', mimeType: result.mimeType, text: result.text }] }; + }, +); + async function main() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts new file mode 100644 index 0000000..ec2767d --- /dev/null +++ b/src/session/lifecycle.ts @@ -0,0 +1,57 @@ +import type { SessionHistory } from '../types/recording'; +import type { SessionMetadata } from './state'; +import { getState } from './state'; + +export function handleSessionTransition(newSessionId: string): void { + const state = getState(); + if (state.currentSession && state.currentSession !== newSessionId) { + const outgoing = state.sessionHistory.get(state.currentSession); + if (outgoing) { + outgoing.steps.push({ + index: outgoing.steps.length + 1, + tool: '__session_transition__', + params: { newSessionId }, + status: 'ok', + durationMs: 0, + timestamp: new Date().toISOString(), + }); + outgoing.endedAt = new Date().toISOString(); + } + } +} + +export function registerSession( + sessionId: string, + browser: WebdriverIO.Browser, + metadata: SessionMetadata, + historyEntry: SessionHistory, +): void { + const state = getState(); + if (state.currentSession && state.currentSession !== sessionId) { + handleSessionTransition(sessionId); + } + state.browsers.set(sessionId, browser); + state.sessionMetadata.set(sessionId, metadata); + state.sessionHistory.set(sessionId, historyEntry); + state.currentSession = sessionId; +} + +export async function closeSession(sessionId: string, detach: boolean, isAttached: boolean): Promise { + const state = getState(); + const browser = state.browsers.get(sessionId); + if (!browser) return; + + const history = state.sessionHistory.get(sessionId); + if (history) { + history.endedAt = new Date().toISOString(); + } + + // Only terminate the WebDriver session if we created it (not attached/borrowed) + if (!detach && !isAttached) { + await browser.deleteSession(); + } + + state.browsers.delete(sessionId); + state.sessionMetadata.delete(sessionId); + state.currentSession = null; +} diff --git a/src/session/state.ts b/src/session/state.ts new file mode 100644 index 0000000..5343668 --- /dev/null +++ b/src/session/state.ts @@ -0,0 +1,26 @@ +import type { SessionHistory } from '../types/recording'; + +export interface SessionMetadata { + type: 'browser' | 'ios' | 'android'; + capabilities: Record; + isAttached: boolean; +} + +const state = { + browsers: new Map(), + currentSession: null as string | null, + sessionMetadata: new Map(), + sessionHistory: new Map(), +}; + +export function getBrowser(): WebdriverIO.Browser { + const browser = state.browsers.get(state.currentSession); + if (!browser) { + throw new Error('No active browser session'); + } + return browser; +} + +export function getState() { + return state; +} diff --git a/src/tools/app-actions.tool.ts b/src/tools/app-actions.tool.ts index faccec4..0e76680 100644 --- a/src/tools/app-actions.tool.ts +++ b/src/tools/app-actions.tool.ts @@ -1,24 +1,8 @@ -import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; -import type { ToolDefinition } from '../types/tool'; -import { z } from 'zod'; -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; -// Get App State Tool -export const getAppStateToolDefinition: ToolDefinition = { - name: 'get_app_state', - description: 'gets the state of an app (not installed, not running, background, foreground)', - inputSchema: { - bundleId: z.string().describe('App bundle ID (e.g., com.example.app)'), - }, -}; - -export const getAppStateTool: ToolCallback = async (args: { - bundleId: string; -}): Promise => { +export async function readAppState(bundleId: string): Promise<{ mimeType: string; text: string }> { try { const browser = getBrowser(); - const { bundleId } = args; const appIdentifier = browser.isAndroid ? { appId: bundleId } @@ -35,18 +19,10 @@ export const getAppStateTool: ToolCallback = async (args: { }; return { - content: [ - { - type: 'text', - text: `App state for ${bundleId}: ${stateMap[state] || 'unknown: ' + state}`, - }, - ], + mimeType: 'text/plain', + text: `App state for ${bundleId}: ${stateMap[state] || 'unknown: ' + state}`, }; } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error getting app state: ${e}` }], - }; + return { mimeType: 'text/plain', text: `Error getting app state: ${e}` }; } -}; - +} diff --git a/src/tools/app-session.tool.ts b/src/tools/app-session.tool.ts index b553be8..ccff9be 100644 --- a/src/tools/app-session.tool.ts +++ b/src/tools/app-session.tool.ts @@ -2,10 +2,10 @@ import { remote } from 'webdriverio'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; -import type { SessionHistory } from '../types/recording'; import { z } from 'zod'; -import { buildAndroidCapabilities, buildIOSCapabilities, getAppiumServerConfig, } from '../config/appium.config'; -import { getBrowser } from './browser.tool'; +import { localAppiumProvider } from '../providers/local-appium.provider'; +import { registerSession } from '../session/lifecycle'; +import type { SessionMetadata } from '../session/state'; export const startAppToolDefinition: ToolDefinition = { name: 'start_app_session', @@ -34,20 +34,6 @@ export const startAppToolDefinition: ToolDefinition = { }, }; -// Access shared state from browser.tool.ts -export const getState = () => { - const sharedState = (getBrowser as any).__state; - if (!sharedState) { - throw new Error('Browser state not initialized'); - } - return sharedState as { - browsers: Map; - currentSession: string | null; - sessionMetadata: Map; - sessionHistory: Map; - }; -}; - export const startAppTool: ToolCallback = async (args: { platform: 'iOS' | 'Android'; appPath?: string; @@ -68,25 +54,7 @@ export const startAppTool: ToolCallback = async (args: { capabilities?: Record; }): Promise => { try { - const { - platform, - appPath, - deviceName, - platformVersion, - automationName, - appiumHost, - appiumPort, - appiumPath, - autoGrantPermissions = true, - autoAcceptAlerts, - autoDismissAlerts, - appWaitActivity, - udid, - noReset, - fullReset, - newCommandTimeout = 300, - capabilities: userCapabilities = {}, - } = args; + const { platform, appPath, deviceName, noReset } = args; // Validate: either appPath or noReset=true is required if (!appPath && noReset !== true) { @@ -99,52 +67,14 @@ export const startAppTool: ToolCallback = async (args: { } // Get Appium server configuration - const serverConfig = getAppiumServerConfig({ - hostname: appiumHost, - port: appiumPort, - path: appiumPath, - }); + const serverConfig = localAppiumProvider.getConnectionConfig(args); // Build platform-specific capabilities - const capabilities: Record = platform === 'iOS' - ? buildIOSCapabilities(appPath, { - deviceName, - platformVersion, - automationName: (automationName as 'XCUITest') || 'XCUITest', - autoGrantPermissions, - autoAcceptAlerts, - autoDismissAlerts, - udid, - noReset, - fullReset, - newCommandTimeout, - }) - : buildAndroidCapabilities(appPath, { - deviceName, - platformVersion, - automationName: (automationName as 'UiAutomator2' | 'Espresso') || 'UiAutomator2', - autoGrantPermissions, - autoAcceptAlerts, - autoDismissAlerts, - appWaitActivity, - noReset, - fullReset, - newCommandTimeout, - }); - - const mergedCapabilities = { - ...capabilities, - ...userCapabilities, - }; - for (const [key, value] of Object.entries(mergedCapabilities)) { - if (value === undefined) { - delete mergedCapabilities[key]; - } - } + const mergedCapabilities = localAppiumProvider.buildCapabilities(args); // Create Appium session const browser = await remote({ - protocol: 'http', + protocol: serverConfig.protocol, hostname: serverConfig.hostname, port: serverConfig.port, path: serverConfig.path, @@ -153,44 +83,23 @@ export const startAppTool: ToolCallback = async (args: { const { sessionId } = browser; - // Store session and metadata - // Auto-set isAttached=true when noReset or no appPath to preserve session on close - const shouldAutoDetach = noReset === true || !appPath; - const state = getState(); - state.browsers.set(sessionId, browser); - state.sessionMetadata.set(sessionId, { - type: platform.toLowerCase() as 'ios' | 'android', + // Register session via lifecycle (handles transition sentinel, state maps, currentSession) + const shouldAutoDetach = localAppiumProvider.shouldAutoDetach(args); + const sessionType = localAppiumProvider.getSessionType(args); + const metadata: SessionMetadata = { + type: sessionType, capabilities: mergedCapabilities, isAttached: shouldAutoDetach, - }); - - // If replacing an active session, close its history with transition sentinel - if (state.currentSession && state.currentSession !== sessionId) { - const outgoing = state.sessionHistory.get(state.currentSession); - if (outgoing) { - outgoing.steps.push({ - index: outgoing.steps.length + 1, - tool: '__session_transition__', - params: { newSessionId: sessionId }, - status: 'ok', - durationMs: 0, - timestamp: new Date().toISOString(), - }); - outgoing.endedAt = new Date().toISOString(); - } - } - - state.sessionHistory.set(sessionId, { + }; + registerSession(sessionId, browser, metadata, { sessionId, - type: platform.toLowerCase() as 'ios' | 'android', + type: sessionType, startedAt: new Date().toISOString(), - capabilities: mergedCapabilities as Record, + capabilities: mergedCapabilities, appiumConfig: { hostname: serverConfig.hostname, port: serverConfig.port, path: serverConfig.path }, steps: [], }); - state.currentSession = sessionId; - const appInfo = appPath ? `\nApp: ${appPath}` : '\nApp: (connected to running app)'; const detachNote = shouldAutoDetach ? '\n\n(Auto-detach enabled: session will be preserved on close. Use close_session({ detach: false }) to force terminate.)' diff --git a/src/tools/attach-browser.tool.ts b/src/tools/attach-browser.tool.ts index e576b05..78cca27 100644 --- a/src/tools/attach-browser.tool.ts +++ b/src/tools/attach-browser.tool.ts @@ -3,7 +3,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; import { z } from 'zod'; -import { getBrowser } from './browser.tool'; +import { getState } from '../session/state'; export const attachBrowserToolDefinition: ToolDefinition = { name: 'attach_browser', @@ -95,7 +95,7 @@ export const attachBrowserTool: ToolCallback = async ({ navigationUrl?: string; }): Promise => { try { - const state = (getBrowser as any).__state; + const state = getState(); await waitForCDP(host, port); const { activeTabUrl, allTabUrls } = await closeStaleMappers(host, port); @@ -118,7 +118,7 @@ export const attachBrowserTool: ToolCallback = async ({ state.currentSession = sessionId; state.sessionMetadata.set(sessionId, { type: 'browser', - capabilities: browser.capabilities, + capabilities: browser.capabilities as Record, isAttached: true, }); state.sessionHistory.set(sessionId, { diff --git a/src/tools/browser.tool.ts b/src/tools/browser.tool.ts index d3171cd..e55dcdf 100644 --- a/src/tools/browser.tool.ts +++ b/src/tools/browser.tool.ts @@ -2,8 +2,10 @@ import { remote } from 'webdriverio'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; -import type { SessionHistory } from '../types/recording'; import { z } from 'zod'; +import { getBrowser, getState } from '../session/state'; +import { registerSession, closeSession } from '../session/lifecycle'; +import { localBrowserProvider } from '../providers/local-browser.provider'; const supportedBrowsers = ['chrome', 'firefox', 'edge', 'safari'] as const; const browserSchema = z.enum(supportedBrowsers).default('chrome'); @@ -30,28 +32,6 @@ export const closeSessionToolDefinition: ToolDefinition = { }, }; -const state: { - browsers: Map; - currentSession: string | null; - sessionMetadata: Map; - sessionHistory: Map; -} = { - browsers: new Map(), - currentSession: null, - sessionMetadata: new Map(), - sessionHistory: new Map(), -}; - -export const getBrowser = () => { - const browser = state.browsers.get(state.currentSession); - if (!browser) { - throw new Error('No active browser session'); - } - return browser; -}; -// Export state for app-session.tool.ts to access -(getBrowser as any).__state = state; - export const startBrowserTool: ToolCallback = async ({ browser = 'chrome', headless = true, @@ -76,121 +56,31 @@ export const startBrowserTool: ToolCallback = async ({ const selectedBrowser = browser; const headlessSupported = selectedBrowser !== 'safari'; const effectiveHeadless = headless && headlessSupported; - const chromiumArgs = [ - `--window-size=${windowWidth},${windowHeight}`, - '--no-sandbox', - '--disable-search-engine-choice-screen', - '--disable-infobars', - '--log-level=3', - '--use-fake-device-for-media-stream', - '--use-fake-ui-for-media-stream', - '--disable-web-security', - '--allow-running-insecure-content', - ]; - - // Add headless argument if enabled - if (effectiveHeadless) { - chromiumArgs.push('--headless=new'); - chromiumArgs.push('--disable-gpu'); - chromiumArgs.push('--disable-dev-shm-usage'); - } - - const firefoxArgs: string[] = []; - if (effectiveHeadless && selectedBrowser === 'firefox') { - firefoxArgs.push('-headless'); - } - - const capabilities: Record = { - acceptInsecureCerts: true, - }; - - switch (selectedBrowser) { - case 'chrome': - capabilities.browserName = 'chrome'; - capabilities['goog:chromeOptions'] = { args: chromiumArgs }; - break; - case 'edge': - capabilities.browserName = 'msedge'; - capabilities['ms:edgeOptions'] = { args: chromiumArgs }; - break; - case 'firefox': - capabilities.browserName = 'firefox'; - if (firefoxArgs.length > 0) { - capabilities['moz:firefoxOptions'] = { args: firefoxArgs }; - } - break; - case 'safari': - capabilities.browserName = 'safari'; - break; - } - const mergeCapabilityOptions = (defaultOptions: unknown, customOptions: unknown) => { - if (!defaultOptions || typeof defaultOptions !== 'object' || !customOptions || typeof customOptions !== 'object') { - return customOptions ?? defaultOptions; - } - - const defaultRecord = defaultOptions as Record; - const customRecord = customOptions as Record; - const merged = { ...defaultRecord, ...customRecord }; - if (Array.isArray(defaultRecord.args) || Array.isArray(customRecord.args)) { - merged.args = [ - ...(Array.isArray(defaultRecord.args) ? defaultRecord.args : []), - ...(Array.isArray(customRecord.args) ? customRecord.args : []), - ]; - } - return merged; - }; - - const mergedCapabilities: Record = { - ...capabilities, - ...userCapabilities, - 'goog:chromeOptions': mergeCapabilityOptions(capabilities['goog:chromeOptions'], userCapabilities['goog:chromeOptions']), - 'ms:edgeOptions': mergeCapabilityOptions(capabilities['ms:edgeOptions'], userCapabilities['ms:edgeOptions']), - 'moz:firefoxOptions': mergeCapabilityOptions(capabilities['moz:firefoxOptions'], userCapabilities['moz:firefoxOptions']), - }; - for (const [key, value] of Object.entries(mergedCapabilities)) { - if (value === undefined) { - delete mergedCapabilities[key]; - } - } + const mergedCapabilities = localBrowserProvider.buildCapabilities({ browser, headless, windowWidth, windowHeight, capabilities: userCapabilities }); const wdioBrowser = await remote({ capabilities: mergedCapabilities, }); const { sessionId } = wdioBrowser; - state.browsers.set(sessionId, wdioBrowser); - state.sessionMetadata.set(sessionId, { - type: 'browser', - capabilities: wdioBrowser.capabilities, - isAttached: false, - }); - - // If replacing an active session, close its history and append transition sentinel - if (state.currentSession && state.currentSession !== sessionId) { - const outgoing = state.sessionHistory.get(state.currentSession); - if (outgoing) { - outgoing.steps.push({ - index: outgoing.steps.length + 1, - tool: '__session_transition__', - params: { newSessionId: sessionId }, - status: 'ok', - durationMs: 0, - timestamp: new Date().toISOString(), - }); - outgoing.endedAt = new Date().toISOString(); - } - } - state.sessionHistory.set(sessionId, { + registerSession( sessionId, - type: 'browser', - startedAt: new Date().toISOString(), - capabilities: wdioBrowser.capabilities as Record, - steps: [], - }); - - state.currentSession = sessionId; + wdioBrowser, + { + type: 'browser', + capabilities: wdioBrowser.capabilities as Record, + isAttached: false, + }, + { + sessionId, + type: 'browser', + startedAt: new Date().toISOString(), + capabilities: wdioBrowser.capabilities as Record, + steps: [], + }, + ); let sizeNote = ''; try { @@ -218,28 +108,69 @@ export const startBrowserTool: ToolCallback = async ({ }; }; -export const closeSessionTool: ToolCallback = async (args: { detach?: boolean } = {}): Promise => { +export async function readTabs(): Promise<{ mimeType: string; text: string }> { try { const browser = getBrowser(); - const sessionId = state.currentSession; - const metadata = state.sessionMetadata.get(sessionId); + const handles = await browser.getWindowHandles(); + const currentHandle = await browser.getWindowHandle(); + const tabs = []; + for (const handle of handles) { + await browser.switchToWindow(handle); + tabs.push({ + handle, + title: await browser.getTitle(), + url: await browser.getUrl(), + isActive: handle === currentHandle, + }); + } + // Switch back to the originally active tab + await browser.switchToWindow(currentHandle); + return { mimeType: 'application/json', text: JSON.stringify(tabs) }; + } catch (e) { + return { mimeType: 'text/plain', text: `Error: ${e}` }; + } +} + +export const switchTabToolDefinition: ToolDefinition = { + name: 'switch_tab', + description: 'switches to a browser tab by handle or index', + inputSchema: { + handle: z.string().optional().describe('Window handle to switch to'), + index: z.number().int().min(0).optional().describe('0-based tab index to switch to'), + }, +}; - // Retain history but mark session as ended - const history = state.sessionHistory.get(sessionId); - if (history) { - history.endedAt = new Date().toISOString(); +export const switchTabTool: ToolCallback = async ({ handle, index }: { handle?: string; index?: number }) => { + try { + const browser = getBrowser(); + if (handle) { + await browser.switchToWindow(handle); + return { content: [{ type: 'text', text: `Switched to tab: ${handle}` }] }; + } else if (index !== undefined) { + const handles = await browser.getWindowHandles(); + if (index >= handles.length) { + return { isError: true, content: [{ type: 'text', text: `Error: index ${index} out of range (${handles.length} tabs)` }] }; + } + await browser.switchToWindow(handles[index]); + return { content: [{ type: 'text', text: `Switched to tab ${index}: ${handles[index]}` }] }; } + return { isError: true, content: [{ type: 'text', text: 'Error: Must provide either handle or index' }] }; + } catch (e) { + return { isError: true, content: [{ type: 'text', text: `Error switching tab: ${e}` }] }; + } +}; + +export const closeSessionTool: ToolCallback = async (args: { detach?: boolean } = {}): Promise => { + try { + getBrowser(); // throws if no active session + const state = getState(); + const sessionId = state.currentSession; + const metadata = state.sessionMetadata.get(sessionId); // Skip deleteSession for attached sessions (not created by us) or when user explicitly detaches const effectiveDetach = args.detach || !!metadata?.isAttached; - if (!effectiveDetach) { - await browser.deleteSession(); - } - // Always clean up local state - state.browsers.delete(sessionId); - state.sessionMetadata.delete(sessionId); - state.currentSession = null; + await closeSession(sessionId, args.detach ?? false, !!metadata?.isAttached); const action = effectiveDetach ? 'detached from' : 'closed'; const note = args.detach && !metadata?.isAttached diff --git a/src/tools/click.tool.ts b/src/tools/click.tool.ts index 0e5e4a6..ba122e0 100644 --- a/src/tools/click.tool.ts +++ b/src/tools/click.tool.ts @@ -1,4 +1,4 @@ -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; import { z } from 'zod'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; @@ -16,7 +16,7 @@ export const clickToolDefinition: ToolDefinition = { }, }; -const clickAction = async (selector: string, timeout: number, scrollToView = true): Promise => { +export const clickAction = async (selector: string, timeout: number, scrollToView = true): Promise => { try { const browser = getBrowser(); await browser.waitUntil(browser.$(selector).isExisting, { timeout }); diff --git a/src/tools/context.tool.ts b/src/tools/context.tool.ts index 78923e7..3640764 100644 --- a/src/tools/context.tool.ts +++ b/src/tools/context.tool.ts @@ -2,21 +2,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; import { z } from 'zod'; -import { getBrowser } from './browser.tool'; - -// Get Contexts Tool Definition -export const getContextsToolDefinition: ToolDefinition = { - name: 'get_contexts', - description: 'lists available contexts (NATIVE_APP, WEBVIEW)', - inputSchema: {}, -}; - -// Get Current Context Tool Definition -export const getCurrentContextToolDefinition: ToolDefinition = { - name: 'get_current_context', - description: 'shows the currently active context', - inputSchema: {}, -}; +import { getBrowser } from '../session/state'; // Switch Context Tool Definition export const switchContextToolDefinition: ToolDefinition = { @@ -31,46 +17,25 @@ export const switchContextToolDefinition: ToolDefinition = { }, }; -// Get Contexts Tool -export const getContextsTool: ToolCallback = async (): Promise => { +export async function readContexts(): Promise<{ mimeType: string; text: string }> { try { const browser = getBrowser(); - const contexts = await browser.getContexts(); - - return { - content: [ - { - type: 'text', - text: `Available contexts:\n${contexts.map((ctx, idx) => `${idx + 1}. ${ctx}`).join('\n')}`, - }, - ], - }; + return { mimeType: 'application/json', text: JSON.stringify(contexts) }; } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error getting contexts: ${e}` }], - }; + return { mimeType: 'text/plain', text: `Error: ${e}` }; } -}; +} -// Get Current Context Tool -export const getCurrentContextTool: ToolCallback = async (): Promise => { +export async function readCurrentContext(): Promise<{ mimeType: string; text: string }> { try { const browser = getBrowser(); - const currentContext = await browser.getContext(); - - return { - content: [{ type: 'text', text: `Current context: ${JSON.stringify(currentContext)}` }], - }; + return { mimeType: 'application/json', text: JSON.stringify(currentContext) }; } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error getting current context: ${e}` }], - }; + return { mimeType: 'text/plain', text: `Error: ${e}` }; } -}; +} export const switchContextTool: ToolCallback = async (args: { context: string; @@ -83,7 +48,7 @@ export const switchContextTool: ToolCallback = async (args: { let targetContext = context; if (/^\d+$/.test(context)) { const contexts = await browser.getContexts(); - const index = parseInt(context, 10) - 1; // Convert to 0-based index + const index = Number.parseInt(context, 10) - 1; // Convert to 0-based index if (index >= 0 && index < contexts.length) { targetContext = contexts[index] as string; } else { diff --git a/src/tools/cookies.tool.ts b/src/tools/cookies.tool.ts index b41c73e..68d0ab3 100644 --- a/src/tools/cookies.tool.ts +++ b/src/tools/cookies.tool.ts @@ -1,53 +1,27 @@ -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; import { z } from 'zod'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { Cookie } from '@wdio/protocols'; import type { ToolDefinition } from '../types/tool'; -// Tool definitions -export const getCookiesToolDefinition: ToolDefinition = { - name: 'get_cookies', - description: 'gets all cookies or a specific cookie by name', - inputSchema: { - name: z.string().optional().describe('Optional cookie name to retrieve a specific cookie. If not provided, returns all cookies'), - }, -}; - -export const getCookiesTool: ToolCallback = async ({ name}: { name?: string }): Promise => { +export async function readCookies(name?: string): Promise<{ mimeType: string; text: string }> { try { const browser = getBrowser(); if (name) { - // Get specific cookie by name const cookie = await browser.getCookies([name]); if (cookie.length === 0) { - return { - content: [{ type: 'text', text: `Cookie "${name}" not found` }], - }; + return { mimeType: 'application/json', text: JSON.stringify(null) }; } - return { - content: [{ type: 'text', text: JSON.stringify(cookie[0], null, 2) }], - }; + return { mimeType: 'application/json', text: JSON.stringify(cookie[0]) }; } - // Get all cookies const cookies = await browser.getCookies(); - if (cookies.length === 0) { - return { - content: [{ type: 'text', text: 'No cookies found' }], - }; - } - return { - content: [{ type: 'text', text: JSON.stringify(cookies, null, 2) }], - }; - + return { mimeType: 'application/json', text: JSON.stringify(cookies) }; } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error getting cookies: ${e}` }], - }; + return { mimeType: 'application/json', text: JSON.stringify({ error: String(e) }) }; } -}; +} // Set a cookie export const setCookieToolDefinition: ToolDefinition = { diff --git a/src/tools/device.tool.ts b/src/tools/device.tool.ts index 7604c25..d5a300a 100644 --- a/src/tools/device.tool.ts +++ b/src/tools/device.tool.ts @@ -2,7 +2,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; import { z } from 'zod'; -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; // Tool Definitions for zero-argument tools export const hideKeyboardToolDefinition: ToolDefinition = { @@ -11,12 +11,6 @@ export const hideKeyboardToolDefinition: ToolDefinition = { inputSchema: {}, }; -export const getGeolocationToolDefinition: ToolDefinition = { - name: 'get_geolocation', - description: 'gets current device geolocation', - inputSchema: {}, -}; - // Tool Definitions for tools with arguments export const rotateDeviceToolDefinition: ToolDefinition = { name: 'rotate_device', @@ -36,6 +30,16 @@ export const setGeolocationToolDefinition: ToolDefinition = { }, }; +export async function readGeolocation(): Promise<{ mimeType: string; text: string }> { + try { + const browser = getBrowser(); + const location = await browser.getGeoLocation(); + return { mimeType: 'application/json', text: JSON.stringify(location) }; + } catch (e) { + return { mimeType: 'text/plain', text: `Error: ${e}` }; + } +} + // Rotate Device Tool export const rotateDeviceTool: ToolCallback = async (args: { orientation: 'PORTRAIT' | 'LANDSCAPE'; @@ -75,29 +79,6 @@ export const hideKeyboardTool: ToolCallback = async (): Promise } }; -// Get Geolocation Tool -export const getGeolocationTool: ToolCallback = async (): Promise => { - try { - const browser = getBrowser(); - - const location = await browser.getGeoLocation(); - - return { - content: [ - { - type: 'text', - text: `Location:\n Latitude: ${location.latitude}\n Longitude: ${location.longitude}\n Altitude: ${location.altitude || 'N/A'}`, - }, - ], - }; - } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error getting geolocation: ${e}` }], - }; - } -}; - // Set Geolocation Tool export const setGeolocationTool: ToolCallback = async (args: { latitude: number; diff --git a/src/tools/emulate-device.tool.ts b/src/tools/emulate-device.tool.ts index 0b95af9..78e37ef 100644 --- a/src/tools/emulate-device.tool.ts +++ b/src/tools/emulate-device.tool.ts @@ -5,7 +5,7 @@ import type { ToolDefinition } from '../types/tool'; // This is a type-only import — it is stripped at build time by tsup and has no runtime impact. import type { DeviceName } from 'webdriverio/build/deviceDescriptorsSource.js'; import { z } from 'zod'; -import { getBrowser } from './browser.tool'; +import { getBrowser, getState } from '../session/state'; // Stores restore functions returned by browser.emulate(), keyed by sessionId const restoreFunctions = new Map Promise>(); @@ -34,15 +34,18 @@ export const emulateDeviceTool: ToolCallback = async ({ }): Promise => { try { const browser = getBrowser(); - const state = (getBrowser as any).__state; - const sessionId = state.currentSession as string; + const state = getState(); + const sessionId = state.currentSession; const metadata = state.sessionMetadata.get(sessionId); // Guard: mobile sessions if (metadata?.type === 'ios' || metadata?.type === 'android') { return { isError: true, - content: [{ type: 'text', text: 'Error: emulate_device is only supported for web browser sessions, not iOS/Android.' }], + content: [{ + type: 'text', + text: 'Error: emulate_device is only supported for web browser sessions, not iOS/Android.' + }], }; } diff --git a/src/tools/execute-script.tool.ts b/src/tools/execute-script.tool.ts index cea2825..de753c4 100644 --- a/src/tools/execute-script.tool.ts +++ b/src/tools/execute-script.tool.ts @@ -2,7 +2,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; import { z } from 'zod'; -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; export const executeScriptToolDefinition: ToolDefinition = { name: 'execute_script', diff --git a/src/tools/execute-sequence.tool.ts b/src/tools/execute-sequence.tool.ts new file mode 100644 index 0000000..fa3431e --- /dev/null +++ b/src/tools/execute-sequence.tool.ts @@ -0,0 +1,205 @@ +import { z } from 'zod'; +import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import type { ToolDefinition } from '../types/tool'; +import { getBrowser } from '../session/state'; +import { clickAction } from './click.tool'; +import { setValueAction } from './set-value.tool'; +import { navigateAction } from './navigate.tool'; +import { scrollAction } from './scroll.tool'; +import { dragAndDropAction, swipeAction, tapAction } from './gestures.tool'; +import { appendStep } from '../recording/step-recorder'; +import { waitForStability } from '../utils/stability-detector'; +import { captureStateDelta } from '../utils/state-diff'; +import { getInteractableBrowserElements } from '../scripts/get-interactable-browser-elements'; + +// Action schemas +const clickActionSchema = z.object({ + action: z.literal('click'), + selector: z.string(), + scrollToView: z.boolean().optional(), + timeout: z.number().optional(), +}); + +const setValueActionSchema = z.object({ + action: z.literal('set_value'), + selector: z.string(), + value: z.string(), + scrollToView: z.boolean().optional(), + timeout: z.number().optional(), +}); + +const navigateActionSchema = z.object({ + action: z.literal('navigate'), + url: z.string(), +}); + +const scrollActionSchema = z.object({ + action: z.literal('scroll'), + direction: z.enum(['up', 'down']), + pixels: z.number().optional(), +}); + +const tapActionSchema = z.object({ + action: z.literal('tap'), + selector: z.string().optional(), + x: z.number().optional(), + y: z.number().optional(), +}); + +const swipeActionSchema = z.object({ + action: z.literal('swipe'), + direction: z.enum(['up', 'down', 'left', 'right']), + duration: z.number().optional(), + percent: z.number().optional(), +}); + +const dragAndDropActionSchema = z.object({ + action: z.literal('drag_and_drop'), + sourceSelector: z.string(), + targetSelector: z.string().optional(), + x: z.number().optional(), + y: z.number().optional(), + duration: z.number().optional(), +}); + +const actionSchema = z.discriminatedUnion('action', [ + clickActionSchema, + setValueActionSchema, + navigateActionSchema, + scrollActionSchema, + tapActionSchema, + swipeActionSchema, + dragAndDropActionSchema, +]); + +export const executeSequenceToolDefinition: ToolDefinition = { + name: 'execute_sequence', + description: 'Execute a sequence of actions atomically. Waits for page stability between actions. Returns a state delta showing what changed.', + inputSchema: { + actions: z.array(actionSchema).min(1).describe('Sequence of actions to execute'), + waitForStability: z.boolean().optional().default(true).describe('Wait for page stability after each action'), + }, +}; + +async function dispatchAction(action: z.infer): Promise { + switch (action.action) { + case 'click': + return clickAction(action.selector, action.timeout ?? 3000, action.scrollToView); + case 'set_value': + return setValueAction(action.selector, action.value, action.scrollToView, action.timeout); + case 'navigate': + return navigateAction(action.url); + case 'scroll': + return scrollAction(action.direction, action.pixels); + case 'tap': + return tapAction({ selector: action.selector, x: action.x, y: action.y }); + case 'swipe': + return swipeAction({ direction: action.direction, duration: action.duration, percent: action.percent }); + case 'drag_and_drop': + return dragAndDropAction({ + sourceSelector: action.sourceSelector, + targetSelector: action.targetSelector, + x: action.x, + y: action.y, + duration: action.duration + }); + default: { + const _exhaustiveCheck: never = action; + return { isError: true, content: [{ type: 'text', text: `Unknown action: ${(action as any).action}` }] }; + } + } +} + +export const executeSequenceTool: ToolCallback = async ({ + actions, + waitForStability: shouldWait = true, +}: { + actions: z.infer[]; + waitForStability?: boolean; +}) => { + const browser = getBrowser(); + const isBrowser = !browser.isAndroid && !browser.isIOS; + + // Capture initial URL/title for diff + const { url: beforeUrl, title: beforeTitle } = isBrowser + ? await browser.execute(() => ({ url: window.location.href, title: document.title })) as { + url: string; + title: string + } + : { url: '', title: '' }; + + // Capture initial elements for diff (browser only) + const initialBrowserElements = isBrowser ? await getInteractableBrowserElements(browser, {}) : []; + const initialElements = initialBrowserElements.map((el) => ({ selector: el.selector, text: el.name })); + + const results: { action: string; durationMs: number }[] = []; + + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + const start = Date.now(); + const result = await dispatchAction(action); + const durationMs = Date.now() - start; + const isError = (result as any).isError === true; + + // Record each sub-action as a step + appendStep( + action.action, + action as Record, + isError ? 'error' : 'ok', + durationMs, + isError ? (result.content.find((c: any) => c.type === 'text') as any)?.text : undefined, + ); + + if (isError) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + completed: i, + total: actions.length, + failed: { + index: i, + action: action.action, + error: (result.content.find((c: any) => c.type === 'text') as any)?.text, + }, + results, + }), + }], + }; + } + + results.push({ action: action.action, durationMs }); + + // Wait for stability after each action (except the last, we do it before diff) + if (shouldWait && i < actions.length - 1 && isBrowser) { + await waitForStability(browser); + } + } + + // Final stability wait before capturing end state + if (shouldWait && isBrowser) { + await waitForStability(browser); + } + + // Capture final elements for state delta (browser only) + const finalBrowserElements = isBrowser ? await getInteractableBrowserElements(browser, {}) : []; + const finalElements = finalBrowserElements.map((el) => ({ selector: el.selector, text: el.name })); + + const delta = isBrowser + ? await captureStateDelta(browser, initialElements, finalElements, beforeUrl, beforeTitle) + : null; + + const response: Record = { + completed: actions.length, + total: actions.length, + results, + }; + if (delta) { + response.delta = delta; + } + + return { + content: [{ type: 'text' as const, text: JSON.stringify(response) }], + }; +}; diff --git a/src/tools/gestures.tool.ts b/src/tools/gestures.tool.ts index 1dac6fa..de02a78 100644 --- a/src/tools/gestures.tool.ts +++ b/src/tools/gestures.tool.ts @@ -2,7 +2,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; import { z } from 'zod'; -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; // Tap Tool export const tapElementToolDefinition: ToolDefinition = { @@ -18,7 +18,7 @@ export const tapElementToolDefinition: ToolDefinition = { }, }; -export const tapElementTool: ToolCallback = async (args: { +export const tapAction = async (args: { selector?: string; x?: number; y?: number; @@ -52,6 +52,12 @@ export const tapElementTool: ToolCallback = async (args: { } }; +export const tapElementTool: ToolCallback = async (args: { + selector?: string; + x?: number; + y?: number; +}): Promise => tapAction(args); + // Swipe Tool export const swipeToolDefinition: ToolDefinition = { name: 'swipe', @@ -82,7 +88,7 @@ const contentToFingerDirection: Record right: 'left', }; -export const swipeTool: ToolCallback = async (args: { +export const swipeAction = async (args: { direction: 'up' | 'down' | 'left' | 'right'; duration?: number; percent?: number; @@ -112,6 +118,12 @@ export const swipeTool: ToolCallback = async (args: { } }; +export const swipeTool: ToolCallback = async (args: { + direction: 'up' | 'down' | 'left' | 'right'; + duration?: number; + percent?: number; +}): Promise => swipeAction(args); + // Drag and Drop Tool export const dragAndDropToolDefinition: ToolDefinition = { name: 'drag_and_drop', @@ -125,7 +137,7 @@ export const dragAndDropToolDefinition: ToolDefinition = { }, }; -export const dragAndDropTool: ToolCallback = async (args: { +export const dragAndDropAction = async (args: { sourceSelector: string; targetSelector?: string; x?: number; @@ -161,4 +173,12 @@ export const dragAndDropTool: ToolCallback = async (args: { content: [{ type: 'text', text: `Error dragging: ${e}` }], }; } -}; \ No newline at end of file +}; + +export const dragAndDropTool: ToolCallback = async (args: { + sourceSelector: string; + targetSelector?: string; + x?: number; + y?: number; + duration?: number; +}): Promise => dragAndDropAction(args); \ No newline at end of file diff --git a/src/tools/get-accessibility-tree.tool.ts b/src/tools/get-accessibility-tree.tool.ts index e63c7cf..37033c0 100644 --- a/src/tools/get-accessibility-tree.tool.ts +++ b/src/tools/get-accessibility-tree.tool.ts @@ -1,49 +1,28 @@ -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; import { getBrowserAccessibilityTree } from '../scripts/get-browser-accessibility-tree'; -import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; -import type { ToolDefinition } from '../types/tool'; import { encode } from '@toon-format/toon'; -import { z } from 'zod'; -export const getAccessibilityToolDefinition: ToolDefinition = { - name: 'get_accessibility', - description: 'Gets the accessibility tree: page structure with headings, landmarks, and semantic roles. Browser-only. Use to understand page layout and context around interactable elements.', - inputSchema: { - limit: z.number().optional() - .describe('Maximum number of nodes to return. Default: 100. Use 0 for unlimited.'), - offset: z.number().optional() - .describe('Number of nodes to skip (for pagination). Default: 0.'), - roles: z.array(z.string()).optional() - .describe('Filter to specific roles (e.g., ["heading", "navigation", "region"]). Default: all roles.'), - }, -}; - -export const getAccessibilityTreeTool: ToolCallback = async (args: { +export async function readAccessibilityTree(params: { limit?: number; offset?: number; roles?: string[]; -}): Promise => { +}): Promise<{ mimeType: string; text: string }> { try { const browser = getBrowser(); if (browser.isAndroid || browser.isIOS) { return { - content: [{ - type: 'text', - text: 'Error: get_accessibility is browser-only. For mobile apps, use get_visible_elements instead.', - }], + mimeType: 'text/plain', + text: 'Error: get_accessibility is browser-only. For mobile apps, use get_visible_elements instead.', }; } - const { limit = 100, offset = 0, roles } = args || {}; + const { limit = 100, offset = 0, roles } = params; let nodes = await getBrowserAccessibilityTree(browser); if (nodes.length === 0) { - return { - content: [{ type: 'text', text: 'No accessibility tree available' }], - }; + return { mimeType: 'text/plain', text: 'No accessibility tree available' }; } // Filter out nodes with no meaningful name @@ -83,13 +62,8 @@ export const getAccessibilityTreeTool: ToolCallback = async (args: { .replace(/,""/g, ',') .replace(/"",/g, ','); - return { - content: [{ type: 'text', text: toon }], - }; + return { mimeType: 'text/plain', text: toon }; } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error getting accessibility tree: ${e}` }], - }; + return { mimeType: 'text/plain', text: `Error getting accessibility tree: ${e}` }; } -}; +} diff --git a/src/tools/get-visible-elements.tool.ts b/src/tools/get-visible-elements.tool.ts index e11b833..e14037d 100644 --- a/src/tools/get-visible-elements.tool.ts +++ b/src/tools/get-visible-elements.tool.ts @@ -1,52 +1,15 @@ -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; import { getInteractableBrowserElements } from '../scripts/get-interactable-browser-elements'; import { getMobileVisibleElements } from '../scripts/get-visible-mobile-elements'; -import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; -import type { ToolDefinition } from '../types/tool'; import { encode } from '@toon-format/toon'; -import { z } from 'zod'; -/** - * Tool definition for get_visible_elements - */ -export const getVisibleElementsToolDefinition: ToolDefinition = { - name: 'get_visible_elements', - description: 'Get interactable elements on the page (buttons, links, inputs). Use get_accessibility for page structure and non-interactable elements.', - inputSchema: { - inViewportOnly: z - .boolean() - .optional() - .describe('Only return elements within the visible viewport. Default: true. Set to false to get ALL elements on the page.'), - includeContainers: z - .boolean() - .optional() - .describe('Mobile only: include layout containers. Default: false.'), - includeBounds: z - .boolean() - .optional() - .describe('Include element bounds/coordinates (x, y, width, height). Default: false.'), - limit: z - .number() - .optional() - .describe('Maximum number of elements to return. Default: 0 (unlimited).'), - offset: z - .number() - .optional() - .describe('Number of elements to skip (for pagination). Default: 0.'), - }, -}; - -/** - * Get visible elements on the current screen - * Supports both web browsers and mobile apps (iOS/Android) - */ -export const getVisibleElementsTool: ToolCallback = async (args: { +export async function readVisibleElements(params: { inViewportOnly?: boolean; includeContainers?: boolean; includeBounds?: boolean; limit?: number; offset?: number; -}) => { +}): Promise<{ mimeType: string; text: string }> { try { const browser = getBrowser(); const { @@ -55,7 +18,7 @@ export const getVisibleElementsTool: ToolCallback = async (args: { includeBounds = false, limit = 0, offset = 0, - } = args || {}; + } = params; let elements: { isInViewport?: boolean }[]; @@ -89,13 +52,8 @@ export const getVisibleElementsTool: ToolCallback = async (args: { // TOON tabular format with post-processing: replace "" with bare commas for efficiency const toon = encode(result).replace(/,""/g, ',').replace(/"",/g, ','); - return { - content: [{ type: 'text', text: toon }], - }; + return { mimeType: 'text/plain', text: toon }; } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error getting visible elements: ${e}` }], - }; + return { mimeType: 'text/plain', text: `Error getting visible elements: ${e}` }; } -}; +} diff --git a/src/tools/navigate.tool.ts b/src/tools/navigate.tool.ts index 7e5e5bd..4c7c5f1 100644 --- a/src/tools/navigate.tool.ts +++ b/src/tools/navigate.tool.ts @@ -1,6 +1,7 @@ -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; import { z } from 'zod'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; export const navigateToolDefinition: ToolDefinition = { @@ -11,7 +12,7 @@ export const navigateToolDefinition: ToolDefinition = { }, }; -export const navigateTool: ToolCallback = async ({ url}: { url: string }) => { +export const navigateAction = async (url: string): Promise => { try { const browser = getBrowser(); await browser.url(url); @@ -25,3 +26,5 @@ export const navigateTool: ToolCallback = async ({ url}: { url: string }) => { }; } }; + +export const navigateTool: ToolCallback = async ({ url}: { url: string }) => navigateAction(url); diff --git a/src/tools/scroll.tool.ts b/src/tools/scroll.tool.ts index f13edb1..4f7bbbe 100644 --- a/src/tools/scroll.tool.ts +++ b/src/tools/scroll.tool.ts @@ -1,7 +1,7 @@ -import { getBrowser } from './browser.tool'; -import { getState } from './app-session.tool'; +import { getBrowser, getState } from '../session/state'; import { z } from 'zod'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; export const scrollToolDefinition: ToolDefinition = { @@ -13,7 +13,7 @@ export const scrollToolDefinition: ToolDefinition = { }, }; -export const scrollTool: ToolCallback = async ({ direction, pixels = 500 }: { direction: 'up' | 'down'; pixels?: number }) => { +export const scrollAction = async (direction: 'up' | 'down', pixels = 500): Promise => { try { const browser = getBrowser(); const state = getState(); @@ -38,4 +38,7 @@ export const scrollTool: ToolCallback = async ({ direction, pixels = 500 }: { di content: [{ type: 'text', text: `Error scrolling: ${e}` }], }; } -}; \ No newline at end of file +}; + +export const scrollTool: ToolCallback = async ({ direction, pixels = 500 }: { direction: 'up' | 'down'; pixels?: number }) => + scrollAction(direction, pixels); \ No newline at end of file diff --git a/src/tools/set-value.tool.ts b/src/tools/set-value.tool.ts index c8fa326..2bb2df4 100644 --- a/src/tools/set-value.tool.ts +++ b/src/tools/set-value.tool.ts @@ -1,6 +1,7 @@ -import { getBrowser } from './browser.tool'; +import { getBrowser } from '../session/state'; import { z } from 'zod'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; import type { ToolDefinition } from '../types/tool'; const defaultTimeout: number = 3000; @@ -16,12 +17,12 @@ export const setValueToolDefinition: ToolDefinition = { }, }; -export const setValueTool: ToolCallback = async ({ selector, value, scrollToView = true, timeout = defaultTimeout}: { - selector: string; - value: string; - scrollToView?: boolean; - timeout?: number -}) => { +export const setValueAction = async ( + selector: string, + value: string, + scrollToView = true, + timeout = defaultTimeout, +): Promise => { try { const browser = getBrowser(); await browser.waitUntil(browser.$(selector).isExisting, { timeout }); @@ -39,4 +40,11 @@ export const setValueTool: ToolCallback = async ({ selector, value, scrollToView content: [{ type: 'text', text: `Error entering text: ${e}` }], }; } -}; \ No newline at end of file +}; + +export const setValueTool: ToolCallback = async ({ selector, value, scrollToView = true, timeout = defaultTimeout}: { + selector: string; + value: string; + scrollToView?: boolean; + timeout?: number +}) => setValueAction(selector, value, scrollToView, timeout); \ No newline at end of file diff --git a/src/tools/take-screenshot.tool.ts b/src/tools/take-screenshot.tool.ts index 7dbf564..1c7c0b4 100644 --- a/src/tools/take-screenshot.tool.ts +++ b/src/tools/take-screenshot.tool.ts @@ -1,21 +1,10 @@ -import { getBrowser } from './browser.tool'; -import { z } from 'zod'; -import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; -import type { ToolDefinition } from '../types/tool'; +import { getBrowser } from '../session/state'; import sharp from 'sharp'; const MAX_DIMENSION = 2000; const MAX_FILE_SIZE_BYTES = 1024 * 1024; // 1MB -export const takeScreenshotToolDefinition: ToolDefinition = { - name: 'take_screenshot', - description: 'captures a screenshot of the current page', - inputSchema: { - outputPath: z.string().optional().describe('Optional path where to save the screenshot. If not provided, returns base64 data.'), - }, -}; - -async function processScreenshot(screenshotBase64: string): Promise<{ data: Buffer; mimeType: string }> { +export async function processScreenshot(screenshotBase64: string): Promise<{ data: Buffer; mimeType: string }> { const inputBuffer = Buffer.from(screenshotBase64, 'base64'); let image = sharp(inputBuffer); const metadata = await image.metadata(); @@ -47,32 +36,13 @@ async function processScreenshot(screenshotBase64: string): Promise<{ data: Buff return { data: outputBuffer, mimeType: 'image/png' }; } -export const takeScreenshotTool: ToolCallback = async ({ outputPath }: { outputPath?: string }) => { +export async function readScreenshot(): Promise<{ mimeType: string; blob: string }> { try { const browser = getBrowser(); const screenshot = await browser.takeScreenshot(); const { data, mimeType } = await processScreenshot(screenshot); - - if (outputPath) { - const fs = await import('node:fs'); - await fs.promises.writeFile(outputPath, data); - const sizeKB = (data.length / 1024).toFixed(1); - return { - content: [{ type: 'text', text: `Screenshot saved to ${outputPath} (${sizeKB}KB, ${mimeType})` }], - }; - } - - const sizeKB = (data.length / 1024).toFixed(1); - return { - content: [ - { type: 'text', text: `Screenshot captured (${sizeKB}KB, ${mimeType}):` }, - { type: 'image', data: data.toString('base64'), mimeType }, - ], - }; + return { mimeType, blob: data.toString('base64') }; } catch (e) { - return { - isError: true, - content: [{ type: 'text', text: `Error taking screenshot: ${(e as Error).message}` }], - }; + return { mimeType: 'text/plain', blob: Buffer.from(`Error: ${e}`).toString('base64') }; } -}; \ No newline at end of file +} diff --git a/src/utils/parse-variables.ts b/src/utils/parse-variables.ts new file mode 100644 index 0000000..0cfc0d1 --- /dev/null +++ b/src/utils/parse-variables.ts @@ -0,0 +1,19 @@ +// Utility to parse URI template variables from MCP resource handlers +export function parseBool(v: string | string[] | undefined, defaultValue: boolean): boolean { + if (v === undefined) return defaultValue; + const s = Array.isArray(v) ? v[0] : v; + return s === 'true' ? true : s === 'false' ? false : defaultValue; +} + +export function parseNumber(v: string | string[] | undefined, defaultValue: number): number { + if (v === undefined) return defaultValue; + const s = Array.isArray(v) ? v[0] : v; + const n = Number.parseInt(s, 10); + return Number.isNaN(n) ? defaultValue : n; +} + +export function parseStringArray(v: string | string[] | undefined): string[] | undefined { + if (v === undefined) return undefined; + if (Array.isArray(v)) return v.flatMap((s) => s.split(',').map((x) => x.trim()).filter(Boolean)); + return v.split(',').map((x) => x.trim()).filter(Boolean); +} diff --git a/src/utils/stability-detector.ts b/src/utils/stability-detector.ts new file mode 100644 index 0000000..48a8fa0 --- /dev/null +++ b/src/utils/stability-detector.ts @@ -0,0 +1,47 @@ +export interface StateSignature { + url: string; + title: string; + elementCount: number; + documentReady: boolean; +} + +const POLL_INTERVAL_MS = 200; +const STABLE_DURATION_MS = 500; +const TIMEOUT_MS = 5000; + +export async function captureSignature(browser: WebdriverIO.Browser): Promise { + return browser.execute(() => ({ + url: window.location.href, + title: document.title, + elementCount: document.querySelectorAll('*').length, + documentReady: document.readyState === 'complete', + })) as Promise; +} + +function signaturesEqual(a: StateSignature, b: StateSignature): boolean { + return a.url === b.url && a.title === b.title && a.elementCount === b.elementCount && a.documentReady === b.documentReady; +} + +export async function waitForStability(browser: WebdriverIO.Browser): Promise { + const deadline = Date.now() + TIMEOUT_MS; + let stableSince: number | null = null; + let last: StateSignature | null = null; + + while (Date.now() < deadline) { + let sig: StateSignature; + try { + sig = await captureSignature(browser); + } catch { + return; // Browser disconnected or session ended — proceed without stability check + } + if (last && signaturesEqual(last, sig)) { + stableSince ??= Date.now(); + if (Date.now() - stableSince >= STABLE_DURATION_MS) return; // stable + } else { + stableSince = null; + } + last = sig; + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + // Timeout — proceed anyway +} diff --git a/src/utils/state-diff.ts b/src/utils/state-diff.ts new file mode 100644 index 0000000..b918697 --- /dev/null +++ b/src/utils/state-diff.ts @@ -0,0 +1,46 @@ +export interface StateDelta { + appeared: string[]; + disappeared: string[]; + changed: string[]; + urlChanged?: string; + titleChanged?: string; +} + +export async function captureStateDelta( + browser: WebdriverIO.Browser, + before: { selector?: string; text?: string }[], + after: { selector?: string; text?: string }[], + beforeUrl?: string, + beforeTitle?: string, +): Promise { + const beforeMap = new Map(); + const afterMap = new Map(); + + for (const el of before) { + if (el.selector) beforeMap.set(el.selector, el.text ?? ''); + } + for (const el of after) { + if (el.selector) afterMap.set(el.selector, el.text ?? ''); + } + + const appeared = [...afterMap.keys()].filter((k) => !beforeMap.has(k)); + const disappeared = [...beforeMap.keys()].filter((k) => !afterMap.has(k)); + const changed = [...afterMap.keys()].filter((k) => beforeMap.has(k) && beforeMap.get(k) !== afterMap.get(k)); + + // Capture current URL/title + const { url, title } = await browser.execute(() => ({ + url: window.location.href, + title: document.title, + })) as { url: string; title: string }; + + const delta: StateDelta = { appeared, disappeared, changed }; + + if (beforeUrl !== undefined && url !== beforeUrl) { + delta.urlChanged = url; + } + if (beforeTitle !== undefined && title !== beforeTitle) { + delta.titleChanged = title; + } + + return delta; +} diff --git a/tests/providers/local-appium.provider.test.ts b/tests/providers/local-appium.provider.test.ts new file mode 100644 index 0000000..d07a96b --- /dev/null +++ b/tests/providers/local-appium.provider.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { localAppiumProvider } from '../../src/providers/local-appium.provider'; + +describe('LocalAppiumProvider', () => { + it('builds iOS capabilities with bundleId', () => { + const caps = localAppiumProvider.buildCapabilities({ + platform: 'iOS', + deviceName: 'iPhone 15', + appPath: '/path/to/app.app', + }); + expect(caps.platformName).toBe('iOS'); + expect(caps['appium:app']).toBe('/path/to/app.app'); + }); + + it('builds Android capabilities', () => { + const caps = localAppiumProvider.buildCapabilities({ + platform: 'Android', + deviceName: 'Pixel 7', + appPath: '/path/to/app.apk', + }); + expect(caps.platformName).toBe('Android'); + }); + + it('getSessionType returns ios for iOS', () => { + expect(localAppiumProvider.getSessionType({ platform: 'iOS' })).toBe('ios'); + }); + + it('shouldAutoDetach true when noReset', () => { + expect(localAppiumProvider.shouldAutoDetach({ noReset: true })).toBe(true); + }); + + it('shouldAutoDetach true when no appPath', () => { + expect(localAppiumProvider.shouldAutoDetach({})).toBe(true); + }); + + it('shouldAutoDetach false when appPath provided', () => { + expect(localAppiumProvider.shouldAutoDetach({ appPath: '/app.apk' })).toBe(false); + }); +}); diff --git a/tests/providers/local-browser.provider.test.ts b/tests/providers/local-browser.provider.test.ts new file mode 100644 index 0000000..b76c32e --- /dev/null +++ b/tests/providers/local-browser.provider.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { localBrowserProvider } from '../../src/providers/local-browser.provider'; + +describe('LocalBrowserProvider', () => { + it('returns empty connection config (local defaults)', () => { + expect(localBrowserProvider.getConnectionConfig({})).toEqual({}); + }); + + it('buildCapabilities: chrome headless includes --headless=new arg', () => { + const caps = localBrowserProvider.buildCapabilities({ browser: 'chrome', headless: true }); + const args = (caps['goog:chromeOptions'] as any)?.args ?? []; + expect(args).toContain('--headless=new'); + }); + + it('buildCapabilities: safari headless ignored (headless not supported)', () => { + const caps = localBrowserProvider.buildCapabilities({ browser: 'safari', headless: true }); + expect(caps.browserName).toBe('safari'); + expect(caps['goog:chromeOptions']).toBeUndefined(); + }); + + it('buildCapabilities: merges user capabilities', () => { + const caps = localBrowserProvider.buildCapabilities({ + browser: 'chrome', + headless: false, + capabilities: { 'goog:chromeOptions': { args: ['--custom-flag'] } }, + }); + const args = (caps['goog:chromeOptions'] as any)?.args ?? []; + expect(args).toContain('--custom-flag'); + }); + + it('getSessionType returns browser', () => { + expect(localBrowserProvider.getSessionType({})).toBe('browser'); + }); + + it('shouldAutoDetach returns false', () => { + expect(localBrowserProvider.shouldAutoDetach({})).toBe(false); + }); +}); diff --git a/tests/recording/resources.test.ts b/tests/recording/resources.test.ts index aec2a5c..ea597ab 100644 --- a/tests/recording/resources.test.ts +++ b/tests/recording/resources.test.ts @@ -1,11 +1,11 @@ // tests/recording/resources.test.ts import { beforeEach, describe, expect, it } from 'vitest'; -import { getBrowser } from '../../src/tools/browser.tool'; +import { getState } from '../../src/session/state'; import type { SessionHistory } from '../../src/types/recording'; import { buildSessionsIndex, buildCurrentSessionSteps, buildSessionStepsById } from '../../src/recording/resources'; function addHistory(sessionId: string, type: 'browser' | 'ios' | 'android', isCurrent = false, ended = false) { - const state = (getBrowser as any).__state; + const state = getState(); const history: SessionHistory = { sessionId, type, @@ -17,13 +17,13 @@ function addHistory(sessionId: string, type: 'browser' | 'ios' | 'android', isCu state.sessionHistory.set(sessionId, history); if (isCurrent) { state.currentSession = sessionId; - state.browsers.set(sessionId, {}); + state.browsers.set(sessionId, {} as WebdriverIO.Browser); } return history; } beforeEach(() => { - const state = (getBrowser as any).__state; + const state = getState(); state.browsers.clear(); state.sessionMetadata.clear(); state.sessionHistory.clear(); diff --git a/tests/recording/step-recorder.test.ts b/tests/recording/step-recorder.test.ts index 0714a9d..1895acb 100644 --- a/tests/recording/step-recorder.test.ts +++ b/tests/recording/step-recorder.test.ts @@ -1,7 +1,7 @@ // tests/recording/step-recorder.test.ts import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; -import { getBrowser } from '../../src/tools/browser.tool'; +import { getState } from '../../src/session/state'; import type { SessionHistory } from '../../src/types/recording'; import { appendStep, withRecording, getSessionHistory } from '../../src/recording/step-recorder'; @@ -9,8 +9,8 @@ const extra = {} as Parameters[1]; type AnyToolFn = (params: Record, extra: unknown) => Promise; function setupSession(sessionId: string) { - const state = (getBrowser as any).__state; - state.browsers.set(sessionId, {}); + const state = getState(); + state.browsers.set(sessionId, {} as WebdriverIO.Browser); state.currentSession = sessionId; state.sessionMetadata.set(sessionId, { type: 'browser', capabilities: {}, isAttached: false }); state.sessionHistory.set(sessionId, { @@ -23,7 +23,7 @@ function setupSession(sessionId: string) { } beforeEach(() => { - const state = (getBrowser as any).__state; + const state = getState(); state.browsers.clear(); state.sessionMetadata.clear(); state.sessionHistory.clear(); diff --git a/tests/session/lifecycle.test.ts b/tests/session/lifecycle.test.ts new file mode 100644 index 0000000..4b7e710 --- /dev/null +++ b/tests/session/lifecycle.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionMetadata } from '../../src/session/state'; +import { getState } from '../../src/session/state'; +import { closeSession, registerSession } from '../../src/session/lifecycle'; +import type { SessionHistory } from '../../src/types/recording'; + +function makeBrowser(overrides: Record = {}) { + return { deleteSession: vi.fn(), ...overrides } as unknown as WebdriverIO.Browser; +} + +beforeEach(() => { + const state = getState(); + state.browsers.clear(); + state.sessionMetadata.clear(); + state.sessionHistory.clear(); + state.currentSession = null; +}); + +describe('registerSession', () => { + it('sets currentSession', () => { + const browser = makeBrowser(); + const meta: SessionMetadata = { type: 'browser', capabilities: {}, isAttached: false }; + const history: SessionHistory = { + sessionId: 's1', + type: 'browser', + startedAt: new Date().toISOString(), + capabilities: {}, + steps: [] + }; + registerSession('s1', browser, meta, history); + expect(getState().currentSession).toBe('s1'); + }); + + it('appends session_transition to previous session', () => { + const state = getState(); + const meta: SessionMetadata = { type: 'browser', capabilities: {}, isAttached: false }; + const h1: SessionHistory = { + sessionId: 's1', + type: 'browser', + startedAt: new Date().toISOString(), + capabilities: {}, + steps: [] + }; + state.browsers.set('s1', makeBrowser()); + state.sessionMetadata.set('s1', meta); + state.sessionHistory.set('s1', h1); + state.currentSession = 's1'; + + const h2: SessionHistory = { + sessionId: 's2', + type: 'browser', + startedAt: new Date().toISOString(), + capabilities: {}, + steps: [] + }; + registerSession('s2', makeBrowser(), meta, h2); + + expect(h1.steps.length).toBe(1); + expect(h1.steps[0].tool).toBe('__session_transition__'); + expect(h1.endedAt).toBeDefined(); + expect(getState().currentSession).toBe('s2'); + }); +}); + +describe('closeSession', () => { + it('calls deleteSession when not detached and not attached', async () => { + const browser = makeBrowser(); + const state = getState(); + const meta: SessionMetadata = { type: 'browser', capabilities: {}, isAttached: false }; + const h: SessionHistory = { + sessionId: 's1', + type: 'browser', + startedAt: new Date().toISOString(), + capabilities: {}, + steps: [] + }; + state.browsers.set('s1', browser); + state.sessionMetadata.set('s1', meta); + state.sessionHistory.set('s1', h); + state.currentSession = 's1'; + + await closeSession('s1', false, false); + expect(browser.deleteSession).toHaveBeenCalled(); + expect(state.currentSession).toBeNull(); + }); + + it('skips deleteSession when detach=true', async () => { + const browser = makeBrowser(); + const state = getState(); + const meta: SessionMetadata = { type: 'browser', capabilities: {}, isAttached: false }; + const h: SessionHistory = { + sessionId: 's1', + type: 'browser', + startedAt: new Date().toISOString(), + capabilities: {}, + steps: [] + }; + state.browsers.set('s1', browser); + state.sessionMetadata.set('s1', meta); + state.sessionHistory.set('s1', h); + state.currentSession = 's1'; + + await closeSession('s1', true, false); + expect(browser.deleteSession).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tools/accessibility-tree-tool.test.ts b/tests/tools/accessibility-tree-tool.test.ts index 66e222a..cd3a98c 100644 --- a/tests/tools/accessibility-tree-tool.test.ts +++ b/tests/tools/accessibility-tree-tool.test.ts @@ -5,15 +5,21 @@ vi.mock('../../src/scripts/get-browser-accessibility-tree', () => ({ getBrowserAccessibilityTree: vi.fn(), })); -vi.mock('../../src/tools/browser.tool', () => ({ +vi.mock('../../src/session/state', () => ({ getBrowser: vi.fn(() => ({ isAndroid: false, isIOS: false })), + getState: vi.fn(() => ({ + browsers: new Map(), + currentSession: null, + sessionMetadata: new Map(), + sessionHistory: new Map(), + })), })); import { getBrowserAccessibilityTree } from '../../src/scripts/get-browser-accessibility-tree'; -import { getAccessibilityTreeTool } from '../../src/tools/get-accessibility-tree.tool'; +import { readAccessibilityTree } from '../../src/tools/get-accessibility-tree.tool'; -type ToolFn = (args: Record) => Promise<{ content: { text: string }[] }>; -const callTool = getAccessibilityTreeTool as unknown as ToolFn; +type ReadFn = (args: Record) => Promise<{ mimeType: string; text: string }>; +const callRead = readAccessibilityTree as unknown as ReadFn; const mockGetTree = getBrowserAccessibilityTree as ReturnType; @@ -34,8 +40,8 @@ beforeEach(() => { describe('column trimming', () => { it('omits state columns when all nodes have empty state', async () => { mockGetTree.mockResolvedValue([makeNode({})]); - const result = await callTool({}); - const text = result.content[0].text; + const result = await callRead({}); + const text = result.text; expect(text).not.toMatch(/\bdisabled\b/); expect(text).not.toMatch(/\bchecked\b/); expect(text).not.toMatch(/\blevel\b/); @@ -46,8 +52,8 @@ describe('column trimming', () => { makeNode({ role: 'heading', name: 'Title', level: 2 }), makeNode({}), ]); - const result = await callTool({}); - const text = result.content[0].text; + const result = await callRead({}); + const text = result.text; expect(text).toMatch(/level/); }); @@ -56,8 +62,8 @@ describe('column trimming', () => { makeNode({ role: 'checkbox', name: 'Accept', checked: 'true' }), makeNode({}), ]); - const result = await callTool({}); - const text = result.content[0].text; + const result = await callRead({}); + const text = result.text; expect(text).toMatch(/checked/); }); }); @@ -68,8 +74,8 @@ describe('filtering', () => { makeNode({ name: '' }), makeNode({ name: 'Visible' }), ]); - const result = await callTool({}); - const text = result.content[0].text; + const result = await callRead({}); + const text = result.text; expect(text).toContain('Visible'); expect(text).toMatch(/total: 1/); }); @@ -79,8 +85,8 @@ describe('filtering', () => { makeNode({ role: 'heading', name: 'Title' }), makeNode({ role: 'link', name: 'Click here' }), ]); - const result = await callTool({ roles: ['heading'] }); - const text = result.content[0].text; + const result = await callRead({ roles: ['heading'] }); + const text = result.text; expect(text).toContain('Title'); expect(text).not.toContain('Click here'); }); @@ -93,8 +99,8 @@ describe('pagination', () => { makeNode({ name: 'B' }), makeNode({ name: 'C' }), ]); - const result = await callTool({ limit: 2 }); - const text = result.content[0].text; + const result = await callRead({ limit: 2 }); + const text = result.text; expect(text).toMatch(/showing: 2/); expect(text).toMatch(/hasMore: true/); }); @@ -104,8 +110,8 @@ describe('pagination', () => { makeNode({ name: 'A' }), makeNode({ name: 'B' }), ]); - const result = await callTool({ offset: 1, limit: 0 }); - const text = result.content[0].text; + const result = await callRead({ offset: 1, limit: 0 }); + const text = result.text; expect(text).toMatch(/showing: 1/); expect(text).toContain('B'); }); diff --git a/tests/tools/attach-browser-tool.test.ts b/tests/tools/attach-browser-tool.test.ts index 71f63a7..9031a36 100644 --- a/tests/tools/attach-browser-tool.test.ts +++ b/tests/tools/attach-browser-tool.test.ts @@ -20,24 +20,28 @@ vi.mock('webdriverio', () => ({ remote: vi.fn().mockResolvedValue(mockBrowser), })); -vi.mock('../../src/tools/browser.tool', () => { - const state = { - browsers: new Map(), - currentSession: null as string | null, - sessionMetadata: new Map(), - sessionHistory: new Map(), - }; - const getBrowser = vi.fn(() => { - const b = state.browsers.get(state.currentSession); - if (!b) throw new Error('No active browser session'); - return b; - }); - (getBrowser as any).__state = state; - return { getBrowser }; -}); +const mockState = vi.hoisted(() => ({ + browsers: new Map(), + currentSession: null as string | null, + sessionMetadata: new Map(), + sessionHistory: new Map(), +})); + +vi.mock('../../src/session/state', () => ({ + getState: vi.fn(() => mockState), +})); + +vi.mock('../../src/session/lifecycle', () => ({ + registerSession: vi.fn((sessionId, browser, metadata, historyEntry) => { + mockState.browsers.set(sessionId, browser); + mockState.sessionMetadata.set(sessionId, metadata); + mockState.sessionHistory.set(sessionId, historyEntry); + mockState.currentSession = sessionId; + }), +})); import { remote } from 'webdriverio'; -import { getBrowser } from '../../src/tools/browser.tool'; +import { getState } from '../../src/session/state'; import { attachBrowserTool } from '../../src/tools/attach-browser.tool'; type ToolFn = (args: Record) => Promise<{ content: { text: string }[] }>; @@ -48,11 +52,10 @@ const mockRemote = remote as ReturnType; beforeEach(() => { vi.clearAllMocks(); - const state = (getBrowser as any).__state; - state.browsers.clear(); - state.sessionMetadata.clear(); - state.sessionHistory.clear(); - state.currentSession = null; + mockState.browsers.clear(); + mockState.sessionMetadata.clear(); + mockState.sessionHistory.clear(); + mockState.currentSession = null; mockRemote.mockResolvedValue(mockBrowser); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, @@ -85,7 +88,7 @@ describe('attach_browser', () => { it('registers session in state with isAttached: true', async () => { await callTool(); - const state = (getBrowser as any).__state; + const state = getState(); expect(state.currentSession).toBe('attached-session-id'); expect(state.sessionMetadata.get('attached-session-id')).toMatchObject({ type: 'browser', @@ -171,7 +174,7 @@ describe('attach_browser', () => { it('initialises sessionHistory with constructed caps and empty steps', async () => { await callTool({ host: 'myhost', port: 9333 }); - const state = (getBrowser as any).__state; + const state = getState(); const history = state.sessionHistory.get('attached-session-id'); expect(history).toBeDefined(); expect(history.steps).toEqual([]); diff --git a/tests/tools/close-session.test.ts b/tests/tools/close-session.test.ts index d59c005..e93e64e 100644 --- a/tests/tools/close-session.test.ts +++ b/tests/tools/close-session.test.ts @@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SessionHistory } from '../../src/types/recording'; // No mock of browser.tool — closeSessionTool reads from the module-level state directly. -// We inject test sessions via getBrowser().__state, which IS the module-level state object. -import { closeSessionTool, getBrowser } from '../../src/tools/browser.tool'; +// We inject test sessions via getState(), which IS the module-level state object. +import { closeSessionTool } from '../../src/tools/browser.tool'; +import { getState } from '../../src/session/state'; type ToolFn = (args: Record) => Promise<{ content: { text: string }[] }>; const callClose = closeSessionTool as unknown as ToolFn; @@ -11,8 +12,8 @@ const callClose = closeSessionTool as unknown as ToolFn; const mockDeleteSession = vi.fn(); function setupSession(sessionId: string, isAttached: boolean) { - const state = (getBrowser as any).__state; - state.browsers.set(sessionId, { deleteSession: mockDeleteSession }); + const state = getState(); + state.browsers.set(sessionId, { deleteSession: mockDeleteSession } as unknown as WebdriverIO.Browser); state.currentSession = sessionId; state.sessionMetadata.set(sessionId, { type: 'browser', capabilities: {}, isAttached }); state.sessionHistory.set(sessionId, { @@ -26,7 +27,7 @@ function setupSession(sessionId: string, isAttached: boolean) { beforeEach(() => { vi.clearAllMocks(); - const state = (getBrowser as any).__state; + const state = getState(); state.browsers.clear(); state.sessionMetadata.clear(); state.sessionHistory.clear(); @@ -55,7 +56,7 @@ describe('close_session', () => { it('cleans up local state in both cases', async () => { setupSession('sess-2', true); await callClose({}); - const state = (getBrowser as any).__state; + const state = getState(); expect(state.currentSession).toBeNull(); expect(state.browsers.has('sess-2')).toBe(false); }); @@ -65,7 +66,7 @@ describe('close_session sessionHistory', () => { it('sets endedAt on the session history when session closes', async () => { setupSession('sess-history', false); await callClose({}); - const state = (getBrowser as any).__state; + const state = getState(); const history = state.sessionHistory.get('sess-history'); expect(history).toBeDefined(); expect(history.endedAt).toBeDefined(); @@ -75,7 +76,7 @@ describe('close_session sessionHistory', () => { it('retains sessionHistory after session is closed (browsers entry removed)', async () => { setupSession('sess-retain', false); await callClose({}); - const state = (getBrowser as any).__state; + const state = getState(); expect(state.browsers.has('sess-retain')).toBe(false); expect(state.sessionHistory.has('sess-retain')).toBe(true); }); diff --git a/tests/tools/emulate-device-tool.test.ts b/tests/tools/emulate-device-tool.test.ts index d911c40..7173b46 100644 --- a/tests/tools/emulate-device-tool.test.ts +++ b/tests/tools/emulate-device-tool.test.ts @@ -10,20 +10,25 @@ const mockBrowser = vi.hoisted(() => ({ emulate: mockEmulate, })); -vi.mock('../../src/tools/browser.tool', () => { - const state = { - browsers: new Map(), - currentSession: 'test-session' as string | null, - sessionMetadata: new Map([ - ['test-session', { type: 'browser', capabilities: {}, isAttached: false }], - ]), - }; - const getBrowser = vi.fn(() => mockBrowser); - (getBrowser as any).__state = state; - return { getBrowser }; -}); +const mockState = vi.hoisted(() => ({ + browsers: new Map([['test-session', mockBrowser]]) as Map, + currentSession: 'test-session' as string | null, + sessionMetadata: new Map([ + ['test-session', { type: 'browser', capabilities: {}, isAttached: false }], + ]), + sessionHistory: new Map(), +})); + +vi.mock('../../src/session/state', () => ({ + getBrowser: vi.fn(() => { + const b = mockState.browsers.get(mockState.currentSession); + if (!b) throw new Error('No active browser session'); + return b; + }), + getState: vi.fn(() => mockState), +})); -import { getBrowser } from '../../src/tools/browser.tool'; +import { getState } from '../../src/session/state'; import { emulateDeviceTool } from '../../src/tools/emulate-device.tool'; type ToolFn = (args: Record) => Promise<{ content: { text: string }[] }>; @@ -35,9 +40,8 @@ beforeEach(() => { mockBrowser.isAndroid = false; mockBrowser.isIOS = false; mockEmulate.mockResolvedValue(mockRestoreFn); - const state = (getBrowser as any).__state; - state.currentSession = 'test-session'; - state.sessionMetadata.set('test-session', { type: 'browser', capabilities: {}, isAttached: false }); + mockState.currentSession = 'test-session'; + mockState.sessionMetadata.set('test-session', { type: 'browser', capabilities: {}, isAttached: false }); }); describe('emulate_device — listing', () => { @@ -81,7 +85,7 @@ describe('emulate_device — guards', () => { }); it('returns error for iOS session', async () => { - const state = (getBrowser as any).__state; + const state = getState(); state.sessionMetadata.set('test-session', { type: 'ios', capabilities: {}, isAttached: false }); const result = await callTool({ device: 'iPhone 15' }); expect(result.content[0].text).toContain('Error'); diff --git a/tests/tools/execute-sequence.test.ts b/tests/tools/execute-sequence.test.ts new file mode 100644 index 0000000..3ee4a9a --- /dev/null +++ b/tests/tools/execute-sequence.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getState } from '../../src/session/state'; +import type { SessionHistory } from '../../src/types/recording'; +import { executeSequenceTool } from '../../src/tools/execute-sequence.tool'; + +const callTool = executeSequenceTool as unknown as (args: Record) => Promise<{ + content: { text: string }[]; + isError?: boolean +}>; + +// Mock action functions +vi.mock('../../src/tools/click.tool', () => ({ + clickAction: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'clicked' }] }), + clickTool: vi.fn(), + clickToolDefinition: { name: 'click_element', description: '', inputSchema: {} }, +})); + +vi.mock('../../src/tools/navigate.tool', () => ({ + navigateAction: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'navigated' }] }), + navigateTool: vi.fn(), + navigateToolDefinition: { name: 'navigate', description: '', inputSchema: {} }, +})); + +vi.mock('../../src/tools/set-value.tool', () => ({ + setValueAction: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'set' }] }), + setValueTool: vi.fn(), + setValueToolDefinition: { name: 'set_value', description: '', inputSchema: {} }, +})); + +vi.mock('../../src/tools/scroll.tool', () => ({ + scrollAction: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'scrolled' }] }), + scrollTool: vi.fn(), + scrollToolDefinition: { name: 'scroll', description: '', inputSchema: {} }, +})); + +vi.mock('../../src/tools/gestures.tool', () => ({ + tapAction: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'tapped' }] }), + swipeAction: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'swiped' }] }), + dragAndDropAction: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'dragged' }] }), + tapElementTool: vi.fn(), + swipeTool: vi.fn(), + dragAndDropTool: vi.fn(), + tapElementToolDefinition: { name: 'tap_element', description: '', inputSchema: {} }, + swipeToolDefinition: { name: 'swipe', description: '', inputSchema: {} }, + dragAndDropToolDefinition: { name: 'drag_and_drop', description: '', inputSchema: {} }, +})); + +// Mock stability detector (no-op) +vi.mock('../../src/utils/stability-detector', () => ({ + waitForStability: vi.fn().mockResolvedValue(undefined), +})); + +// Mock state-diff +vi.mock('../../src/utils/state-diff', () => ({ + captureStateDelta: vi.fn().mockResolvedValue({ appeared: [], disappeared: [], changed: [] }), +})); + +// Mock get-interactable-browser-elements +vi.mock('../../src/scripts/get-interactable-browser-elements', () => ({ + getInteractableBrowserElements: vi.fn().mockResolvedValue([]), +})); + +function setupBrowserSession(sessionId = 'sess-1') { + const state = getState(); + const mockBrowser = { + isAndroid: false, + isIOS: false, + execute: vi.fn().mockResolvedValue({ url: 'http://example.com', title: 'Test' }), + }; + state.browsers.set(sessionId, mockBrowser as any); + state.currentSession = sessionId; + state.sessionMetadata.set(sessionId, { type: 'browser', capabilities: {}, isAttached: false }); + state.sessionHistory.set(sessionId, { + sessionId, type: 'browser', startedAt: new Date().toISOString(), capabilities: {}, steps: [], + } as SessionHistory); + return mockBrowser; +} + +beforeEach(() => { + vi.clearAllMocks(); + const state = getState(); + state.browsers.clear(); + state.sessionMetadata.clear(); + state.sessionHistory.clear(); + state.currentSession = null; +}); + +describe('execute_sequence', () => { + it('dispatches click action', async () => { + setupBrowserSession(); + const { clickAction } = await import('../../src/tools/click.tool'); + const result = await callTool({ actions: [{ action: 'click', selector: '#btn' }], waitForStability: false }); + expect(clickAction).toHaveBeenCalledWith('#btn', 3000, undefined); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.completed).toBe(1); + }); + + it('dispatches navigate action', async () => { + setupBrowserSession(); + const { navigateAction } = await import('../../src/tools/navigate.tool'); + await callTool({ actions: [{ action: 'navigate', url: 'https://example.com' }], waitForStability: false }); + expect(navigateAction).toHaveBeenCalledWith('https://example.com'); + }); + + it('stops on first failure', async () => { + setupBrowserSession(); + const { clickAction } = await import('../../src/tools/click.tool'); + const { navigateAction } = await import('../../src/tools/navigate.tool'); + (clickAction as any).mockResolvedValueOnce({ isError: true, content: [{ type: 'text', text: 'Element not found' }] }); + const result = await callTool({ + actions: [ + { action: 'click', selector: '#missing' }, + { action: 'navigate', url: 'https://example.com' }, + ], + waitForStability: false, + }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.completed).toBe(0); + expect(parsed.failed.index).toBe(0); + expect(parsed.failed.error).toContain('Element not found'); + expect(navigateAction).not.toHaveBeenCalled(); + }); + + it('records steps via appendStep', async () => { + setupBrowserSession(); + await callTool({ + actions: [{ action: 'navigate', url: 'https://a.com' }, { action: 'click', selector: '#b' }], + waitForStability: false + }); + const state = getState(); + const steps = state.sessionHistory.get('sess-1')?.steps ?? []; + expect(steps.length).toBeGreaterThanOrEqual(2); + expect(steps[0].tool).toBe('navigate'); + expect(steps[1].tool).toBe('click'); + }); + + it('includes state delta in response', async () => { + setupBrowserSession(); + const { captureStateDelta } = await import('../../src/utils/state-diff'); + (captureStateDelta as any).mockResolvedValueOnce({ appeared: ['#new-btn'], disappeared: [], changed: [] }); + const result = await callTool({ + actions: [{ action: 'navigate', url: 'https://example.com' }], + waitForStability: false + }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.delta).toBeDefined(); + expect(parsed.delta.appeared).toContain('#new-btn'); + }); +}); + +describe('execute_sequence — stability', () => { + it('calls waitForStability between actions when enabled', async () => { + setupBrowserSession(); + const { waitForStability } = await import('../../src/utils/stability-detector'); + await callTool({ + actions: [{ action: 'navigate', url: 'https://a.com' }, { action: 'click', selector: '#b' }], + waitForStability: true + }); + expect(waitForStability).toHaveBeenCalled(); + }); + + it('skips waitForStability when disabled', async () => { + setupBrowserSession(); + const { waitForStability } = await import('../../src/utils/stability-detector'); + await callTool({ + actions: [{ action: 'navigate', url: 'https://a.com' }, { action: 'click', selector: '#b' }], + waitForStability: false + }); + expect(waitForStability).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tools/switch-tab.test.ts b/tests/tools/switch-tab.test.ts new file mode 100644 index 0000000..ad0e7b2 --- /dev/null +++ b/tests/tools/switch-tab.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getState } from '../../src/session/state'; +import { switchTabTool } from '../../src/tools/browser.tool'; + +const callTool = switchTabTool as unknown as (args: Record) => Promise<{ + content: { text: string }[]; + isError?: boolean +}>; + +const mockGetWindowHandles = vi.fn(); +const mockGetWindowHandle = vi.fn(); +const mockSwitchToWindow = vi.fn(); + +vi.mock('../../src/session/state', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + }; +}); + +vi.mock('webdriverio', () => ({ remote: vi.fn() })); +vi.mock('../../src/session/lifecycle', () => ({ + registerSession: vi.fn(), + closeSession: vi.fn(), +})); +vi.mock('../../src/providers/local-browser.provider', () => ({ + localBrowserProvider: { buildCapabilities: vi.fn(() => ({})) }, +})); + +function setupSession(sessionId: string) { + const state = getState(); + state.browsers.set(sessionId, { + getWindowHandles: mockGetWindowHandles, + getWindowHandle: mockGetWindowHandle, + switchToWindow: mockSwitchToWindow, + } as any); + state.currentSession = sessionId; + state.sessionMetadata.set(sessionId, { type: 'browser', capabilities: {}, isAttached: false }); + state.sessionHistory.set(sessionId, { + sessionId, + type: 'browser', + startedAt: new Date().toISOString(), + capabilities: {}, + steps: [] + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + const state = getState(); + state.browsers.clear(); + state.sessionMetadata.clear(); + state.sessionHistory.clear(); + state.currentSession = null; +}); + +describe('switch_tab', () => { + it('switches by handle', async () => { + setupSession('s1'); + mockSwitchToWindow.mockResolvedValue(undefined); + const result = await callTool({ handle: 'handle-1' }); + expect(mockSwitchToWindow).toHaveBeenCalledWith('handle-1'); + expect(result.content[0].text).toContain('handle-1'); + }); + + it('switches by index', async () => { + setupSession('s2'); + mockGetWindowHandles.mockResolvedValue(['h0', 'h1', 'h2']); + mockSwitchToWindow.mockResolvedValue(undefined); + const result = await callTool({ index: 1 }); + expect(mockSwitchToWindow).toHaveBeenCalledWith('h1'); + expect(result.isError).toBeFalsy(); + }); + + it('returns error for out of range index', async () => { + setupSession('s3'); + mockGetWindowHandles.mockResolvedValue(['h0', 'h1']); + const result = await callTool({ index: 5 }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('out of range'); + }); + + it('returns error when neither handle nor index provided', async () => { + setupSession('s4'); + const result = await callTool({}); + expect(result.isError).toBe(true); + }); +}); diff --git a/tests/utils/stability-detector.test.ts b/tests/utils/stability-detector.test.ts new file mode 100644 index 0000000..1fdecea --- /dev/null +++ b/tests/utils/stability-detector.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { captureSignature, waitForStability } from '../../src/utils/stability-detector'; + +function makeBrowser(signatures: object[]) { + let idx = 0; + return { + execute: vi.fn().mockImplementation(() => Promise.resolve(signatures[Math.min(idx++, signatures.length - 1)])), + } as unknown as WebdriverIO.Browser; +} + +describe('waitForStability', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves when signature is stable for 500ms', async () => { + const stable = { url: 'https://a.com', title: 'A', elementCount: 10, documentReady: true }; + // Return same signature 5+ times to trigger stability + const browser = makeBrowser(Array(10).fill(stable)); + + const p = waitForStability(browser); + // Advance time by 1500ms in 200ms increments to let the polling happen + for (let i = 0; i < 8; i++) { + await vi.advanceTimersByTimeAsync(200); + } + await p; + // If we get here without timeout, the test passes + expect(true).toBe(true); + }); +}); + +describe('captureSignature', () => { + it('captures url, title, elementCount, documentReady', async () => { + const expected = { url: 'https://x.com', title: 'X', elementCount: 42, documentReady: true }; + const browser = { execute: vi.fn().mockResolvedValue(expected) } as unknown as WebdriverIO.Browser; + const sig = await captureSignature(browser); + expect(sig).toEqual(expected); + }); +}); diff --git a/tests/utils/state-diff.test.ts b/tests/utils/state-diff.test.ts new file mode 100644 index 0000000..912c097 --- /dev/null +++ b/tests/utils/state-diff.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; +import { captureStateDelta } from '../../src/utils/state-diff'; + +function makeBrowser() { + return { + execute: vi.fn().mockResolvedValue({ url: 'https://example.com', title: 'Test' }), + } as unknown as WebdriverIO.Browser; +} + +describe('captureStateDelta', () => { + it('detects appeared elements', async () => { + const browser = makeBrowser(); + const before = [{ selector: '#old', text: 'Old' }]; + const after = [{ selector: '#old', text: 'Old' }, { selector: '#new', text: 'New' }]; + const delta = await captureStateDelta(browser, before, after); + expect(delta.appeared).toContain('#new'); + expect(delta.disappeared).toHaveLength(0); + }); + + it('detects disappeared elements', async () => { + const browser = makeBrowser(); + const before = [{ selector: '#gone', text: 'Gone' }, { selector: '#stays', text: 'Stays' }]; + const after = [{ selector: '#stays', text: 'Stays' }]; + const delta = await captureStateDelta(browser, before, after); + expect(delta.disappeared).toContain('#gone'); + expect(delta.appeared).toHaveLength(0); + }); + + it('detects changed element text', async () => { + const browser = makeBrowser(); + const before = [{ selector: '#el', text: 'before' }]; + const after = [{ selector: '#el', text: 'after' }]; + const delta = await captureStateDelta(browser, before, after); + expect(delta.changed).toContain('#el'); + }); + + it('returns empty delta when nothing changed', async () => { + const browser = makeBrowser(); + const elems = [{ selector: '#x', text: 'same' }]; + const delta = await captureStateDelta(browser, elems, [...elems]); + expect(delta.appeared).toHaveLength(0); + expect(delta.disappeared).toHaveLength(0); + expect(delta.changed).toHaveLength(0); + }); + + it('reports urlChanged when URL changes', async () => { + const browser = { + execute: vi.fn().mockResolvedValue({ url: 'https://new.com', title: 'New' }), + } as unknown as WebdriverIO.Browser; + const delta = await captureStateDelta(browser, [], [], 'https://old.com', 'Old'); + expect(delta.urlChanged).toBe('https://new.com'); + expect(delta.titleChanged).toBe('New'); + }); + + it('omits urlChanged when URL is the same', async () => { + const browser = { + execute: vi.fn().mockResolvedValue({ url: 'https://same.com', title: 'Same' }), + } as unknown as WebdriverIO.Browser; + const delta = await captureStateDelta(browser, [], [], 'https://same.com', 'Same'); + expect(delta.urlChanged).toBeUndefined(); + expect(delta.titleChanged).toBeUndefined(); + }); +});