Skip to content

Commit 237363a

Browse files
committed
feat: implement launchBrowser tool
1 parent 165a154 commit 237363a

File tree

8 files changed

+565
-10
lines changed

8 files changed

+565
-10
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ concurrency:
1313
jobs:
1414
test:
1515
name: Run tests on Node.js ${{ matrix.node-version }}
16-
runs-on: self-hosted-arc
16+
runs-on: ubuntu-latest
1717

1818
strategy:
1919
matrix:

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ permissions:
1515

1616
jobs:
1717
release-please:
18-
runs-on: self-hosted-arc
18+
runs-on: ubuntu-latest
1919
outputs:
2020
release_created: ${{ steps.release.outputs.release_created }}
2121
steps:
@@ -29,7 +29,7 @@ jobs:
2929
publish:
3030
needs: release-please
3131
if: ${{ needs.release-please.outputs.release_created }}
32-
runs-on: self-hosted-arc
32+
runs-on: ubuntu-latest
3333
steps:
3434
- uses: actions/checkout@v4
3535

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,36 @@ Navigate to URL in the browser.
141141
### `closeBrowser`
142142
Close the current browser session.
143143

144+
### `launchBrowser`
145+
Launch a new browser session with custom configuration options.
146+
147+
- **Parameters:**
148+
- `desiredCapabilities` (object, optional): WebDriver [desired capabilities](https://www.selenium.dev/documentation/webdriver/capabilities/) to forward to the Testplane launcher. Example:
149+
150+
```json
151+
{
152+
"browserName": "chrome",
153+
"goog:chromeOptions": {
154+
"mobileEmulation": {
155+
"deviceMetrics": {
156+
"width": 375,
157+
"height": 667,
158+
"pixelRatio": 2.0
159+
}
160+
}
161+
}
162+
}
163+
```
164+
165+
- `gridUrl` (string, optional): WebDriver endpoint to connect to. Default: `"local"` (lets Testplane MCP manage Chrome and Firefox automatically). Set a Selenium grid URL only when you need other browsers.
166+
167+
- `windowSize` (object | string | null, optional): Viewport size for the browser session. Can be:
168+
- Object format: `{"width": 1280, "height": 720}`
169+
- String format: `"1280x720"`
170+
- `null` to reset to default size
171+
172+
> **Note:** Testplane MCP automatically downloads Chrome and Firefox. To launch additional browsers (for example, Safari, Edge, or mobile-specific builds), use the `gridUrl` parameter to point to your Selenium grid.
173+
144174
</details>
145175

146176
<details>

src/browser-context.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
import { WdioBrowser, SessionOptions } from "testplane";
22
import { launchBrowser, attachToBrowser } from "testplane/unstable";
3+
import type { StandaloneBrowserOptionsInput } from "testplane/unstable";
34

45
export interface BrowserOptions {
56
headless?: boolean;
7+
desiredCapabilities?: StandaloneBrowserOptionsInput["desiredCapabilities"];
8+
gridUrl?: string;
9+
windowSize?: StandaloneBrowserOptionsInput["windowSize"];
610
}
711

12+
const getSandboxArgs = (): string[] =>
13+
process.env.DISABLE_BROWSER_SANDBOX ? ["--no-sandbox", "--disable-dev-shm-usage", "--disable-web-security"] : [];
14+
15+
const buildSandboxCapabilities = (sandboxArgs: string[]): StandaloneBrowserOptionsInput["desiredCapabilities"] => ({
16+
"goog:chromeOptions": {
17+
args: sandboxArgs,
18+
},
19+
});
20+
821
export class BrowserContext {
922
protected _browser: WdioBrowser | null = null;
1023
protected _options: BrowserOptions;
@@ -27,15 +40,17 @@ export class BrowserContext {
2740
await this._browser.getUrl(); // Need to get exception if not attach
2841
} else {
2942
console.error("Launch browser");
43+
44+
const sandboxArgs = getSandboxArgs();
45+
const desiredCapabilities =
46+
this._options.desiredCapabilities ??
47+
(sandboxArgs.length ? buildSandboxCapabilities(sandboxArgs) : undefined);
48+
3049
this._browser = await launchBrowser({
3150
headless: this._options.headless ? "new" : false,
32-
desiredCapabilities: {
33-
"goog:chromeOptions": {
34-
args: process.env.DISABLE_BROWSER_SANDBOX
35-
? ["--no-sandbox", "--disable-dev-shm-usage", "--disable-web-security"]
36-
: [],
37-
},
38-
},
51+
desiredCapabilities,
52+
gridUrl: this._options.gridUrl,
53+
windowSize: this._options.windowSize,
3954
});
4055
}
4156

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ToolDefinition } from "../types.js";
22
import { navigate } from "./navigate.js";
33
import { closeBrowser } from "./close-browser.js";
4+
import { launchBrowser } from "./launch-browser.js";
45
import { clickOnElement } from "./click-on-element.js";
56
import { hoverElement } from "./hover-element.js";
67
import { typeIntoElement } from "./type-into-element.js";
@@ -16,6 +17,7 @@ import { attachToBrowser } from "./attach-to-browser.js";
1617
export const tools = [
1718
navigate,
1819
closeBrowser,
20+
launchBrowser,
1921
clickOnElement,
2022
hoverElement,
2123
typeIntoElement,

src/tools/launch-browser.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { z } from "zod";
2+
import { ToolDefinition, Context } from "../types.js";
3+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import { contextProvider } from "../context-provider.js";
5+
import { createSimpleResponse, createErrorResponse } from "../responses/index.js";
6+
import { BrowserContext, type BrowserOptions } from "../browser-context.js";
7+
8+
const desiredCapabilitiesSchema = z
9+
.object({})
10+
.catchall(z.unknown())
11+
.superRefine((value, ctx) => {
12+
const browserName = value?.["browserName"];
13+
14+
if (browserName !== undefined && typeof browserName !== "string") {
15+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: '"browserName" must be a string' });
16+
}
17+
})
18+
.describe(
19+
'WebDriver desiredCapabilities that should be used when launching the browser. Example to launch Chrome with mobile emulation: {"browserName":"chrome","goog:chromeOptions":{"mobileEmulation":{"deviceMetrics":{"width":360,"height":800,"pixelRatio":1.0}}}}',
20+
);
21+
22+
const windowSizeSchema = z
23+
.union([
24+
z
25+
.object({
26+
width: z.number().int().positive(),
27+
height: z.number().int().positive(),
28+
})
29+
.strict(),
30+
z
31+
.string()
32+
.trim()
33+
.regex(/^[0-9]+x[0-9]+$/, {
34+
message: '"windowSize" should use the format "<width>x<height>" (e.g. "1600x900")',
35+
}),
36+
z.null(),
37+
])
38+
.optional()
39+
.describe(
40+
'Viewport to use for the session. Provide {"width": number, "height": number} or a string like "1280x720"; use null to reset to the default size.',
41+
);
42+
43+
export const launchBrowserSchema = {
44+
desiredCapabilities: desiredCapabilitiesSchema.optional(),
45+
gridUrl: z
46+
.string()
47+
.default("local")
48+
.describe(
49+
'WebDriver endpoint to connect to. "local" (default) lets Testplane MCP manage Chrome and Firefox automatically; set a Selenium grid URL only when you need other browsers.',
50+
),
51+
windowSize: windowSizeSchema,
52+
};
53+
54+
const launchBrowserCb: ToolCallback<typeof launchBrowserSchema> = async args => {
55+
try {
56+
const context = contextProvider.getContext();
57+
const desiredCapabilities = args.desiredCapabilities as BrowserOptions["desiredCapabilities"];
58+
const gridUrl = args.gridUrl ?? "local";
59+
const windowSizeInput = args.windowSize;
60+
61+
if (await context.browser.isActive()) {
62+
console.error("Closing existing browser before launching a new one");
63+
await context.browser.close();
64+
}
65+
66+
const updatedOptions: BrowserOptions = {
67+
...context.browser.getOptions(),
68+
};
69+
70+
if (Object.prototype.hasOwnProperty.call(args, "desiredCapabilities")) {
71+
updatedOptions.desiredCapabilities = desiredCapabilities;
72+
}
73+
74+
if (!gridUrl || gridUrl === "local") {
75+
delete updatedOptions.gridUrl;
76+
} else {
77+
updatedOptions.gridUrl = gridUrl;
78+
}
79+
80+
if (Object.prototype.hasOwnProperty.call(args, "windowSize")) {
81+
if (windowSizeInput === null) {
82+
updatedOptions.windowSize = null;
83+
} else if (typeof windowSizeInput === "string") {
84+
const [width, height] = windowSizeInput.split("x").map(value => Number.parseInt(value, 10));
85+
updatedOptions.windowSize = { width, height };
86+
} else if (windowSizeInput === undefined) {
87+
delete updatedOptions.windowSize;
88+
} else {
89+
updatedOptions.windowSize = windowSizeInput as BrowserOptions["windowSize"];
90+
}
91+
}
92+
93+
const browserContext = new BrowserContext(updatedOptions);
94+
const newContext: Context = {
95+
browser: browserContext,
96+
};
97+
contextProvider.setContext(newContext);
98+
99+
await browserContext.get();
100+
101+
return createSimpleResponse("Successfully launched browser session");
102+
} catch (error) {
103+
console.error("Error launching browser:", error);
104+
return createErrorResponse("Error launching browser", error instanceof Error ? error : undefined);
105+
}
106+
};
107+
108+
export const launchBrowser: ToolDefinition<typeof launchBrowserSchema> = {
109+
name: "launchBrowser",
110+
description:
111+
"Launch a new browser session with custom desired capabilities. Avoid using this tool unless the user explicitly requests a custom browser configuration; browsers are launched automatically for commands like navigate to URL. Testplane MCP can ONLY download Chrome and Firefox automatically, for other browsers you MUST ensure that driver is launched and provide it as custom gridUrl.",
112+
schema: launchBrowserSchema,
113+
cb: launchBrowserCb,
114+
};

test/playground/mobile-info.html

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Mobile Emulation Diagnostics</title>
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
<style>
8+
body {
9+
font-family: Arial, sans-serif;
10+
margin: 20px;
11+
line-height: 1.6;
12+
}
13+
h1 {
14+
color: #333;
15+
}
16+
p {
17+
margin: 0.5rem 0;
18+
}
19+
</style>
20+
</head>
21+
<body>
22+
<h1>Mobile Emulation Diagnostics</h1>
23+
<p id="viewport-width">Viewport width:</p>
24+
<p id="viewport-height">Viewport height:</p>
25+
<p id="device-pixel-ratio">Device pixel ratio:</p>
26+
<p id="user-agent">User agent:</p>
27+
<script>
28+
const $ = id => document.getElementById(id);
29+
30+
const updateDiagnostics = () => {
31+
$("viewport-width").textContent = `Viewport width: ${Math.round(window.innerWidth)}`;
32+
$("viewport-height").textContent = `Viewport height: ${Math.round(window.innerHeight)}`;
33+
$("device-pixel-ratio").textContent = `Device pixel ratio: ${window.devicePixelRatio}`;
34+
$("user-agent").textContent = `User agent: ${navigator.userAgent}`;
35+
};
36+
37+
updateDiagnostics();
38+
window.addEventListener("resize", updateDiagnostics);
39+
</script>
40+
</body>
41+
</html>

0 commit comments

Comments
 (0)