Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions packages/cli/src/daemon/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<SessionRegistry["getOrCreate"]>["browser"]>,
): Promise<void> {
await browser.deleteSession();
}

/**
* Handles requests from clients, executing tools in sessions as needed.
*/
Expand Down Expand Up @@ -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 };
}
Expand All @@ -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",
Expand All @@ -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;
}

Expand Down
26 changes: 18 additions & 8 deletions packages/cli/src/daemon/session-registry.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -40,6 +40,7 @@ export class SessionRegistry {
if (!state) {
state = {
browser: null,
transport: null,
defaultOptions: { ...this._defaultOptions },
options: { ...this._defaultOptions },
activeInteractions: 0,
Expand Down Expand Up @@ -67,6 +68,7 @@ export class SessionRegistry {

public clearBrowser(sessionName: string, state: SessionState): void {
state.browser = null;
state.transport = null;
this._cancelExpirationTimer(sessionName, state);
}

Expand Down Expand Up @@ -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));
}
Expand All @@ -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));
}
Expand Down Expand Up @@ -152,7 +154,11 @@ export class SessionRegistry {
state.expirationTimer = null;
}

private async _expireSession(sessionName: string, state: SessionState, browser: WdioBrowser | null): Promise<void> {
private async _expireSession(
sessionName: string,
state: SessionState,
browser: BrowserSession | null,
): Promise<void> {
state.expirationTimer = null;

if (!browser || state.browser !== browser || state.activeInteractions > 0) {
Expand All @@ -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<boolean> {
private async _isSessionAlive(sessionName: string, browser: BrowserSession): Promise<boolean> {
try {
await browser.getUrl();
} catch (error) {
Expand All @@ -182,4 +188,8 @@ export class SessionRegistry {

return true;
}

private async _closeBrowserSession(browser: BrowserSession): Promise<void> {
await browser.deleteSession();
}
}
69 changes: 69 additions & 0 deletions packages/cli/test/daemon/request-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});

Expand All @@ -33,16 +37,34 @@ vi.mock("@testplane/tools", () => {
autoLaunchBrowser: true,
name: "navigate",
description: "navigate",
supportedTransports: ["launch-browser"],
schema: {},
cb: mockedTools.navigateCb,
},
{
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,
},
],
};
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
}
});
});
7 changes: 3 additions & 4 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -35,7 +34,7 @@ export interface ServerOptions {
}

export async function startServer(serverOptions: ServerOptions = {}): Promise<McpServer> {
let browser: WdioBrowser | null = null;
let browser: BrowserSession | null = null;
const defaultOptions: BrowserOptions = { headless: serverOptions.headless ?? false };
let sessionOptions: BrowserOptions = { ...defaultOptions };

Expand Down Expand Up @@ -64,7 +63,7 @@ export async function startServer(serverOptions: ServerOptions = {}): Promise<Mc
browser = await launchBrowserWithOptions(defaultOptions);
}

return tool.cb(args, browser);
return tool.cb(args, browser as never);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate MCP actions by REPL transport

When an MCP client opens a session with attach-repl, browser becomes a ReplBrowser, but this path still invokes every action tool regardless of its new supportedTransports value. Tools such as click and navigate are marked launch-browser only and call Webdriver-specific methods that ReplBrowser does not implement, so MCP users can hit runtime failures after attaching to a REPL; the daemon path added a transport check, but the MCP server needs the same guard or transport tracking.

Useful? React with 👍 / 👎.

}

if (tool.kind === ToolKind.SessionOpen) {
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/test/server.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const EXPECTED_TOOL_NAMES = [
// session tools
"launch",
"attach",
"attach-repl",
"close-browser",
// advanced tools
"run-code",
Expand Down
Loading
Loading