From 7817082f1d992b065dac7d97bddf096c03a17102 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Thu, 14 May 2026 18:24:50 +0300 Subject: [PATCH 1/2] feat: implement the REPL integration --- packages/cli/src/daemon/request-handler.ts | 40 +- packages/cli/src/daemon/session-registry.ts | 26 +- .../cli/test/daemon/request-handler.test.ts | 69 +++ packages/mcp/src/server.ts | 7 +- packages/mcp/test/server.e2e.test.ts | 1 + packages/tools/src/browser/repl-browser.ts | 99 +++++ packages/tools/src/browser/repl-connection.ts | 406 ++++++++++++++++++ packages/tools/src/browser/types.ts | 29 ++ packages/tools/src/index.ts | 7 + .../tools/src/responses/browser-helpers.ts | 19 +- packages/tools/src/responses/index.ts | 12 +- packages/tools/src/tools/attach-repl.ts | 50 +++ packages/tools/src/tools/attach-to-browser.ts | 1 + packages/tools/src/tools/browser-console.ts | 1 + packages/tools/src/tools/click-on-element.ts | 1 + packages/tools/src/tools/close-tab.ts | 1 + packages/tools/src/tools/hover-element.ts | 1 + packages/tools/src/tools/launch-browser.ts | 1 + packages/tools/src/tools/list-tabs.ts | 1 + packages/tools/src/tools/navigate.ts | 1 + packages/tools/src/tools/open-new-tab.ts | 1 + packages/tools/src/tools/run-code.ts | 14 +- packages/tools/src/tools/select-option.ts | 1 + packages/tools/src/tools/switch-to-tab.ts | 1 + .../tools/src/tools/take-page-snapshot.ts | 6 +- .../src/tools/take-viewport-screenshot.ts | 1 + packages/tools/src/tools/type-into-element.ts | 1 + packages/tools/src/tools/wait-for-element.ts | 1 + packages/tools/src/types.ts | 18 +- .../tools/test/browser/repl-browser.test.ts | 70 +++ .../test/browser/repl-connection.test.ts | 152 +++++++ packages/tools/test/tools/attach-repl.test.ts | 73 ++++ packages/tools/test/tools/run-code.test.ts | 20 +- 33 files changed, 1086 insertions(+), 46 deletions(-) create mode 100644 packages/tools/src/browser/repl-browser.ts create mode 100644 packages/tools/src/browser/repl-connection.ts create mode 100644 packages/tools/src/browser/types.ts create mode 100644 packages/tools/src/tools/attach-repl.ts create mode 100644 packages/tools/test/browser/repl-browser.test.ts create mode 100644 packages/tools/test/browser/repl-connection.test.ts create mode 100644 packages/tools/test/tools/attach-repl.test.ts diff --git a/packages/cli/src/daemon/request-handler.ts b/packages/cli/src/daemon/request-handler.ts index 659276f..f1fec97 100644 --- a/packages/cli/src/daemon/request-handler.ts +++ b/packages/cli/src/daemon/request-handler.ts @@ -16,6 +16,21 @@ function createNoActiveBrowserResponse(toolName: string): ResponseResult["conten ]; } +function createUnsupportedReplToolResponse(toolName: string): ResponseResult["content"] { + return [ + { + type: "text", + text: `Tool '${toolName}' is not yet supported with REPL sessions. Currently supported in REPL mode: snapshot, run-code.`, + }, + ]; +} + +async function closeBrowserSession( + browser: NonNullable["browser"]>, +): Promise { + await browser.deleteSession(); +} + /** * Handles requests from clients, executing tools in sessions as needed. */ @@ -88,10 +103,29 @@ export class RequestHandler { debug("Auto-launching browser: session=%s tool=%s", req.sessionName, req.tool); state.browser = await launchBrowserWithOptions(state.defaultOptions); + state.transport = "launch-browser"; + } + + const supportedTransports = tool.supportedTransports ?? ["launch-browser"]; + const transport = state.transport ?? "launch-browser"; + if (!supportedTransports.includes(transport)) { + debug( + "Action is not supported with current transport: session=%s tool=%s transport=%s", + req.sessionName, + req.tool, + transport, + ); + + return { + id: req.id, + kind: "result", + content: createUnsupportedReplToolResponse(tool.name), + isError: true, + }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await tool.cb(parsedArgs as any, state.browser); + const result = await tool.cb(parsedArgs as any, state.browser as never); return { id: req.id, kind: "result", content: result.content, isError: result.isError }; } @@ -101,7 +135,7 @@ export class RequestHandler { debug("Replacing existing browser session: session=%s", req.sessionName); try { - await state.browser.deleteSession(); + await closeBrowserSession(state.browser); } catch (error) { debug( "Error closing previous session: session=%s message=%s", @@ -111,12 +145,14 @@ export class RequestHandler { } state.browser = null; + state.transport = null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const openResult = await tool.cb(parsedArgs as any, state.options); if (openResult.browser) { state.browser = openResult.browser; + state.transport = openResult.transport ?? "launch-browser"; state.options = openResult.options; } diff --git a/packages/cli/src/daemon/session-registry.ts b/packages/cli/src/daemon/session-registry.ts index fc99b32..5a53876 100644 --- a/packages/cli/src/daemon/session-registry.ts +++ b/packages/cli/src/daemon/session-registry.ts @@ -1,12 +1,12 @@ -import type { WdioBrowser } from "testplane"; -import type { BrowserOptions } from "@testplane/tools"; +import type { BrowserOptions, BrowserSession, TransportKind } from "@testplane/tools"; import makeDebug from "debug"; const debug = makeDebug("testplane-cli:daemon:session-registry"); export interface SessionState { - browser: WdioBrowser | null; + browser: BrowserSession | null; + transport: TransportKind | null; defaultOptions: BrowserOptions; options: BrowserOptions; activeInteractions: number; @@ -40,6 +40,7 @@ export class SessionRegistry { if (!state) { state = { browser: null, + transport: null, defaultOptions: { ...this._defaultOptions }, options: { ...this._defaultOptions }, activeInteractions: 0, @@ -67,6 +68,7 @@ export class SessionRegistry { public clearBrowser(sessionName: string, state: SessionState): void { state.browser = null; + state.transport = null; this._cancelExpirationTimer(sessionName, state); } @@ -99,7 +101,7 @@ export class SessionRegistry { debug("Stale session detected, clearing: session=%s", sessionName); try { - await browser.deleteSession(); + await this._closeBrowserSession(browser); } catch (error) { debug("Stale session cleanup error: session=%s message=%s", sessionName, formatError(error)); } @@ -119,7 +121,7 @@ export class SessionRegistry { debug("Closing session: session=%s", sessionName); try { - await state.browser.deleteSession(); + await this._closeBrowserSession(state.browser); } catch (error) { debug("Session cleanup error: session=%s message=%s", sessionName, formatError(error)); } @@ -152,7 +154,11 @@ export class SessionRegistry { state.expirationTimer = null; } - private async _expireSession(sessionName: string, state: SessionState, browser: WdioBrowser | null): Promise { + private async _expireSession( + sessionName: string, + state: SessionState, + browser: BrowserSession | null, + ): Promise { state.expirationTimer = null; if (!browser || state.browser !== browser || state.activeInteractions > 0) { @@ -165,13 +171,13 @@ export class SessionRegistry { state.browser = null; try { - await browser.deleteSession(); + await this._closeBrowserSession(browser); } catch (error) { debug("Session expiration cleanup error: session=%s message=%s", sessionName, formatError(error)); } } - private async _isSessionAlive(sessionName: string, browser: WdioBrowser): Promise { + private async _isSessionAlive(sessionName: string, browser: BrowserSession): Promise { try { await browser.getUrl(); } catch (error) { @@ -182,4 +188,8 @@ export class SessionRegistry { return true; } + + private async _closeBrowserSession(browser: BrowserSession): Promise { + await browser.deleteSession(); + } } diff --git a/packages/cli/test/daemon/request-handler.test.ts b/packages/cli/test/daemon/request-handler.test.ts index d7004af..cdc5b98 100644 --- a/packages/cli/test/daemon/request-handler.test.ts +++ b/packages/cli/test/daemon/request-handler.test.ts @@ -8,11 +8,15 @@ const mockedTools = vi.hoisted(() => { const launchBrowserWithOptions = vi.fn(); const navigateCb = vi.fn(); const clickCb = vi.fn(); + const snapshotCb = vi.fn(); + const runCodeCb = vi.fn(); return { launchBrowserWithOptions, navigateCb, clickCb, + snapshotCb, + runCodeCb, }; }); @@ -33,6 +37,7 @@ vi.mock("@testplane/tools", () => { autoLaunchBrowser: true, name: "navigate", description: "navigate", + supportedTransports: ["launch-browser"], schema: {}, cb: mockedTools.navigateCb, }, @@ -40,9 +45,26 @@ vi.mock("@testplane/tools", () => { kind: ToolKind.Action, name: "click", description: "click", + supportedTransports: ["launch-browser"], schema: {}, cb: mockedTools.clickCb, }, + { + kind: ToolKind.Action, + name: "snapshot", + description: "snapshot", + supportedTransports: ["launch-browser", "attach-repl"], + schema: {}, + cb: mockedTools.snapshotCb, + }, + { + kind: ToolKind.Action, + name: "run-code", + description: "run-code", + supportedTransports: ["launch-browser", "attach-repl"], + schema: {}, + cb: mockedTools.runCodeCb, + }, ], }; }); @@ -79,6 +101,8 @@ describe("daemon/RequestHandler", () => { mockedTools.launchBrowserWithOptions.mockResolvedValue(browser); mockedTools.navigateCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false }); mockedTools.clickCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false }); + mockedTools.snapshotCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false }); + mockedTools.runCodeCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false }); }); it("does not auto-launch browser for action tools that require an existing session", async () => { @@ -106,4 +130,49 @@ describe("daemon/RequestHandler", () => { expect(response.isError).toBe(false); } }); + + it("returns a clear error for unsupported action tools in REPL sessions", async () => { + const state = sessions.getOrCreate("default"); + state.browser = browser; + state.transport = "attach-repl"; + + const response = await handler.handleRequest(createRequest("click"), sessions); + + expect(mockedTools.clickCb).not.toHaveBeenCalled(); + expect(response.kind).toBe("result"); + if (response.kind === "result") { + expect(response.isError).toBe(true); + expect(response.content[0].text).toBe( + "Tool 'click' is not yet supported with REPL sessions. Currently supported in REPL mode: snapshot, run-code.", + ); + } + }); + + it("allows snapshot in REPL sessions", async () => { + const state = sessions.getOrCreate("default"); + state.browser = browser; + state.transport = "attach-repl"; + + const response = await handler.handleRequest(createRequest("snapshot"), sessions); + + expect(mockedTools.snapshotCb).toHaveBeenCalledWith({}, browser); + expect(response.kind).toBe("result"); + if (response.kind === "result") { + expect(response.isError).toBe(false); + } + }); + + it("allows run-code in REPL sessions", async () => { + const state = sessions.getOrCreate("default"); + state.browser = browser; + state.transport = "attach-repl"; + + const response = await handler.handleRequest(createRequest("run-code"), sessions); + + expect(mockedTools.runCodeCb).toHaveBeenCalledWith({}, browser); + expect(response.kind).toBe("result"); + if (response.kind === "result") { + expect(response.isError).toBe(false); + } + }); }); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 88c1772..426f3b2 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,9 +1,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { ZodRawShape } from "zod"; -import type { WdioBrowser } from "testplane"; -import { tools, ToolKind, launchBrowserWithOptions, type BrowserOptions } from "@testplane/tools"; +import { tools, ToolKind, launchBrowserWithOptions, type BrowserOptions, type BrowserSession } from "@testplane/tools"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; @@ -35,7 +34,7 @@ export interface ServerOptions { } export async function startServer(serverOptions: ServerOptions = {}): Promise { - let browser: WdioBrowser | null = null; + let browser: BrowserSession | null = null; const defaultOptions: BrowserOptions = { headless: serverOptions.headless ?? false }; let sessionOptions: BrowserOptions = { ...defaultOptions }; @@ -64,7 +63,7 @@ export async function startServer(serverOptions: ServerOptions = {}): Promise; + sendRaw(code: string): Promise; + close(): Promise; +} + +export interface ReplCodeRunner { + runCodeInRepl(source: string): Promise; +} + +export function isReplCodeRunner(browser: unknown): browser is ReplCodeRunner { + return ( + Boolean(browser) && + typeof browser === "object" && + typeof (browser as { runCodeInRepl?: unknown }).runCodeInRepl === "function" + ); +} + +function errorFromEvaluation(error: unknown): Error { + if (error && typeof error === "object") { + const record = error as Record; + const message = typeof record.message === "string" ? record.message : JSON.stringify(record); + const result = new Error(message); + + if (typeof record.name === "string") { + result.name = record.name; + } + + if (typeof record.stack === "string") { + result.stack = record.stack; + } + + return result; + } + + return new Error(String(error)); +} + +export class ReplBrowser implements BrowserSession { + private readonly _connection: ReplBrowserConnection; + + constructor(connection: ReplConnection | ReplBrowserConnection) { + this._connection = connection; + } + + public async getWindowHandles(): Promise { + return this._evaluate("await browser.getWindowHandles()"); + } + + public async getWindowHandle(): Promise { + return this._evaluate("await browser.getWindowHandle()"); + } + + public async switchToWindow(handle: string): Promise { + await this._evaluate(`await browser.switchToWindow(${JSON.stringify(handle)})`); + } + + public async getTitle(): Promise { + return this._evaluate("await browser.getTitle()"); + } + + public async getUrl(): Promise { + return this._evaluate("await browser.getUrl()"); + } + + public async getPageSource(): Promise { + return this._evaluate("await browser.getPageSource()"); + } + + public async unstable_captureDomSnapshot(options?: CaptureSnapshotOptions): Promise { + return this._evaluate( + `await browser.unstable_captureDomSnapshot(${JSON.stringify(options)})`, + ); + } + + public async close(): Promise { + await this._connection.close(); + } + + public async deleteSession(): Promise { + await this.close(); + } + + public async runCodeInRepl(source: string): Promise { + return this._connection.sendRaw(source); + } + + private async _evaluate(code: string): Promise { + const result = await this._connection.send(code); + if (result.ok) { + return result.value as T; + } + + throw errorFromEvaluation(result.error); + } +} diff --git a/packages/tools/src/browser/repl-connection.ts b/packages/tools/src/browser/repl-connection.ts new file mode 100644 index 0000000..51eca08 --- /dev/null +++ b/packages/tools/src/browser/repl-connection.ts @@ -0,0 +1,406 @@ +import net from "node:net"; +import { randomUUID } from "node:crypto"; +import { stripVTControlCharacters } from "node:util"; + +export type EvaluateResult = { ok: true; value: unknown } | { ok: false; error: unknown }; + +export interface ReplConnectionOptions { + host?: string; + port: number; + connectTimeoutMs?: number; + evaluateTimeoutMs?: number; +} + +interface PendingEvaluation { + startMarker: string; + endMarker: string; + timeout: NodeJS.Timeout; + resolve: (result: unknown) => void; + reject: (error: Error) => void; + parse: (payload: string, outputBeforePayload: string) => unknown; +} + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_CONNECT_TIMEOUT_MS = 5_000; +const DEFAULT_EVALUATE_TIMEOUT_MS = 30_000; + +declare const browser: unknown; + +interface ReplThis { + browser?: unknown; +} + +function isEvaluateResult(value: unknown): value is EvaluateResult { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + return record.ok === true || record.ok === false; +} + +function parseEvaluateResult(payload: string): EvaluateResult { + const parsed: unknown = JSON.parse(payload); + + if (!isEvaluateResult(parsed)) { + throw new Error(`Unexpected REPL evaluation payload: ${payload}`); + } + + return parsed; +} + +function serializeFunctionExpression(fn: (...args: never[]) => unknown): string { + return JSON.stringify(`(${fn.toString()})`); +} + +async function evaluateReplExpression( + this: ReplThis | undefined, + source: string, + startMarker: string, + endMarker: string, +): Promise { + const inspect = Symbol.for("nodejs.util.inspect.custom"); + const serializeError = (error: unknown): Record => { + if (error && typeof error === "object") { + const errorRecord = error as Record; + + return { + name: typeof errorRecord.name === "string" ? errorRecord.name : "Error", + message: typeof errorRecord.message === "string" ? errorRecord.message : String(error), + stack: typeof errorRecord.stack === "string" ? errorRecord.stack : undefined, + }; + } + + return { + name: "Error", + message: String(error), + stack: undefined, + }; + }; + const resolveBrowser = (): unknown => { + let scopedBrowser: unknown; + + try { + scopedBrowser = browser; + } catch { + scopedBrowser = undefined; + } + + if (scopedBrowser != null) { + return scopedBrowser; + } + + if (this?.browser != null) { + return this.browser; + } + + return undefined; + }; + const format = (payload: unknown): string => { + try { + return startMarker + JSON.stringify(payload) + endMarker; + } catch (error) { + return startMarker + JSON.stringify({ ok: false, error: serializeError(error) }) + endMarker; + } + }; + + try { + const run = new Function("browser", `return (async () => (${source}))();`) as ( + browserArg: unknown, + ) => Promise; + const value = await run(resolveBrowser()); + + return { [inspect]: () => format({ ok: true, value }) }; + } catch (error) { + return { [inspect]: () => format({ ok: false, error: serializeError(error) }) }; + } +} + +function installBrowserFallback(this: ReplThis | undefined): void { + let scopedBrowser: unknown; + + try { + scopedBrowser = browser; + } catch { + scopedBrowser = undefined; + } + + if (scopedBrowser != null || this?.browser == null) { + return; + } + + (globalThis as ReplThis).browser = this.browser; +} + +export class ReplConnection { + private readonly _host: string; + private readonly _port: number; + private readonly _connectTimeoutMs: number; + private readonly _evaluateTimeoutMs: number; + private _socket: net.Socket | null = null; + private _connectPromise: Promise | null = null; + private _buffer = ""; + private _pending: PendingEvaluation | null = null; + private _sendQueue: Promise = Promise.resolve(); + + constructor(options: ReplConnectionOptions) { + this._host = options.host ?? DEFAULT_HOST; + this._port = options.port; + this._connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + this._evaluateTimeoutMs = options.evaluateTimeoutMs ?? DEFAULT_EVALUATE_TIMEOUT_MS; + } + + public async connect(): Promise { + if (this._socket && !this._socket.destroyed) { + return; + } + + if (this._connectPromise) { + return this._connectPromise; + } + + const connectPromise = new Promise((resolve, reject) => { + const socket = net.createConnection({ host: this._host, port: this._port }); + this._socket = socket; + socket.setEncoding("utf8"); + + const timeout = setTimeout(() => { + cleanup(); + socket.destroy(); + reject(new Error(`Timed out connecting to Testplane REPL at ${this._host}:${this._port}`)); + }, this._connectTimeoutMs); + + const cleanup = (): void => { + clearTimeout(timeout); + socket.off("connect", onConnect); + socket.off("error", onConnectError); + }; + + const onConnect = (): void => { + cleanup(); + socket.on("data", data => this._onData(data.toString())); + socket.on("error", error => this._rejectPending(error)); + socket.on("close", () => { + this._socket = null; + this._rejectPending(new Error("Testplane REPL connection closed")); + }); + resolve(); + }; + + const onConnectError = (error: Error): void => { + cleanup(); + socket.destroy(); + this._socket = null; + reject(error); + }; + + socket.once("connect", onConnect); + socket.once("error", onConnectError); + }).finally(() => { + this._connectPromise = null; + }); + this._connectPromise = connectPromise; + + return connectPromise; + } + + public async send(code: string): Promise { + const run = this._sendQueue.then( + () => this._sendNow(code), + () => this._sendNow(code), + ); + this._sendQueue = run.then( + () => undefined, + () => undefined, + ); + + return run; + } + + public async sendRaw(code: string): Promise { + const run = this._sendQueue.then( + () => this._sendRawNow(code), + () => this._sendRawNow(code), + ); + this._sendQueue = run.then( + () => undefined, + () => undefined, + ); + + return run; + } + + public async close(): Promise { + const socket = this._socket; + this._socket = null; + this._connectPromise = null; + this._rejectPending(new Error("Testplane REPL connection closed")); + + if (!socket || socket.destroyed) { + return; + } + + await new Promise(resolve => { + const timeout = setTimeout(() => { + socket.destroy(); + resolve(); + }, 1_000); + + socket.once("close", () => { + clearTimeout(timeout); + resolve(); + }); + socket.end(); + socket.destroy(); + }); + } + + private async _sendNow(code: string): Promise { + await this.connect(); + + const socket = this._socket; + if (!socket || socket.destroyed) { + throw new Error("Testplane REPL connection is not open"); + } + + const id = randomUUID(); + const startMarker = `__TESTPLANE_MCP_RESULT_${id}__`; + const endMarker = `__TESTPLANE_MCP_END_${id}__`; + const command = this._createEvaluationCommand(code, startMarker, endMarker); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this._pending = null; + reject(new Error(`Timed out waiting for Testplane REPL evaluation result for: ${code}`)); + }, this._evaluateTimeoutMs); + + this._pending = { + startMarker, + endMarker, + timeout, + resolve: result => resolve(result as EvaluateResult), + reject, + parse: parseEvaluateResult, + }; + + socket.write(command); + this._tryResolvePending(); + }); + } + + private async _sendRawNow(code: string): Promise { + await this.connect(); + + const socket = this._socket; + if (!socket || socket.destroyed) { + throw new Error("Testplane REPL connection is not open"); + } + + await this._sendRawCommand(socket, this._createBrowserFallbackCommand(), "browser fallback setup"); + + return this._sendRawCommand(socket, code, code); + } + + private _sendRawCommand(socket: net.Socket, code: string, timeoutDescription: string): Promise { + const id = randomUUID(); + const startMarker = `__TESTPLANE_MCP_RAW_RESULT_${id}__`; + const endMarker = `__TESTPLANE_MCP_RAW_END_${id}__`; + const markerCommand = this._createRawCompletionMarkerCommand(startMarker, endMarker); + const command = `${code.endsWith("\n") ? code : `${code}\n`}${markerCommand}`; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this._pending = null; + reject(new Error(`Timed out waiting for Testplane REPL command to finish for: ${timeoutDescription}`)); + }, this._evaluateTimeoutMs); + + this._buffer = ""; + this._pending = { + startMarker, + endMarker, + timeout, + resolve: result => resolve(result as string), + reject, + parse: (_payload, outputBeforePayload) => stripReplPrompts(outputBeforePayload), + }; + + socket.write(command); + this._tryResolvePending(); + }); + } + + private _createEvaluationCommand(code: string, startMarker: string, endMarker: string): string { + return `await eval(${serializeFunctionExpression(evaluateReplExpression)}).call(this, ${JSON.stringify(code)}, ${JSON.stringify(startMarker)}, ${JSON.stringify(endMarker)})\n`; + } + + private _createBrowserFallbackCommand(): string { + return `void eval(${serializeFunctionExpression(installBrowserFallback)}).call(this)\n`; + } + + private _createRawCompletionMarkerCommand(startMarker: string, endMarker: string): string { + return `({ [Symbol.for("nodejs.util.inspect.custom")]: () => ${JSON.stringify(startMarker + endMarker)} })\n`; + } + + private _onData(data: string): void { + this._buffer = stripVTControlCharacters(this._buffer + data); + this._tryResolvePending(); + } + + private _tryResolvePending(): void { + const pending = this._pending; + if (!pending) { + return; + } + + const startIndex = this._buffer.indexOf(pending.startMarker); + if (startIndex === -1) { + return; + } + + const payloadStart = startIndex + pending.startMarker.length; + const endIndex = this._buffer.indexOf(pending.endMarker, payloadStart); + if (endIndex === -1) { + return; + } + + const outputBeforePayload = this._buffer.slice(0, startIndex); + const payload = this._buffer.slice(payloadStart, endIndex).trim(); + this._buffer = this._buffer.slice(endIndex + pending.endMarker.length); + this._pending = null; + clearTimeout(pending.timeout); + + try { + pending.resolve(pending.parse(payload, outputBeforePayload)); + } catch (error) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + } + + private _rejectPending(error: Error): void { + const pending = this._pending; + if (!pending) { + return; + } + + this._pending = null; + clearTimeout(pending.timeout); + pending.reject(error); + } +} + +function stripReplPrompts(output: string): string { + const lines = stripVTControlCharacters(output) + .replace(/\r\n/g, "\n") + .split("\n") + .map(line => line.replace(/^(?:> |\.\.\. )+/, "")); + + while (lines.length > 0 && lines[0].trim() === "") { + lines.shift(); + } + + while (lines.length > 0 && lines[lines.length - 1].trim() === "") { + lines.pop(); + } + + return lines.join("\n"); +} diff --git a/packages/tools/src/browser/types.ts b/packages/tools/src/browser/types.ts new file mode 100644 index 0000000..b4bb4d5 --- /dev/null +++ b/packages/tools/src/browser/types.ts @@ -0,0 +1,29 @@ +export interface CaptureSnapshotOptions { + includeTags?: string[]; + includeAttrs?: string[]; + excludeTags?: string[]; + excludeAttrs?: string[]; + truncateText?: boolean; + maxTextLength?: number; +} + +export interface CaptureDomSnapshotResult { + snapshot: string; + omittedTags: string[]; + omittedAttributes: string[]; + textWasTruncated: boolean; +} + +export interface BrowserAdapter { + getWindowHandles(): Promise; + getWindowHandle(): Promise; + switchToWindow(handle: string): Promise; + getTitle(): Promise; + getUrl(): Promise; + getPageSource(): Promise; + unstable_captureDomSnapshot(options?: CaptureSnapshotOptions): Promise; +} + +export interface BrowserSession extends BrowserAdapter { + deleteSession(): Promise; +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 0ea2dc5..d95057b 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -14,6 +14,7 @@ import { openNewTab } from "./tools/open-new-tab.js"; import { closeTab } from "./tools/close-tab.js"; import { launchBrowser, launchBrowserWithOptions } from "./tools/launch-browser.js"; import { attachToBrowser } from "./tools/attach-to-browser.js"; +import { attachRepl } from "./tools/attach-repl.js"; import { closeBrowser } from "./tools/close-browser.js"; import { runCode } from "./tools/run-code.js"; import { inspectResult } from "./tools/inspect-result/index.js"; @@ -41,6 +42,7 @@ export const tools = typeCheckedTools([ closeTab, launchBrowser, attachToBrowser, + attachRepl, closeBrowser, runCode, testResults, @@ -58,6 +60,11 @@ export type { SessionCloseTool, SessionOpenResult, BrowserOptions, + TransportKind, ToolArgs, ToolResponse, } from "./types.js"; + +export type { BrowserAdapter, BrowserSession, CaptureSnapshotOptions } from "./browser/types.js"; +export { ReplBrowser } from "./browser/repl-browser.js"; +export { ReplConnection } from "./browser/repl-connection.js"; diff --git a/packages/tools/src/responses/browser-helpers.ts b/packages/tools/src/responses/browser-helpers.ts index 7a41060..459df56 100644 --- a/packages/tools/src/responses/browser-helpers.ts +++ b/packages/tools/src/responses/browser-helpers.ts @@ -2,16 +2,18 @@ import path from "path"; import fs from "fs/promises"; import os from "os"; import { randomUUID } from "node:crypto"; -import { WdioBrowser } from "testplane"; +import type { BrowserAdapter, CaptureSnapshotOptions } from "../browser/types.js"; import { formatTimestamp } from "../utils/formatters.js"; +export type { CaptureSnapshotOptions } from "../browser/types.js"; + export interface BrowserTab { title: string; url: string; isActive: boolean; } -export async function getBrowserTabs(browser: WdioBrowser): Promise { +export async function getBrowserTabs(browser: BrowserAdapter): Promise { try { const windowHandles = await browser.getWindowHandles(); const currentHandle = await browser.getWindowHandle(); @@ -39,15 +41,6 @@ export async function getBrowserTabs(browser: WdioBrowser): Promise { try { @@ -141,7 +134,7 @@ export function isPageSnapshotTooLargeForInline(snapshot: PageSnapshotResult): b } export async function getPageSnapshot( - browser: WdioBrowser, + browser: BrowserAdapter, options: CaptureSnapshotOptions = {}, ): Promise { const result = await capturePageSnapshot(browser, options); diff --git a/packages/tools/src/responses/index.ts b/packages/tools/src/responses/index.ts index 31abf7d..9ca8bb9 100644 --- a/packages/tools/src/responses/index.ts +++ b/packages/tools/src/responses/index.ts @@ -1,11 +1,7 @@ -import { WdioBrowser } from "testplane"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { - CaptureSnapshotOptions, - getPageSnapshot, - getBrowserTabs, - convertSnapshotToResponse, -} from "./browser-helpers.js"; +import type { BrowserAdapter } from "../browser/types.js"; +import type { CaptureSnapshotOptions } from "./browser-helpers.js"; +import { getPageSnapshot, getBrowserTabs, convertSnapshotToResponse } from "./browser-helpers.js"; export type ToolResponse = CallToolResult; @@ -31,7 +27,7 @@ export function createSimpleResponse(message: string, isError = false): ToolResp } export async function createBrowserStateResponse( - browser: WdioBrowser, + browser: BrowserAdapter, options: BrowserResponseOptions, ): Promise { const sections: string[] = []; diff --git a/packages/tools/src/tools/attach-repl.ts b/packages/tools/src/tools/attach-repl.ts new file mode 100644 index 0000000..6f5e9ce --- /dev/null +++ b/packages/tools/src/tools/attach-repl.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { SessionOpenTool, ToolKind } from "../types.js"; +import { createErrorResponse, createSimpleResponse } from "../responses/index.js"; +import { ReplConnection } from "../browser/repl-connection.js"; +import { ReplBrowser } from "../browser/repl-browser.js"; + +const DEFAULT_REPL_HOST = "127.0.0.1"; + +export const attachReplSchema = { + port: z.number().int().positive().describe("Testplane REPL TCP port"), + host: z.string().default(DEFAULT_REPL_HOST).describe("Testplane REPL TCP host"), +}; + +const attachReplCb: SessionOpenTool["cb"] = async (args, previousOptions) => { + const host = args.host ?? DEFAULT_REPL_HOST; + const connection = new ReplConnection({ host, port: args.port }); + const browser = new ReplBrowser(connection); + + try { + await connection.connect(); + await browser.getUrl(); + + return { + browser, + options: previousOptions, + transport: "attach-repl" as const, + response: createSimpleResponse(`Attached to Testplane REPL at ${host}:${args.port}`), + }; + } catch (error) { + await connection.close().catch(() => undefined); + + return { + browser: null, + options: previousOptions, + response: createErrorResponse( + "Error attaching to Testplane REPL", + error instanceof Error ? error : undefined, + ), + }; + } +}; + +export const attachRepl: SessionOpenTool = { + kind: ToolKind.SessionOpen, + name: "attach-repl", + description: "Attach to a running Testplane REPL session", + schema: attachReplSchema, + cb: attachReplCb, + cli: { section: "Session" }, +}; diff --git a/packages/tools/src/tools/attach-to-browser.ts b/packages/tools/src/tools/attach-to-browser.ts index 4956df4..887c870 100644 --- a/packages/tools/src/tools/attach-to-browser.ts +++ b/packages/tools/src/tools/attach-to-browser.ts @@ -17,6 +17,7 @@ const attachToBrowserCb: SessionOpenTool["cb"] = a return { browser, options: previousOptions, + transport: "launch-browser" as const, response: createSimpleResponse("Successfully attached to existing browser session"), }; } catch (error) { diff --git a/packages/tools/src/tools/browser-console.ts b/packages/tools/src/tools/browser-console.ts index 66e8f37..85c258f 100644 --- a/packages/tools/src/tools/browser-console.ts +++ b/packages/tools/src/tools/browser-console.ts @@ -93,6 +93,7 @@ export const browserConsole: ActionTool = { "Get browser-side console messages. " + "This command only works with Chromium-based browsers " + 'and returns only "unseen" messages - those that were not returned by the previous getLogs call in the current session.', + supportedTransports: ["launch-browser"], schema: browserConsoleSchema, cb: browserConsoleCb, cli: { section: "Inspection" }, diff --git a/packages/tools/src/tools/click-on-element.ts b/packages/tools/src/tools/click-on-element.ts index 632c5b9..d8379ff 100644 --- a/packages/tools/src/tools/click-on-element.ts +++ b/packages/tools/src/tools/click-on-element.ts @@ -36,6 +36,7 @@ export const clickOnElement: ActionTool = { kind: ToolKind.Action, name: "click", description: "Click an element on the page.", + supportedTransports: ["launch-browser"], schema: elementClickSchema, cb: clickOnElementCb, cli: { positional: ["selector"], section: "Interaction" }, diff --git a/packages/tools/src/tools/close-tab.ts b/packages/tools/src/tools/close-tab.ts index ac95243..e491231 100644 --- a/packages/tools/src/tools/close-tab.ts +++ b/packages/tools/src/tools/close-tab.ts @@ -76,6 +76,7 @@ export const closeTab: ActionTool = { name: "close-tab", description: "Close a specific browser tab by its number (1-based), or close the current tab if no number is provided", + supportedTransports: ["launch-browser"], schema: closeTabSchema, cb: closeTabCb, cli: { positional: ["tabNumber"], section: "Tabs" }, diff --git a/packages/tools/src/tools/hover-element.ts b/packages/tools/src/tools/hover-element.ts index 22cbcb6..e559872 100644 --- a/packages/tools/src/tools/hover-element.ts +++ b/packages/tools/src/tools/hover-element.ts @@ -36,6 +36,7 @@ export const hoverElement: ActionTool = { kind: ToolKind.Action, name: "hover", description: "Hover an element on the page.", + supportedTransports: ["launch-browser"], schema: elementHoverSchema, cb: hoverElementCb, cli: { positional: ["selector"], section: "Interaction" }, diff --git a/packages/tools/src/tools/launch-browser.ts b/packages/tools/src/tools/launch-browser.ts index 1381138..f400a2b 100644 --- a/packages/tools/src/tools/launch-browser.ts +++ b/packages/tools/src/tools/launch-browser.ts @@ -106,6 +106,7 @@ const launchBrowserCb: SessionOpenTool["cb"] = async return { browser, options: updatedOptions, + transport: "launch-browser" as const, response: createSimpleResponse("Successfully launched browser session"), }; } catch (error) { diff --git a/packages/tools/src/tools/list-tabs.ts b/packages/tools/src/tools/list-tabs.ts index 738b9a9..2c645f5 100644 --- a/packages/tools/src/tools/list-tabs.ts +++ b/packages/tools/src/tools/list-tabs.ts @@ -19,6 +19,7 @@ export const listTabs: ActionTool = { kind: ToolKind.Action, name: "list-tabs", description: "Get a list of all currently opened browser tabs with their URLs, titles, and active status", + supportedTransports: ["launch-browser"], schema: listTabsSchema, cb: listTabsCb, cli: { section: "Tabs" }, diff --git a/packages/tools/src/tools/navigate.ts b/packages/tools/src/tools/navigate.ts index 19ce92d..fa94e8d 100644 --- a/packages/tools/src/tools/navigate.ts +++ b/packages/tools/src/tools/navigate.ts @@ -54,6 +54,7 @@ export const navigate: ActionTool = { autoLaunchBrowser: true, name: "navigate", description: "Open a URL in the browser", + supportedTransports: ["launch-browser"], schema: navigateSchema, cb: navigateCb, cli: { positional: ["url"], section: "Navigation" }, diff --git a/packages/tools/src/tools/open-new-tab.ts b/packages/tools/src/tools/open-new-tab.ts index e40ac50..6ddb754 100644 --- a/packages/tools/src/tools/open-new-tab.ts +++ b/packages/tools/src/tools/open-new-tab.ts @@ -45,6 +45,7 @@ export const openNewTab: ActionTool = { kind: ToolKind.Action, name: "new-tab", description: "Open a new browser tab, optionally navigate to a URL, and automatically switch to it", + supportedTransports: ["launch-browser"], schema: openNewTabSchema, cb: openNewTabCb, cli: { positional: ["url"], section: "Tabs" }, diff --git a/packages/tools/src/tools/run-code.ts b/packages/tools/src/tools/run-code.ts index f67b05d..3e1ffe1 100644 --- a/packages/tools/src/tools/run-code.ts +++ b/packages/tools/src/tools/run-code.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { ActionTool, ToolKind } from "../types.js"; import { createSimpleResponse } from "../responses/index.js"; +import { isReplCodeRunner } from "../browser/repl-browser.js"; const FUNCTION_NODE_TYPES = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]); @@ -187,9 +188,15 @@ async function getSource(args: { code?: string; file?: string }): Promise["cb"] = async (args, browser) => { +const runCodeCb: ActionTool["cb"] = async (args, browser) => { try { const source = await getSource(args); + if (isReplCodeRunner(browser)) { + const result = await browser.runCodeInRepl(source); + + return createSimpleResponse(json({ result })); + } + const result = await executeSource(source, browser); return createSimpleResponse(json({ result })); @@ -198,12 +205,13 @@ const runCodeCb: ActionTool["cb"] = async (args, browser) } }; -export const runCode: ActionTool = { +export const runCode: ActionTool = { kind: ToolKind.Action, name: "run-code", description: "Run arbitrary Testplane script using the current browser, useful when other tools don't provide the functionality you need. " + - "Inline input may be code or a function that receives browser as its only argument.", + "Inline input may be code or a function that receives browser as its only argument. In REPL sessions, the code is passed directly to the Testplane REPL.", + supportedTransports: ["launch-browser", "attach-repl"], schema: runCodeSchema, cb: runCodeCb, cli: { diff --git a/packages/tools/src/tools/select-option.ts b/packages/tools/src/tools/select-option.ts index 9857494..3574ea9 100644 --- a/packages/tools/src/tools/select-option.ts +++ b/packages/tools/src/tools/select-option.ts @@ -150,6 +150,7 @@ export const selectOption: ActionTool = { kind: ToolKind.Action, name: "select", description: "Select an option in a native