Skip to content

Commit ec1c002

Browse files
committed
feat: implement the REPL integration
1 parent 6519311 commit ec1c002

33 files changed

Lines changed: 1086 additions & 46 deletions

packages/cli/src/daemon/request-handler.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ function createNoActiveBrowserResponse(toolName: string): ResponseResult["conten
1616
];
1717
}
1818

19+
function createUnsupportedReplToolResponse(toolName: string): ResponseResult["content"] {
20+
return [
21+
{
22+
type: "text",
23+
text: `Tool '${toolName}' is not yet supported with REPL sessions. Currently supported in REPL mode: snapshot, run-code.`,
24+
},
25+
];
26+
}
27+
28+
async function closeBrowserSession(
29+
browser: NonNullable<ReturnType<SessionRegistry["getOrCreate"]>["browser"]>,
30+
): Promise<void> {
31+
await browser.deleteSession();
32+
}
33+
1934
/**
2035
* Handles requests from clients, executing tools in sessions as needed.
2136
*/
@@ -88,10 +103,29 @@ export class RequestHandler {
88103

89104
debug("Auto-launching browser: session=%s tool=%s", req.sessionName, req.tool);
90105
state.browser = await launchBrowserWithOptions(state.defaultOptions);
106+
state.transport = "launch-browser";
107+
}
108+
109+
const supportedTransports = tool.supportedTransports ?? ["launch-browser"];
110+
const transport = state.transport ?? "launch-browser";
111+
if (!supportedTransports.includes(transport)) {
112+
debug(
113+
"Action is not supported with current transport: session=%s tool=%s transport=%s",
114+
req.sessionName,
115+
req.tool,
116+
transport,
117+
);
118+
119+
return {
120+
id: req.id,
121+
kind: "result",
122+
content: createUnsupportedReplToolResponse(tool.name),
123+
isError: true,
124+
};
91125
}
92126

93127
// eslint-disable-next-line @typescript-eslint/no-explicit-any
94-
const result = await tool.cb(parsedArgs as any, state.browser);
128+
const result = await tool.cb(parsedArgs as any, state.browser as never);
95129

96130
return { id: req.id, kind: "result", content: result.content, isError: result.isError };
97131
}
@@ -101,7 +135,7 @@ export class RequestHandler {
101135
debug("Replacing existing browser session: session=%s", req.sessionName);
102136

103137
try {
104-
await state.browser.deleteSession();
138+
await closeBrowserSession(state.browser);
105139
} catch (error) {
106140
debug(
107141
"Error closing previous session: session=%s message=%s",
@@ -111,12 +145,14 @@ export class RequestHandler {
111145
}
112146

113147
state.browser = null;
148+
state.transport = null;
114149
}
115150

116151
// eslint-disable-next-line @typescript-eslint/no-explicit-any
117152
const openResult = await tool.cb(parsedArgs as any, state.options);
118153
if (openResult.browser) {
119154
state.browser = openResult.browser;
155+
state.transport = openResult.transport ?? "launch-browser";
120156
state.options = openResult.options;
121157
}
122158

packages/cli/src/daemon/session-registry.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import type { WdioBrowser } from "testplane";
2-
import type { BrowserOptions } from "@testplane/tools";
1+
import type { BrowserOptions, BrowserSession, TransportKind } from "@testplane/tools";
32

43
import makeDebug from "debug";
54

65
const debug = makeDebug("testplane-cli:daemon:session-registry");
76

87
export interface SessionState {
9-
browser: WdioBrowser | null;
8+
browser: BrowserSession | null;
9+
transport: TransportKind | null;
1010
defaultOptions: BrowserOptions;
1111
options: BrowserOptions;
1212
activeInteractions: number;
@@ -40,6 +40,7 @@ export class SessionRegistry {
4040
if (!state) {
4141
state = {
4242
browser: null,
43+
transport: null,
4344
defaultOptions: { ...this._defaultOptions },
4445
options: { ...this._defaultOptions },
4546
activeInteractions: 0,
@@ -67,6 +68,7 @@ export class SessionRegistry {
6768

6869
public clearBrowser(sessionName: string, state: SessionState): void {
6970
state.browser = null;
71+
state.transport = null;
7072
this._cancelExpirationTimer(sessionName, state);
7173
}
7274

@@ -99,7 +101,7 @@ export class SessionRegistry {
99101
debug("Stale session detected, clearing: session=%s", sessionName);
100102

101103
try {
102-
await browser.deleteSession();
104+
await this._closeBrowserSession(browser);
103105
} catch (error) {
104106
debug("Stale session cleanup error: session=%s message=%s", sessionName, formatError(error));
105107
}
@@ -119,7 +121,7 @@ export class SessionRegistry {
119121
debug("Closing session: session=%s", sessionName);
120122

121123
try {
122-
await state.browser.deleteSession();
124+
await this._closeBrowserSession(state.browser);
123125
} catch (error) {
124126
debug("Session cleanup error: session=%s message=%s", sessionName, formatError(error));
125127
}
@@ -152,7 +154,11 @@ export class SessionRegistry {
152154
state.expirationTimer = null;
153155
}
154156

155-
private async _expireSession(sessionName: string, state: SessionState, browser: WdioBrowser | null): Promise<void> {
157+
private async _expireSession(
158+
sessionName: string,
159+
state: SessionState,
160+
browser: BrowserSession | null,
161+
): Promise<void> {
156162
state.expirationTimer = null;
157163

158164
if (!browser || state.browser !== browser || state.activeInteractions > 0) {
@@ -165,13 +171,13 @@ export class SessionRegistry {
165171
state.browser = null;
166172

167173
try {
168-
await browser.deleteSession();
174+
await this._closeBrowserSession(browser);
169175
} catch (error) {
170176
debug("Session expiration cleanup error: session=%s message=%s", sessionName, formatError(error));
171177
}
172178
}
173179

174-
private async _isSessionAlive(sessionName: string, browser: WdioBrowser): Promise<boolean> {
180+
private async _isSessionAlive(sessionName: string, browser: BrowserSession): Promise<boolean> {
175181
try {
176182
await browser.getUrl();
177183
} catch (error) {
@@ -182,4 +188,8 @@ export class SessionRegistry {
182188

183189
return true;
184190
}
191+
192+
private async _closeBrowserSession(browser: BrowserSession): Promise<void> {
193+
await browser.deleteSession();
194+
}
185195
}

packages/cli/test/daemon/request-handler.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ const mockedTools = vi.hoisted(() => {
88
const launchBrowserWithOptions = vi.fn();
99
const navigateCb = vi.fn();
1010
const clickCb = vi.fn();
11+
const snapshotCb = vi.fn();
12+
const runCodeCb = vi.fn();
1113

1214
return {
1315
launchBrowserWithOptions,
1416
navigateCb,
1517
clickCb,
18+
snapshotCb,
19+
runCodeCb,
1620
};
1721
});
1822

@@ -33,16 +37,34 @@ vi.mock("@testplane/tools", () => {
3337
autoLaunchBrowser: true,
3438
name: "navigate",
3539
description: "navigate",
40+
supportedTransports: ["launch-browser"],
3641
schema: {},
3742
cb: mockedTools.navigateCb,
3843
},
3944
{
4045
kind: ToolKind.Action,
4146
name: "click",
4247
description: "click",
48+
supportedTransports: ["launch-browser"],
4349
schema: {},
4450
cb: mockedTools.clickCb,
4551
},
52+
{
53+
kind: ToolKind.Action,
54+
name: "snapshot",
55+
description: "snapshot",
56+
supportedTransports: ["launch-browser", "attach-repl"],
57+
schema: {},
58+
cb: mockedTools.snapshotCb,
59+
},
60+
{
61+
kind: ToolKind.Action,
62+
name: "run-code",
63+
description: "run-code",
64+
supportedTransports: ["launch-browser", "attach-repl"],
65+
schema: {},
66+
cb: mockedTools.runCodeCb,
67+
},
4668
],
4769
};
4870
});
@@ -79,6 +101,8 @@ describe("daemon/RequestHandler", () => {
79101
mockedTools.launchBrowserWithOptions.mockResolvedValue(browser);
80102
mockedTools.navigateCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false });
81103
mockedTools.clickCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false });
104+
mockedTools.snapshotCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false });
105+
mockedTools.runCodeCb.mockResolvedValue({ content: [{ type: "text", text: "ok" }], isError: false });
82106
});
83107

84108
it("does not auto-launch browser for action tools that require an existing session", async () => {
@@ -106,4 +130,49 @@ describe("daemon/RequestHandler", () => {
106130
expect(response.isError).toBe(false);
107131
}
108132
});
133+
134+
it("returns a clear error for unsupported action tools in REPL sessions", async () => {
135+
const state = sessions.getOrCreate("default");
136+
state.browser = browser;
137+
state.transport = "attach-repl";
138+
139+
const response = await handler.handleRequest(createRequest("click"), sessions);
140+
141+
expect(mockedTools.clickCb).not.toHaveBeenCalled();
142+
expect(response.kind).toBe("result");
143+
if (response.kind === "result") {
144+
expect(response.isError).toBe(true);
145+
expect(response.content[0].text).toBe(
146+
"Tool 'click' is not yet supported with REPL sessions. Currently supported in REPL mode: snapshot, run-code.",
147+
);
148+
}
149+
});
150+
151+
it("allows snapshot in REPL sessions", async () => {
152+
const state = sessions.getOrCreate("default");
153+
state.browser = browser;
154+
state.transport = "attach-repl";
155+
156+
const response = await handler.handleRequest(createRequest("snapshot"), sessions);
157+
158+
expect(mockedTools.snapshotCb).toHaveBeenCalledWith({}, browser);
159+
expect(response.kind).toBe("result");
160+
if (response.kind === "result") {
161+
expect(response.isError).toBe(false);
162+
}
163+
});
164+
165+
it("allows run-code in REPL sessions", async () => {
166+
const state = sessions.getOrCreate("default");
167+
state.browser = browser;
168+
state.transport = "attach-repl";
169+
170+
const response = await handler.handleRequest(createRequest("run-code"), sessions);
171+
172+
expect(mockedTools.runCodeCb).toHaveBeenCalledWith({}, browser);
173+
expect(response.kind).toBe("result");
174+
if (response.kind === "result") {
175+
expect(response.isError).toBe(false);
176+
}
177+
});
109178
});

packages/mcp/src/server.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
33
import type { ZodRawShape } from "zod";
4-
import type { WdioBrowser } from "testplane";
54

6-
import { tools, ToolKind, launchBrowserWithOptions, type BrowserOptions } from "@testplane/tools";
5+
import { tools, ToolKind, launchBrowserWithOptions, type BrowserOptions, type BrowserSession } from "@testplane/tools";
76

87
import { readFileSync } from "fs";
98
import { fileURLToPath } from "url";
@@ -35,7 +34,7 @@ export interface ServerOptions {
3534
}
3635

3736
export async function startServer(serverOptions: ServerOptions = {}): Promise<McpServer> {
38-
let browser: WdioBrowser | null = null;
37+
let browser: BrowserSession | null = null;
3938
const defaultOptions: BrowserOptions = { headless: serverOptions.headless ?? false };
4039
let sessionOptions: BrowserOptions = { ...defaultOptions };
4140

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

67-
return tool.cb(args, browser);
66+
return tool.cb(args, browser as never);
6867
}
6968

7069
if (tool.kind === ToolKind.SessionOpen) {

packages/mcp/test/server.e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const EXPECTED_TOOL_NAMES = [
2727
// session tools
2828
"launch",
2929
"attach",
30+
"attach-repl",
3031
"close-browser",
3132
// advanced tools
3233
"run-code",

0 commit comments

Comments
 (0)