Compare commits

...

5 Commits

Author SHA1 Message Date
abhi1992002
54bf45656a fix(frontend): use case-insensitive regex in getBlockCardByName
beautifyString capitalizes each word (e.g. "Add To Dictionary") but
tests may pass names with different casing (e.g. "Add to Dictionary").
Playwright hasText with a string is case-insensitive but with a regex
it is case-sensitive, so add the "i" flag.
2026-03-17 11:29:33 +05:30
abhi1992002
2f32217c7c fix(frontend): address coderabbit review comments on builder e2e tests
- Use exact regex matching in getBlockCardByName to avoid partial name collisions
- Add waitForSaveComplete() to createDummyAgent to prevent race conditions
2026-03-17 10:43:45 +05:30
abhi1992002
7b64fbc931 fix(frontend): address PR review comments
Remove redundant test.setTimeout (already set in beforeEach) and remove
unused Block interface from build.page.ts.
2026-03-16 21:02:59 +05:30
Abhimanyu Yadav
1a0234c946 Merge branch 'dev' into abhi/add-builder-e2e-test 2026-03-16 21:01:56 +05:30
abhi1992002
1e14634d3d Simplify builder E2E tests and add new flow editor tests
Replace legacy builder tests with comprehensive tests for the new flow
editor. Tests now use the simpler `addBlockByClick()` method instead of
API-based block lookup, reducing complexity and improving
maintainability.
2026-03-16 18:32:02 +05:30
3 changed files with 338 additions and 810 deletions

View File

@@ -18,11 +18,8 @@ test.beforeEach(async ({ page }) => {
await page.goto("/build");
await buildPage.closeTutorial();
const [dictionaryBlock] = await buildPage.getFilteredBlocksFromAPI(
(block) => block.name === "AddToDictionaryBlock",
);
await buildPage.addBlock(dictionaryBlock);
await buildPage.addBlockByClick("Add to Dictionary");
await buildPage.waitForNodeOnCanvas(1);
await buildPage.saveAgent("Test Agent", "Test Description");
await test

View File

@@ -1,363 +1,134 @@
// TODO: These tests were written for the old (legacy) builder.
// They need to be updated to work with the new flow editor.
// Note: all the comments with //(number)! are for the docs
//ignore them when reading the code, but if you change something,
//make sure to update the docs! Your autoformmater will break this page,
// so don't run it on this file.
// --8<-- [start:BuildPageExample]
import test from "@playwright/test";
import test, { expect } from "@playwright/test";
import { BuildPage } from "./pages/build.page";
import { LoginPage } from "./pages/login.page";
import { hasUrl } from "./utils/assertion";
import { getTestUser } from "./utils/auth";
// Reason Ignore: admonishment is in the wrong place visually with correct prettier rules
// prettier-ignore
test.describe.skip("Build", () => { //(1)!
let buildPage: BuildPage; //(2)!
test.describe("Builder", () => {
let buildPage: BuildPage;
// Reason Ignore: admonishment is in the wrong place visually with correct prettier rules
// prettier-ignore
test.beforeEach(async ({ page }) => { //(3)! ts-ignore
test.setTimeout(25000);
test.beforeEach(async ({ page }) => {
test.setTimeout(60000);
const loginPage = new LoginPage(page);
const testUser = await getTestUser();
buildPage = new BuildPage(page);
// Start each test with login using worker auth
await page.goto("/login"); //(4)!
await page.goto("/login");
await loginPage.login(testUser.email, testUser.password);
await hasUrl(page, "/marketplace"); //(5)!
await buildPage.navbar.clickBuildLink();
await hasUrl(page, "/build");
await hasUrl(page, "/marketplace");
await page.goto("/build");
await page.waitForLoadState("domcontentloaded");
await buildPage.closeTutorial();
});
// Helper function to add blocks starting with a specific letter, split into parts for parallelization
async function addBlocksStartingWithSplit(letter: string, part: number, totalParts: number): Promise<void> {
const blockIdsToSkip = await buildPage.getBlocksToSkip();
const blockTypesToSkip = ["Input", "Output", "Agent", "AI"];
const targetLetter = letter.toLowerCase();
const allBlocks = await buildPage.getFilteredBlocksFromAPI(block =>
block.name[0].toLowerCase() === targetLetter &&
!blockIdsToSkip.includes(block.id) &&
!blockTypesToSkip.includes(block.type)
);
// --- Core tests ---
const blocksToAdd = allBlocks.filter((_, index) =>
index % totalParts === (part - 1)
);
console.log(`Adding ${blocksToAdd.length} blocks starting with "${letter}" (part ${part}/${totalParts})`);
for (const block of blocksToAdd) {
await buildPage.addBlock(block);
}
}
// Reason Ignore: admonishment is in the wrong place visually with correct prettier rules
// prettier-ignore
test("user can add a block", async ({ page: _page }) => { //(6)!
await buildPage.openBlocksPanel(); //(10)!
const blocks = await buildPage.getFilteredBlocksFromAPI(block => block.name[0].toLowerCase() === "a");
const block = blocks.at(-1);
if (!block) throw new Error("No block found");
await buildPage.addBlock(block); //(11)!
await buildPage.closeBlocksPanel(); //(12)!
await buildPage.hasBlock(block); //(13)!
});
// --8<-- [end:BuildPageExample]
test("user can add blocks starting with a (part 1)", async () => {
await addBlocksStartingWithSplit("a", 1, 2);
test("build page loads successfully", async () => {
await expect(buildPage.isLoaded()).resolves.toBeTruthy();
await expect(
buildPage.getPlaywrightPage().getByTestId("blocks-control-blocks-button"),
).toBeVisible();
await expect(
buildPage.getPlaywrightPage().getByTestId("save-control-save-button"),
).toBeVisible();
});
test("user can add blocks starting with a (part 2)", async () => {
await addBlocksStartingWithSplit("a", 2, 2);
test("user can add a block via block menu", async () => {
const initialCount = await buildPage.getNodeCount();
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(initialCount + 1);
expect(await buildPage.getNodeCount()).toBe(initialCount + 1);
});
test("user can add blocks starting with b", async () => {
await addBlocksStartingWithSplit("b", 1, 1);
test("user can add multiple blocks", async () => {
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(1);
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(2);
expect(await buildPage.getNodeCount()).toBe(2);
});
test("user can add blocks starting with c", async () => {
await addBlocksStartingWithSplit("c", 1, 1);
test("user can remove a block", async () => {
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(1);
// Deselect, then re-select the node and delete
await buildPage.clickCanvas();
await buildPage.selectNode(0);
await buildPage.deleteSelectedNodes();
await expect(buildPage.getNodeLocator()).toHaveCount(0, { timeout: 5000 });
});
test("user can add blocks starting with d", async () => {
await addBlocksStartingWithSplit("d", 1, 1);
test("user can save an agent", async ({ page }) => {
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(1);
await buildPage.saveAgent("E2E Test Agent", "Created by e2e test");
await buildPage.waitForSaveComplete();
expect(page.url()).toContain("flowID=");
});
test("user can add blocks starting with e", async () => {
test.setTimeout(60000); // Increase timeout for many Exa blocks
await addBlocksStartingWithSplit("e", 1, 2);
});
test("user can save and run button becomes enabled", async () => {
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(1);
test("user can add blocks starting with e pt 2", async () => {
test.setTimeout(60000); // Increase timeout for many Exa blocks
await addBlocksStartingWithSplit("e", 2, 2);
});
test("user can add blocks starting with f", async () => {
await addBlocksStartingWithSplit("f", 1, 1);
});
test("user can add blocks starting with g (part 1)", async () => {
await addBlocksStartingWithSplit("g", 1, 3);
});
test("user can add blocks starting with g (part 2)", async () => {
await addBlocksStartingWithSplit("g", 2, 3);
});
test("user can add blocks starting with g (part 3)", async () => {
await addBlocksStartingWithSplit("g", 3, 3);
});
test("user can add blocks starting with h", async () => {
await addBlocksStartingWithSplit("h", 1, 1);
});
test("user can add blocks starting with i", async () => {
await addBlocksStartingWithSplit("i", 1, 1);
});
test("user can add blocks starting with j", async () => {
await addBlocksStartingWithSplit("j", 1, 1);
});
test("user can add blocks starting with k", async () => {
await addBlocksStartingWithSplit("k", 1, 1);
});
test("user can add blocks starting with l", async () => {
await addBlocksStartingWithSplit("l", 1, 1);
});
test("user can add blocks starting with m", async () => {
await addBlocksStartingWithSplit("m", 1, 1);
});
test("user can add blocks starting with n", async () => {
await addBlocksStartingWithSplit("n", 1, 1);
});
test("user can add blocks starting with o", async () => {
await addBlocksStartingWithSplit("o", 1, 1);
});
test("user can add blocks starting with p", async () => {
await addBlocksStartingWithSplit("p", 1, 1);
});
test("user can add blocks starting with q", async () => {
await addBlocksStartingWithSplit("q", 1, 1);
});
test("user can add blocks starting with r", async () => {
await addBlocksStartingWithSplit("r", 1, 1);
});
test("user can add blocks starting with s (part 1)", async () => {
await addBlocksStartingWithSplit("s", 1, 3);
});
test("user can add blocks starting with s (part 2)", async () => {
await addBlocksStartingWithSplit("s", 2, 3);
});
test("user can add blocks starting with s (part 3)", async () => {
await addBlocksStartingWithSplit("s", 3, 3);
});
test("user can add blocks starting with t", async () => {
await addBlocksStartingWithSplit("t", 1, 1);
});
test("user can add blocks starting with u", async () => {
await addBlocksStartingWithSplit("u", 1, 1);
});
test("user can add blocks starting with v", async () => {
await addBlocksStartingWithSplit("v", 1, 1);
});
test("user can add blocks starting with w", async () => {
await addBlocksStartingWithSplit("w", 1, 1);
});
test("user can add blocks starting with x", async () => {
await addBlocksStartingWithSplit("x", 1, 1);
});
test("user can add blocks starting with y", async () => {
await addBlocksStartingWithSplit("y", 1, 1);
});
test("user can add blocks starting with z", async () => {
await addBlocksStartingWithSplit("z", 1, 1);
});
test("build navigation is accessible from navbar", async ({ page }) => {
// Navigate somewhere else first
await page.goto("/marketplace"); //(4)!
// Check that navigation to the Builder is available on the page
await buildPage.navbar.clickBuildLink();
await hasUrl(page, "/build");
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
});
test("user can add two blocks and connect them", async ({ page }) => {
await buildPage.openBlocksPanel();
// Define the blocks to add
const block1 = {
id: "1ff065e9-88e8-4358-9d82-8dc91f622ba9",
name: "Store Value 1",
description: "Store Value Block 1",
type: "Standard",
};
const block2 = {
id: "1ff065e9-88e8-4358-9d82-8dc91f622ba9",
name: "Store Value 2",
description: "Store Value Block 2",
type: "Standard",
};
// Add the blocks
await buildPage.addBlock(block1);
await buildPage.addBlock(block2);
await buildPage.closeBlocksPanel();
// Connect the blocks
await buildPage.connectBlockOutputToBlockInputViaDataId(
"1-1-output-source",
"1-2-input-target",
);
// Fill in the input for the first block
await buildPage.fillBlockInputByPlaceholder(
block1.id,
"Enter input",
"Test Value",
"1",
);
// Save the agent and wait for the URL to update
await buildPage.saveAgent(
"Connected Blocks Test",
"Testing block connections",
);
await test.expect(page).toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
// Wait for the save button to be enabled again
await buildPage.saveAgent("Runnable Agent", "Test run button");
await buildPage.waitForSaveComplete();
await buildPage.waitForSaveButton();
// Ensure the run button is enabled
await test.expect(buildPage.isRunButtonEnabled()).resolves.toBeTruthy();
await expect(buildPage.isRunButtonEnabled()).resolves.toBeTruthy();
});
test.skip("user can build an agent with inputs and output blocks", async ({ page }, testInfo) => {
test.setTimeout(testInfo.timeout * 10);
// --- Copy / Paste test ---
// prep
await buildPage.openBlocksPanel();
test("user can copy and paste a node", async ({ context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
// Get input block from Input category
const inputBlocks = await buildPage.getBlocksForCategory("Input");
const inputBlock = inputBlocks.find((b) => b.name === "Agent Input");
if (!inputBlock) throw new Error("Input block not found");
await buildPage.addBlock(inputBlock);
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(1);
// Get output block from Output category
const outputBlocks = await buildPage.getBlocksForCategory("Output");
const outputBlock = outputBlocks.find((b) => b.name === "Agent Output");
if (!outputBlock) throw new Error("Output block not found");
await buildPage.addBlock(outputBlock);
await buildPage.selectNode(0);
await buildPage.copyViaKeyboard();
await buildPage.pasteViaKeyboard();
// Get calculator block from Logic category
const logicBlocks = await buildPage.getBlocksForCategory("Logic");
const calculatorBlock = logicBlocks.find((b) => b.name === "Calculator");
if (!calculatorBlock) throw new Error("Calculator block not found");
await buildPage.addBlock(calculatorBlock);
await buildPage.waitForNodeOnCanvas(2);
expect(await buildPage.getNodeCount()).toBe(2);
});
await buildPage.closeBlocksPanel();
// --- Run agent test ---
// Wait for blocks to be fully loaded
await page.waitForTimeout(1000);
test("user can run an agent from the builder", async () => {
await buildPage.addBlockByClick("Store Value");
await buildPage.waitForNodeOnCanvas(1);
// Wait for blocks to be ready for connections
await page.waitForTimeout(1000);
// Save the agent (required before running)
await buildPage.saveAgent("Run Test Agent", "Testing run from builder");
await buildPage.waitForSaveComplete();
await buildPage.waitForSaveButton();
await buildPage.connectBlockOutputToBlockInputViaName(
inputBlock.id,
"Result",
calculatorBlock.id,
"A",
);
await buildPage.connectBlockOutputToBlockInputViaName(
inputBlock.id,
"Result",
calculatorBlock.id,
"B",
);
await buildPage.connectBlockOutputToBlockInputViaName(
calculatorBlock.id,
"Result",
outputBlock.id,
"Value",
);
// Click run button
await buildPage.clickRunButton();
// Wait for connections to stabilize
await page.waitForTimeout(1000);
// Either the run dialog appears or the agent starts running directly
const runDialogOrRunning = await Promise.race([
buildPage
.getPlaywrightPage()
.locator('[data-id="run-input-dialog-content"]')
.waitFor({ state: "visible", timeout: 10000 })
.then(() => "dialog"),
buildPage
.getPlaywrightPage()
.locator('[data-id="stop-graph-button"]')
.waitFor({ state: "visible", timeout: 10000 })
.then(() => "running"),
]).catch(() => "timeout");
await buildPage.fillBlockInputByPlaceholder(
inputBlock.id,
"Enter Name",
"Value",
);
await buildPage.fillBlockInputByPlaceholder(
outputBlock.id,
"Enter Name",
"Doubled",
);
// Wait before changing dropdown
await page.waitForTimeout(500);
await buildPage.selectBlockInputValue(
calculatorBlock.id,
"Operation",
"Add",
);
// Wait before saving
await page.waitForTimeout(1000);
await buildPage.saveAgent(
"Input and Output Blocks Test",
"Testing input and output blocks",
);
await test.expect(page).toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
// Wait for save to complete
await page.waitForTimeout(1000);
// await buildPage.runAgent();
// await buildPage.fillRunDialog({
// Value: "10",
// });
// await buildPage.clickRunDialogRunButton();
// await buildPage.waitForCompletionBadge();
// await test
// .expect(buildPage.isCompletionBadgeVisible())
// .resolves.toBeTruthy();
expect(["dialog", "running"]).toContain(runDialogOrRunning);
});
});

View File

@@ -1,44 +1,47 @@
import { Locator, Page } from "@playwright/test";
import { Block as APIBlock } from "../../lib/autogpt-server-api/types";
import { beautifyString } from "../../lib/utils";
import { expect, Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page";
export interface Block {
id: string;
name: string;
description: string;
type: string;
}
export class BuildPage extends BasePage {
private cachedBlocks: Record<string, Block> = {};
constructor(page: Page) {
super(page);
}
private getDisplayName(blockName: string): string {
return beautifyString(blockName).replace(/ Block$/, "");
// --- Navigation ---
async goto(): Promise<void> {
await this.page.goto("/build");
await this.page.waitForLoadState("domcontentloaded");
}
async isLoaded(): Promise<boolean> {
try {
await this.page.waitForLoadState("domcontentloaded", { timeout: 10_000 });
await this.page
.locator(".react-flow")
.waitFor({ state: "visible", timeout: 10_000 });
return true;
} catch {
return false;
}
}
async closeTutorial(): Promise<void> {
console.log(`closing tutorial`);
try {
await this.page
.getByRole("button", { name: "Skip Tutorial", exact: true })
.click({ timeout: 3000 });
} catch (_error) {
console.info("Tutorial not shown or already dismissed");
} catch {
// Tutorial not shown or already dismissed
}
}
// --- Block Menu ---
async openBlocksPanel(): Promise<void> {
const popoverContent = this.page.locator(
'[data-id="blocks-control-popover-content"]',
);
const isPanelOpen = await popoverContent.isVisible();
if (!isPanelOpen) {
if (!(await popoverContent.isVisible())) {
await this.page.getByTestId("blocks-control-blocks-button").click();
await popoverContent.waitFor({ state: "visible", timeout: 5000 });
}
@@ -50,501 +53,258 @@ export class BuildPage extends BasePage {
);
if (await popoverContent.isVisible()) {
await this.page.getByTestId("blocks-control-blocks-button").click();
await popoverContent.waitFor({ state: "hidden", timeout: 5000 });
}
}
async searchBlock(searchTerm: string): Promise<void> {
const searchInput = this.page.locator(
'[data-id="blocks-control-search-bar"] input[type="text"]',
);
await searchInput.clear();
await searchInput.fill(searchTerm);
await this.page.waitForTimeout(300);
}
private getBlockCardByName(name: string): Locator {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const exactName = new RegExp(`^\\s*${escapedName}\\s*$`, "i");
return this.page
.locator('[data-id^="block-card-"]')
.filter({ has: this.page.locator("span", { hasText: exactName }) })
.first();
}
async addBlockByClick(searchTerm: string): Promise<void> {
await this.openBlocksPanel();
await this.searchBlock(searchTerm);
// Wait for any search results to appear
const anyCard = this.page.locator('[data-id^="block-card-"]').first();
await anyCard.waitFor({ state: "visible", timeout: 10000 });
// Click the card matching the search term name
const blockCard = this.getBlockCardByName(searchTerm);
await blockCard.waitFor({ state: "visible", timeout: 5000 });
await blockCard.click();
// Close the panel so it doesn't overlay the canvas
await this.closeBlocksPanel();
}
async dragBlockToCanvas(searchTerm: string): Promise<void> {
await this.openBlocksPanel();
await this.searchBlock(searchTerm);
const anyCard = this.page.locator('[data-id^="block-card-"]').first();
await anyCard.waitFor({ state: "visible", timeout: 10000 });
const blockCard = this.getBlockCardByName(searchTerm);
await blockCard.waitFor({ state: "visible", timeout: 5000 });
const canvas = this.page.locator(".react-flow__pane").first();
await blockCard.dragTo(canvas);
}
// --- Nodes on Canvas ---
getNodeLocator(index?: number): Locator {
const locator = this.page.locator('[data-id^="custom-node-"]');
return index !== undefined ? locator.nth(index) : locator;
}
async getNodeCount(): Promise<number> {
return await this.getNodeLocator().count();
}
async waitForNodeOnCanvas(expectedCount?: number): Promise<void> {
if (expectedCount !== undefined) {
await expect(this.getNodeLocator()).toHaveCount(expectedCount, {
timeout: 10000,
});
} else {
await this.getNodeLocator()
.first()
.waitFor({ state: "visible", timeout: 10000 });
}
}
async selectNode(index: number = 0): Promise<void> {
const node = this.getNodeLocator(index);
await node.click();
}
async selectAllNodes(): Promise<void> {
await this.page.locator(".react-flow__pane").first().click();
const isMac = process.platform === "darwin";
await this.page.keyboard.press(isMac ? "Meta+a" : "Control+a");
}
async deleteSelectedNodes(): Promise<void> {
await this.page.keyboard.press("Backspace");
}
// --- Connections (Edges) ---
async connectNodes(
sourceNodeIndex: number,
targetNodeIndex: number,
): Promise<void> {
// Get the node wrapper elements to scope handle search
const sourceNode = this.getNodeLocator(sourceNodeIndex);
const targetNode = this.getNodeLocator(targetNodeIndex);
// ReactFlow renders Handle components as .react-flow__handle elements
// Output handles have class .react-flow__handle-right (Position.Right)
// Input handles have class .react-flow__handle-left (Position.Left)
const sourceHandle = sourceNode
.locator(".react-flow__handle-right")
.first();
const targetHandle = targetNode.locator(".react-flow__handle-left").first();
// Get precise center coordinates using evaluate to avoid CSS transform issues
const getHandleCenter = async (locator: Locator) => {
const el = await locator.elementHandle();
if (!el) throw new Error("Handle element not found");
const rect = await el.evaluate((node) => {
const r = node.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
return rect;
};
const source = await getHandleCenter(sourceHandle);
const target = await getHandleCenter(targetHandle);
// ReactFlow requires a proper drag sequence with intermediate moves
await this.page.mouse.move(source.x, source.y);
await this.page.mouse.down();
// Move in steps to trigger ReactFlow's connection detection
const steps = 20;
for (let i = 1; i <= steps; i++) {
const ratio = i / steps;
await this.page.mouse.move(
source.x + (target.x - source.x) * ratio,
source.y + (target.y - source.y) * ratio,
);
}
await this.page.mouse.up();
}
async getEdgeCount(): Promise<number> {
return await this.page.locator(".react-flow__edge").count();
}
// --- Save ---
async saveAgent(
name: string = "Test Agent",
description: string = "",
): Promise<void> {
console.log(`Saving agent '${name}' with description '${description}'`);
await this.page.getByTestId("save-control-save-button").click();
await this.page.getByTestId("save-control-name-input").fill(name);
await this.page
.getByTestId("save-control-description-input")
.fill(description);
const nameInput = this.page.getByTestId("save-control-name-input");
await nameInput.waitFor({ state: "visible", timeout: 5000 });
await nameInput.fill(name);
if (description) {
await this.page
.getByTestId("save-control-description-input")
.fill(description);
}
await this.page.getByTestId("save-control-save-agent-button").click();
}
async getBlocksFromAPI(): Promise<Block[]> {
if (Object.keys(this.cachedBlocks).length > 0) {
return Object.values(this.cachedBlocks);
}
async waitForSaveComplete(): Promise<void> {
await expect(this.page).toHaveURL(/flowID=/, { timeout: 15000 });
}
console.log(`Getting blocks from API request`);
// Make direct API request using the page's request context
const response = await this.page.request.get(
"http://localhost:3000/api/proxy/api/blocks",
async waitForSaveButton(): Promise<void> {
await this.page.waitForSelector(
'[data-testid="save-control-save-button"]:not([disabled])',
{ timeout: 10000 },
);
const apiBlocks: APIBlock[] = await response.json();
console.log(`Found ${apiBlocks.length} blocks from API`);
// Convert API blocks to test Block format
const blocks = apiBlocks.map((block) => ({
id: block.id,
name: block.name,
description: block.description,
type: block.uiType,
}));
this.cachedBlocks = blocks.reduce(
(acc, block) => {
acc[block.id] = block;
return acc;
},
{} as Record<string, Block>,
);
return blocks;
}
async getFilteredBlocksFromAPI(
filterFn: (block: Block) => boolean,
): Promise<Block[]> {
console.log(`Getting filtered blocks from API`);
const blocks = await this.getBlocksFromAPI();
return blocks.filter(filterFn);
}
async addBlock(block: Block): Promise<void> {
console.log(`Adding block ${block.name} (${block.id}) to agent`);
await this.openBlocksPanel();
const searchInput = this.page.locator(
'[data-id="blocks-control-search-bar"] input[type="text"]',
);
const displayName = this.getDisplayName(block.name);
await searchInput.clear();
await searchInput.fill(displayName);
const blockCardId = block.id.replace(/[^a-zA-Z0-9]/g, "");
const blockCard = this.page.locator(
`[data-id="block-card-${blockCardId}"]`,
);
await blockCard.waitFor({ state: "visible", timeout: 10000 });
await blockCard.click();
}
async hasBlock(_block: Block) {
// In the new flow editor, verify a node exists on the canvas
const node = this.page.locator('[data-id^="custom-node-"]').first();
await node.isVisible();
}
async getBlockInputs(blockId: string): Promise<string[]> {
console.log(`Getting block ${blockId} inputs`);
try {
const node = this.page.locator(`[data-blockid="${blockId}"]`).first();
const inputsData = await node.getAttribute("data-inputs");
return inputsData ? JSON.parse(inputsData) : [];
} catch (error) {
console.error("Error getting block inputs:", error);
return [];
}
}
async selectBlockCategory(category: string): Promise<void> {
console.log(`Selecting block category: ${category}`);
await this.page.getByText(category, { exact: true }).click();
// Wait for the blocks to load after category selection
await this.page.waitForTimeout(3000);
}
async getBlocksForCategory(category: string): Promise<Block[]> {
console.log(`Getting blocks for category: ${category}`);
// Clear any existing search to ensure we see all blocks in the category
const searchInput = this.page.locator(
'[data-id="blocks-control-search-bar"] input[type="text"]',
);
await searchInput.clear();
// Wait for search to clear
await this.page.waitForTimeout(300);
// Select the category first
await this.selectBlockCategory(category);
try {
const blockFinder = this.page.locator('[data-id^="block-card-"]');
await blockFinder.first().waitFor();
const blocks = await blockFinder.all();
console.log(`found ${blocks.length} blocks in category ${category}`);
const results = await Promise.all(
blocks.map(async (block) => {
try {
const fullId = (await block.getAttribute("data-id")) || "";
const id = fullId.replace("block-card-", "");
const nameElement = block.locator('[data-testid^="block-name-"]');
const descriptionElement = block.locator(
'[data-testid^="block-description-"]',
);
const name = (await nameElement.textContent()) || "";
const description = (await descriptionElement.textContent()) || "";
const type = (await nameElement.getAttribute("data-type")) || "";
return {
id,
name: name.trim(),
type: type.trim(),
description: description.trim(),
};
} catch (elementError) {
console.error("Error processing block:", elementError);
return null;
}
}),
);
// Filter out any null results from errors
return results.filter((block): block is Block => block !== null);
} catch (error) {
console.error(`Error getting blocks for category ${category}:`, error);
return [];
}
}
async _buildBlockSelector(blockId: string, dataId?: string): Promise<string> {
const selector = dataId
? `[data-id="${dataId}"] [data-blockid="${blockId}"]`
: `[data-blockid="${blockId}"]`;
return selector;
}
private async moveBlockToViewportPosition(
blockSelector: string,
options: { xRatio?: number; yRatio?: number } = {},
): Promise<void> {
const { xRatio = 0.5, yRatio = 0.5 } = options;
const blockLocator = this.page.locator(blockSelector).first();
await blockLocator.waitFor({ state: "visible" });
const boundingBox = await blockLocator.boundingBox();
const viewport = this.page.viewportSize();
if (!boundingBox || !viewport) {
return;
}
const currentX = boundingBox.x + boundingBox.width / 2;
const currentY = boundingBox.y + boundingBox.height / 2;
const targetX = viewport.width * xRatio;
const targetY = viewport.height * yRatio;
const distance = Math.hypot(targetX - currentX, targetY - currentY);
if (distance < 5) {
return;
}
await this.page.mouse.move(currentX, currentY);
await this.page.mouse.down();
await this.page.mouse.move(targetX, targetY, { steps: 15 });
await this.page.mouse.up();
await this.page.waitForTimeout(200);
}
async getBlockById(blockId: string, dataId?: string): Promise<Locator> {
console.log(`getting block ${blockId} with dataId ${dataId}`);
return this.page.locator(await this._buildBlockSelector(blockId, dataId));
}
// dataId is optional, if provided, it will start the search with that container, otherwise it will start with the blockId
// this is useful if you have multiple blocks with the same id, but different dataIds which you should have when adding a block to the graph.
// Do note that once you run an agent, the dataId will change, so you will need to update the tests to use the new dataId or not use the same block in tests that run an agent
async fillBlockInputByPlaceholder(
blockId: string,
placeholder: string,
value: string,
dataId?: string,
): Promise<void> {
console.log(
`filling block input ${placeholder} with value ${value} of block ${blockId}`,
);
const block = await this.getBlockById(blockId, dataId);
const input = block.getByPlaceholder(placeholder);
await input.fill(value);
}
async selectBlockInputValue(
blockId: string,
inputName: string,
value: string,
dataId?: string,
): Promise<void> {
console.log(
`selecting value ${value} for input ${inputName} of block ${blockId}`,
);
// First get the button that opens the dropdown
const baseSelector = await this._buildBlockSelector(blockId, dataId);
// Find the combobox button within the input handle container
const comboboxSelector = `${baseSelector} [data-id="input-handle-${inputName.toLowerCase()}"] button[role="combobox"]`;
try {
// Click the combobox to open it
await this.page.click(comboboxSelector);
// Wait a moment for the dropdown to open
await this.page.waitForTimeout(100);
// Select the option from the dropdown
// The actual selector for the option might need adjustment based on the dropdown structure
await this.page.getByRole("option", { name: value }).click();
} catch (error) {
console.error(
`Error selecting value "${value}" for input "${inputName}":`,
error,
);
throw error;
}
}
async fillBlockInputByLabel(
blockId: string,
label: string,
value: string,
): Promise<void> {
console.log(`filling block input ${label} with value ${value}`);
const block = await this.getBlockById(blockId);
const input = block.getByLabel(label);
await input.fill(value);
}
async connectBlockOutputToBlockInputViaDataId(
blockOutputId: string,
blockInputId: string,
): Promise<void> {
console.log(
`connecting block output ${blockOutputId} to block input ${blockInputId}`,
);
try {
// Locate the output element
const outputElement = this.page.locator(`[data-id="${blockOutputId}"]`);
// Locate the input element
const inputElement = this.page.locator(`[data-id="${blockInputId}"]`);
await outputElement.dragTo(inputElement);
} catch (error) {
console.error("Error connecting block output to input:", error);
}
}
async connectBlockOutputToBlockInputViaName(
startBlockId: string,
startBlockOutputName: string,
endBlockId: string,
endBlockInputName: string,
startDataId?: string,
endDataId?: string,
): Promise<void> {
console.log(
`connecting block output ${startBlockOutputName} of block ${startBlockId} to block input ${endBlockInputName} of block ${endBlockId}`,
);
const startBlockBase = await this._buildBlockSelector(
startBlockId,
startDataId,
);
const endBlockBase = await this._buildBlockSelector(endBlockId, endDataId);
await this.moveBlockToViewportPosition(startBlockBase, { xRatio: 0.35 });
await this.moveBlockToViewportPosition(endBlockBase, { xRatio: 0.65 });
const startBlockOutputSelector = `${startBlockBase} [data-testid="output-handle-${startBlockOutputName.toLowerCase()}"]`;
const endBlockInputSelector = `${endBlockBase} [data-testid="input-handle-${endBlockInputName.toLowerCase()}"]`;
console.log("Start block selector:", startBlockOutputSelector);
console.log("End block selector:", endBlockInputSelector);
const startElement = this.page.locator(startBlockOutputSelector);
const endElement = this.page.locator(endBlockInputSelector);
await startElement.scrollIntoViewIfNeeded();
await this.page.waitForTimeout(200);
await endElement.scrollIntoViewIfNeeded();
await this.page.waitForTimeout(200);
await startElement.dragTo(endElement);
}
async isLoaded(): Promise<boolean> {
console.log(`checking if build page is loaded`);
try {
await this.page.waitForLoadState("domcontentloaded", { timeout: 10_000 });
return true;
} catch {
return false;
}
}
// --- Run ---
async isRunButtonEnabled(): Promise<boolean> {
console.log(`checking if run button is enabled`);
const runButton = this.page.locator('[data-id="run-graph-button"]');
return await runButton.isEnabled();
}
async runAgent(): Promise<void> {
console.log(`clicking run button`);
async clickRunButton(): Promise<void> {
const runButton = this.page.locator('[data-id="run-graph-button"]');
await runButton.click();
await this.page.waitForTimeout(1000);
await runButton.click();
}
async fillRunDialog(inputs: Record<string, string>): Promise<void> {
console.log(`filling run dialog`);
for (const [key, value] of Object.entries(inputs)) {
await this.page.getByTestId(`agent-input-${key}`).fill(value);
// --- Undo / Redo ---
async isUndoEnabled(): Promise<boolean> {
const btn = this.page.locator('[data-id="undo-button"]');
return !(await btn.isDisabled());
}
async isRedoEnabled(): Promise<boolean> {
const btn = this.page.locator('[data-id="redo-button"]');
return !(await btn.isDisabled());
}
async clickUndo(): Promise<void> {
await this.page.locator('[data-id="undo-button"]').click();
}
async clickRedo(): Promise<void> {
await this.page.locator('[data-id="redo-button"]').click();
}
// --- Copy / Paste ---
async copyViaKeyboard(): Promise<void> {
const isMac = process.platform === "darwin";
await this.page.keyboard.press(isMac ? "Meta+c" : "Control+c");
}
async pasteViaKeyboard(): Promise<void> {
const isMac = process.platform === "darwin";
await this.page.keyboard.press(isMac ? "Meta+v" : "Control+v");
}
// --- Helpers ---
async fillBlockInputByPlaceholder(
placeholder: string,
value: string,
nodeIndex: number = 0,
): Promise<void> {
const node = this.getNodeLocator(nodeIndex);
const input = node.getByPlaceholder(placeholder);
await input.fill(value);
}
async clickCanvas(): Promise<void> {
const pane = this.page.locator(".react-flow__pane").first();
const box = await pane.boundingBox();
if (box) {
// Click in the center of the canvas to avoid sidebar/toolbar overlaps
await pane.click({
position: { x: box.width / 2, y: box.height / 2 },
});
} else {
await pane.click();
}
}
async clickRunDialogRunButton(): Promise<void> {
console.log(`clicking run button`);
await this.page.getByTestId("agent-run-button").click();
getPlaywrightPage(): Page {
return this.page;
}
async waitForCompletionBadge(): Promise<void> {
console.log(`waiting for completion badge`);
await this.page.waitForSelector(
'[data-id^="badge-"][data-id$="-COMPLETED"]',
);
}
async waitForSaveButton(): Promise<void> {
console.log(`waiting for save button`);
await this.page.waitForSelector(
'[data-testid="save-control-save-button"]:not([disabled])',
);
}
async isCompletionBadgeVisible(): Promise<boolean> {
console.log(`checking for completion badge`);
const completionBadge = this.page
.locator('[data-id^="badge-"][data-id$="-COMPLETED"]')
.first();
return await completionBadge.isVisible();
}
async waitForVersionField(): Promise<void> {
console.log(`waiting for version field`);
// wait for the url to have the flowID
await this.page.waitForSelector(
'[data-testid="save-control-version-output"]',
);
}
async getDictionaryBlockDetails(): Promise<Block> {
return {
id: "dummy-id-1",
name: "Add to Dictionary",
description: "Add to Dictionary",
type: "Standard",
};
}
async getCalculatorBlockDetails(): Promise<Block> {
return {
id: "dummy-id-2",
name: "Calculator",
description: "Calculator",
type: "Standard",
};
}
async waitForSaveDialogClose(): Promise<void> {
console.log(`waiting for save dialog to close`);
await this.page.waitForSelector(
'[data-id="save-control-popover-content"]',
{ state: "hidden" },
);
}
async getGithubTriggerBlockDetails(): Promise<Block[]> {
return [
{
id: "6c60ec01-8128-419e-988f-96a063ee2fea",
name: "Github Trigger",
description:
"This block triggers on pull request events and outputs the event type and payload.",
type: "Standard",
},
{
id: "551e0a35-100b-49b7-89b8-3031322239b6",
name: "Github Star Trigger",
description:
"This block triggers on star events and outputs the event type and payload.",
type: "Standard",
},
{
id: "2052dd1b-74e1-46ac-9c87-c7a0e057b60b",
name: "Github Release Trigger",
description:
"This block triggers on release events and outputs the event type and payload.",
type: "Standard",
},
{
id: "b2605464-e486-4bf4-aad3-d8a213c8a48a",
name: "Github Issue Trigger",
description:
"This block triggers on issue events and outputs the event type and payload.",
type: "Standard",
},
{
id: "87f847b3-d81a-424e-8e89-acadb5c9d52b",
name: "Github Discussion Trigger",
description:
"This block triggers on discussion events and outputs the event type and payload.",
type: "Standard",
},
];
}
async nextTutorialStep(): Promise<void> {
console.log(`clicking next tutorial step`);
await this.page.getByRole("button", { name: "Next" }).click();
}
async getBlocksToSkip(): Promise<string[]> {
return [
(await this.getGithubTriggerBlockDetails()).map((b) => b.id),
// MCP Tool block requires an interactive dialog (server URL + OAuth) before
// it can be placed, so it can't be tested via the standard "add block" flow.
"a0a4b1c2-d3e4-4f56-a7b8-c9d0e1f2a3b4",
].flat();
}
async createDummyAgent() {
async createDummyAgent(): Promise<void> {
await this.closeTutorial();
await this.openBlocksPanel();
const searchInput = this.page.locator(
'[data-id="blocks-control-search-bar"] input[type="text"]',
);
await searchInput.clear();
await searchInput.fill("Add to Dictionary");
const blockCard = this.page.locator('[data-id^="block-card-"]').first();
try {
await blockCard.waitFor({ state: "visible", timeout: 10000 });
await blockCard.click();
} catch (error) {
console.log("Could not find Add to Dictionary block:", error);
}
await this.addBlockByClick("Add to Dictionary");
await this.waitForNodeOnCanvas(1);
await this.saveAgent("Test Agent", "Test Description");
await this.waitForSaveComplete();
}
}