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 .prototools
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
dagger = "^0"
moon = "^1"
node = "^22"
node = "^24"
yarn = "^4"

[plugins]
Expand Down
6 changes: 4 additions & 2 deletions apps/ext-e2e/.swcrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"$schema": "https://swc.rs/schema.json",
"jsc": {
"parser": {
"syntax": "typescript",
Expand All @@ -10,11 +11,12 @@
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2022",
"target": "es2023",
"keepClassNames": true
},
"module": {
"type": "es6"
}
},
"sourceMaps": "inline"
}

6 changes: 5 additions & 1 deletion apps/ext-e2e/package.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
{
"name": "@mcp-browser-kit/ext-e2e"
"name": "@mcp-browser-kit/ext-e2e",
"scripts": {
"test": "TS_NODE_TRANSPILE_ONLY=true TS_NODE_COMPILER_OPTIONS='{\"module\":\"CommonJS\"}' NODE_OPTIONS='--require ts-node/register --enable-source-maps' playwright test",
"test:debug": "TS_NODE_TRANSPILE_ONLY=true TS_NODE_COMPILER_OPTIONS='{\"module\":\"CommonJS\"}' NODE_OPTIONS='--require ts-node/register --enable-source-maps' PWDEBUG=1 playwright test --headed"
}
}
10 changes: 9 additions & 1 deletion apps/ext-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
import type { ExtContextOptions } from "./src/fixtures/ext-context";

process.env.NODE_OPTIONS = "--require @swc-node/register";

// Set browser path for Playwright extension debugging
process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.resolve(
__dirname,
"../../.tmp/playwright/browsers",
);

export default defineConfig<ExtContextOptions>({
testDir: "./src/tests",
outputDir: "target/playwright/test-results",
Expand Down Expand Up @@ -31,11 +38,12 @@ export default defineConfig<ExtContextOptions>({
webServer: [
{
command: "moon run ext-e2e-test-app:react-router-start-csr",
cwd: path.resolve(__dirname, "../.."),
url: "http://localhost:3000",
timeout: 30000,
reuseExistingServer: !process.env.CI,
env: {
// biome-ignore lint/style/useNamingConvention: The issue is that process.env.NODE_OPTIONS = "--require @swc-node/register" is inherited by the webServer child process, causing Yarn to fail.
// biome-ignore lint/style/useNamingConvention: process.env.NODE_OPTIONS = "--require @swc-node/register" is inherited by the webServer child process, causing Yarn to fail.
NODE_OPTIONS: "",
},
},
Expand Down
7 changes: 7 additions & 0 deletions apps/ext-e2e/src/fixtures/ext-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { McpClientPageObject } from "../pages/mcp-client-page-object";
import { PlaywrightPage } from "../pages/playwright-page";
import { TestAppPage } from "../pages/test-app-page";
import {
type ExtContextFixtures,
type ExtContextOptions,
Expand All @@ -12,6 +13,7 @@ export type ExtTestFixtures = ExtContextFixtures &
ExtContextOptions & {
playwrightPage: PlaywrightPage;
mcpClientPage: McpClientPageObject;
testAppPage: TestAppPage;
};

export const test = extContextTest.extend<
Expand All @@ -29,6 +31,11 @@ export const test = extContextTest.extend<
await use(mcpClientPage);
await mcpClientPage.disconnect();
},
testAppPage: async ({ context }, use) => {
const page = await context.newPage();
const testAppPage = new TestAppPage(page);
await use(testAppPage);
},
});

export { expect } from "@playwright/test";
1 change: 1 addition & 0 deletions apps/ext-e2e/src/pages/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { BasePage } from "./base-page";
export { McpClientPageObject } from "./mcp-client-page-object";
export { PlaywrightPage } from "./playwright-page";
export { TestAppPage } from "./test-app-page";
94 changes: 94 additions & 0 deletions apps/ext-e2e/src/pages/test-app-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Locator, Page } from "@playwright/test";
import { BasePage } from "./base-page";

const TEST_APP_BASE_URL = process.env.TEST_APP_URL ?? "http://localhost:3000";

export class TestAppPage extends BasePage {
readonly homeUrl = TEST_APP_BASE_URL;
readonly clickTestUrl = `${TEST_APP_BASE_URL}/click-test`;
readonly formTestUrl = `${TEST_APP_BASE_URL}/form-test`;
readonly textTestUrl = `${TEST_APP_BASE_URL}/text-test`;
readonly javascriptTestUrl = `${TEST_APP_BASE_URL}/javascript-test`;

readonly pageTitle: Locator;

constructor(page: Page) {
super(page);
this.pageTitle = this.getByTestId("page-title");
}

async navigateToHome() {
await this.goto(this.homeUrl);
await this.waitForPageLoad();
}

async navigateToClickTest() {
await this.goto(this.clickTestUrl);
await this.waitForPageLoad();
}

async navigateToFormTest() {
await this.goto(this.formTestUrl);
await this.waitForPageLoad();
}

async navigateToTextTest() {
await this.goto(this.textTestUrl);
await this.waitForPageLoad();
}

async navigateToJavaScriptTest() {
await this.goto(this.javascriptTestUrl);
await this.waitForPageLoad();
}

getClickTestLocators() {
return {
clickCount: this.getByTestId("click-count"),
lastClicked: this.getByTestId("last-clicked"),
primaryButton: this.getByTestId("primary-button"),
secondaryButton: this.getByTestId("secondary-button"),
dangerButton: this.getByTestId("danger-button"),
topLeftButton: this.getByTestId("top-left-button"),
centerButton: this.getByTestId("center-button"),
nestedButton: this.getByTestId("nested-button"),
};
}

getFormTestLocators() {
return {
searchInput: this.getByTestId("search-input"),
searchButton: this.getByTestId("search-button"),
searchResult: this.getByTestId("search-result"),
usernameInput: this.getByTestId("username-input"),
emailInput: this.getByTestId("email-input"),
passwordInput: this.getByTestId("password-input"),
messageTextarea: this.getByTestId("message-textarea"),
submitButton: this.getByTestId("submit-button"),
submittedData: this.getByTestId("submitted-data"),
formState: this.getByTestId("form-state"),
};
}

getTextTestLocators() {
return {
heading1: this.getByTestId("heading-1"),
paragraph1: this.getByTestId("paragraph-1"),
selectableText: this.getByTestId("selectable-text"),
dataTable: this.getByTestId("data-table"),
navigation: this.getByTestId("navigation"),
ariaButtonClose: this.getByTestId("aria-button-close"),
};
}

getJavaScriptTestLocators() {
return {
pageInfo: this.getByTestId("page-info"),
renderCount: this.getByTestId("render-count"),
testContainer: this.getByTestId("test-container"),
dynamicContent: this.getByTestId("dynamic-content"),
styleTarget: this.getByTestId("style-target"),
dataElement: this.getByTestId("data-element"),
};
}
}
7 changes: 7 additions & 0 deletions apps/ext-e2e/src/test-utils/assert-defined.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { expect } from "@playwright/test";

export function expectToBeDefined<T>(
arg: T,
): asserts arg is Exclude<T, undefined | null> {
expect(arg).toBeDefined();
}
138 changes: 138 additions & 0 deletions apps/ext-e2e/src/tests/click-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { expect, test } from "../fixtures/ext-test";
import { expectToBeDefined } from "../test-utils/assert-defined";

test.describe("Click Tools", () => {
test.beforeEach(async ({ mcpClientPage }) => {
test.setTimeout(30000);
await mcpClientPage.startServer();
await mcpClientPage.connect();
await mcpClientPage.waitForBrowsers();
});

test.describe("clickOnElement", () => {
test("clicks on button by readable path", async ({
testAppPage,
mcpClientPage,
}) => {
await testAppPage.navigateToClickTest();

const contextResult = await mcpClientPage.callTool("getContext", {});
const browsers = contextResult.structuredContent?.browsers ?? [];
const tabKey = browsers[0]?.browserWindows[0]?.tabs.find((t) =>
t.url.includes("click-test"),
)?.tabKey;
expectToBeDefined(tabKey);

const elementsResult = await mcpClientPage.callTool(
"getReadableElements",
{
tabKey,
},
);
const elements = elementsResult.structuredContent?.elements ?? [];
const primaryButtonPath = elements.find((el) =>
el[2]?.includes("Primary Button"),
)?.[0];
expectToBeDefined(primaryButtonPath);

await mcpClientPage.callTool("clickOnElement", {
tabKey,
readablePath: primaryButtonPath,
});

const locators = testAppPage.getClickTestLocators();
await expect(locators.clickCount).toContainText("Click Count: 1");
await expect(locators.lastClicked).toContainText("primary-button");
});

test("clicks on nested button element", async ({
testAppPage,
mcpClientPage,
}) => {
await testAppPage.navigateToClickTest();

const contextResult = await mcpClientPage.callTool("getContext", {});
const tabKey =
contextResult.structuredContent?.browsers[0]?.browserWindows[0]?.tabs.find(
(t) => t.url.includes("click-test"),
)?.tabKey;
expectToBeDefined(tabKey);

const elementsResult = await mcpClientPage.callTool(
"getReadableElements",
{
tabKey,
},
);
const nestedButtonPath = (
elementsResult.structuredContent?.elements ?? []
).find((el) => el[2]?.includes("Nested Button"))?.[0];
expectToBeDefined(nestedButtonPath);

await mcpClientPage.callTool("clickOnElement", {
tabKey,
readablePath: nestedButtonPath,
});

const locators = testAppPage.getClickTestLocators();
await expect(locators.lastClicked).toContainText("nested-button");
});
});

test.describe("clickOnCoordinates", () => {
test("clicks on button at specific coordinates", async ({
testAppPage,
mcpClientPage,
}) => {
await testAppPage.navigateToClickTest();

const contextResult = await mcpClientPage.callTool("getContext", {});
const tabKey =
contextResult.structuredContent?.browsers[0]?.browserWindows[0]?.tabs.find(
(t) => t.url.includes("click-test"),
)?.tabKey;
expectToBeDefined(tabKey);

const locators = testAppPage.getClickTestLocators();
const buttonBox = await locators.secondaryButton.boundingBox();
expectToBeDefined(buttonBox);

const centerX = buttonBox.x + buttonBox.width / 2;
const centerY = buttonBox.y + buttonBox.height / 2;

await mcpClientPage.callTool("clickOnCoordinates", {
tabKey,
x: centerX,
y: centerY,
});

await expect(locators.lastClicked).toContainText("secondary-button");
});

test("clicks on positioned button using coordinates", async ({
testAppPage,
mcpClientPage,
}) => {
await testAppPage.navigateToClickTest();

const contextResult = await mcpClientPage.callTool("getContext", {});
const tabKey =
contextResult.structuredContent?.browsers[0]?.browserWindows[0]?.tabs.find(
(t) => t.url.includes("click-test"),
)?.tabKey;
expectToBeDefined(tabKey);

const locators = testAppPage.getClickTestLocators();
const centerButtonBox = await locators.centerButton.boundingBox();
expectToBeDefined(centerButtonBox);

await mcpClientPage.callTool("clickOnCoordinates", {
tabKey,
x: centerButtonBox.x + centerButtonBox.width / 2,
y: centerButtonBox.y + centerButtonBox.height / 2,
});

await expect(locators.lastClicked).toContainText("center-button");
});
});
});
Loading