Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
jobs:
test:
name: Run tests on Node.js ${{ matrix.node-version }}
runs-on: self-hosted-arc
runs-on: ubuntu-latest

strategy:
matrix:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ permissions:

jobs:
release-please:
runs-on: self-hosted-arc
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
steps:
Expand All @@ -29,7 +29,7 @@ jobs:
publish:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: self-hosted-arc
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,36 @@ Navigate to URL in the browser.
### `closeBrowser`
Close the current browser session.

### `launchBrowser`
Launch a new browser session with custom configuration options.

- **Parameters:**
- `desiredCapabilities` (object, optional): WebDriver [desired capabilities](https://www.selenium.dev/documentation/webdriver/capabilities/) to forward to the Testplane launcher. Example:

```json
{
"browserName": "chrome",
"goog:chromeOptions": {
"mobileEmulation": {
"deviceMetrics": {
"width": 375,
"height": 667,
"pixelRatio": 2.0
}
}
}
}
```

- `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.

- `windowSize` (object | string | null, optional): Viewport size for the browser session. Can be:
- Object format: `{"width": 1280, "height": 720}`
- String format: `"1280x720"`
- `null` to reset to default size

> **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.

</details>

<details>
Expand Down
52 changes: 45 additions & 7 deletions src/browser-context.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
import { WdioBrowser, SessionOptions } from "testplane";
import { launchBrowser, attachToBrowser } from "testplane/unstable";
import type { StandaloneBrowserOptionsInput } from "testplane/unstable";

export interface BrowserOptions {
headless?: boolean;
desiredCapabilities?: StandaloneBrowserOptionsInput["desiredCapabilities"];
gridUrl?: string;
windowSize?: StandaloneBrowserOptionsInput["windowSize"];
}

const getSandboxArgs = (): string[] =>
process.env.DISABLE_BROWSER_SANDBOX ? ["--no-sandbox", "--disable-dev-shm-usage", "--disable-web-security"] : [];

const mergeSandboxArgs = (
desiredCapabilities: StandaloneBrowserOptionsInput["desiredCapabilities"],
sandboxArgs: string[],
): StandaloneBrowserOptionsInput["desiredCapabilities"] => {
if (!sandboxArgs.length) {
return desiredCapabilities;
}

if (!desiredCapabilities) {
return {
"goog:chromeOptions": {
args: sandboxArgs,
},
};
}

const chromeOptions = desiredCapabilities["goog:chromeOptions"] as Record<string, unknown> | undefined;
const existingArgs = (chromeOptions?.args as string[]) || [];

const mergedArgs = [...existingArgs, ...sandboxArgs];
const uniqueArgs = Array.from(new Set(mergedArgs));

return {
...desiredCapabilities,
"goog:chromeOptions": {
...(chromeOptions || {}),
args: uniqueArgs,
},
};
};

export class BrowserContext {
protected _browser: WdioBrowser | null = null;
protected _options: BrowserOptions;
Expand All @@ -27,15 +65,15 @@ export class BrowserContext {
await this._browser.getUrl(); // Need to get exception if not attach
} else {
console.error("Launch browser");

const sandboxArgs = getSandboxArgs();
const desiredCapabilities = mergeSandboxArgs(this._options.desiredCapabilities, sandboxArgs);

this._browser = await launchBrowser({
headless: this._options.headless ? "new" : false,
desiredCapabilities: {
"goog:chromeOptions": {
args: process.env.DISABLE_BROWSER_SANDBOX
? ["--no-sandbox", "--disable-dev-shm-usage", "--disable-web-security"]
: [],
},
},
desiredCapabilities,
gridUrl: this._options.gridUrl,
windowSize: this._options.windowSize,
});
}

Expand Down
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ToolDefinition } from "../types.js";
import { navigate } from "./navigate.js";
import { closeBrowser } from "./close-browser.js";
import { launchBrowser } from "./launch-browser.js";
import { clickOnElement } from "./click-on-element.js";
import { hoverElement } from "./hover-element.js";
import { typeIntoElement } from "./type-into-element.js";
Expand All @@ -16,6 +17,7 @@ import { attachToBrowser } from "./attach-to-browser.js";
export const tools = [
navigate,
closeBrowser,
launchBrowser,
clickOnElement,
hoverElement,
typeIntoElement,
Expand Down
114 changes: 114 additions & 0 deletions src/tools/launch-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { z } from "zod";
import { ToolDefinition, Context } from "../types.js";
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
import { contextProvider } from "../context-provider.js";
import { createSimpleResponse, createErrorResponse } from "../responses/index.js";
import { BrowserContext, type BrowserOptions } from "../browser-context.js";

const desiredCapabilitiesSchema = z
.object({})
.catchall(z.unknown())
.superRefine((value, ctx) => {
const browserName = value?.["browserName"];

if (browserName !== undefined && typeof browserName !== "string") {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: '"browserName" must be a string' });
}
})
.describe(
'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}}}}',
);

const windowSizeSchema = z
.union([
z
.object({
width: z.number().int().positive(),
height: z.number().int().positive(),
})
.strict(),
z
.string()
.trim()
.regex(/^[0-9]+x[0-9]+$/, {
message: '"windowSize" should use the format "<width>x<height>" (e.g. "1600x900")',
}),
z.null(),
])
.optional()
.describe(
'Viewport to use for the session. Provide {"width": number, "height": number} or a string like "1280x720"; use null to reset to the default size.',
);

export const launchBrowserSchema = {
desiredCapabilities: desiredCapabilitiesSchema.optional(),
gridUrl: z
.string()
.default("local")
.describe(
'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.',
),
windowSize: windowSizeSchema,
};

const launchBrowserCb: ToolCallback<typeof launchBrowserSchema> = async args => {
try {
const context = contextProvider.getContext();
const desiredCapabilities = args.desiredCapabilities as BrowserOptions["desiredCapabilities"];
const gridUrl = args.gridUrl ?? "local";
const windowSizeInput = args.windowSize;

if (await context.browser.isActive()) {
console.error("Closing existing browser before launching a new one");
await context.browser.close();
}

const updatedOptions: BrowserOptions = {
...context.browser.getOptions(),
};

if (Object.prototype.hasOwnProperty.call(args, "desiredCapabilities")) {
Copy link
Member

Choose a reason for hiding this comment

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

Is "Object.prototype.hasOwnProperty" different from "Object.hasOwn"?

Copy link
Member Author

Choose a reason for hiding this comment

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

Seems like the difference is very subtle

updatedOptions.desiredCapabilities = desiredCapabilities;
}

if (!gridUrl || gridUrl === "local") {
delete updatedOptions.gridUrl;
} else {
updatedOptions.gridUrl = gridUrl;
}

if (Object.prototype.hasOwnProperty.call(args, "windowSize")) {
if (windowSizeInput === null) {
updatedOptions.windowSize = null;
} else if (typeof windowSizeInput === "string") {
const [width, height] = windowSizeInput.split("x").map(value => Number.parseInt(value, 10));
updatedOptions.windowSize = { width, height };
} else if (windowSizeInput === undefined) {
delete updatedOptions.windowSize;
} else {
updatedOptions.windowSize = windowSizeInput as BrowserOptions["windowSize"];
}
}

const browserContext = new BrowserContext(updatedOptions);
const newContext: Context = {
browser: browserContext,
};
contextProvider.setContext(newContext);

await browserContext.get();

return createSimpleResponse("Successfully launched browser session");
} catch (error) {
console.error("Error launching browser:", error);
return createErrorResponse("Error launching browser", error instanceof Error ? error : undefined);
}
};

export const launchBrowser: ToolDefinition<typeof launchBrowserSchema> = {
name: "launchBrowser",
description:
"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.",
schema: launchBrowserSchema,
cb: launchBrowserCb,
};
41 changes: 41 additions & 0 deletions test/playground/mobile-info.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Mobile Emulation Diagnostics</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
line-height: 1.6;
}
h1 {
color: #333;
}
p {
margin: 0.5rem 0;
}
</style>
</head>
<body>
<h1>Mobile Emulation Diagnostics</h1>
<p id="viewport-width">Viewport width:</p>
<p id="viewport-height">Viewport height:</p>
<p id="device-pixel-ratio">Device pixel ratio:</p>
<p id="user-agent">User agent:</p>
<script>
const $ = id => document.getElementById(id);

const updateDiagnostics = () => {
$("viewport-width").textContent = `Viewport width: ${Math.round(window.innerWidth)}`;
$("viewport-height").textContent = `Viewport height: ${Math.round(window.innerHeight)}`;
$("device-pixel-ratio").textContent = `Device pixel ratio: ${window.devicePixelRatio}`;
$("user-agent").textContent = `User agent: ${navigator.userAgent}`;
};

updateDiagnostics();
window.addEventListener("resize", updateDiagnostics);
</script>
</body>
</html>
Loading