feat(frontend): add block tests (#8804)

<!-- Clearly explain the need for these changes: -->
We want to be able to automatically test agent running creation and
building via the build page

### Changes 🏗️
- updates many UI elements to have new data ids 
- adds page for build
- adds spec for build
<!-- Concisely describe all of the changes made in this pull request:
-->

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run the UI Tests!

---------

Co-authored-by: Bently <tomnoon9@gmail.com>
This commit is contained in:
Nicholas Tindle
2024-12-03 10:10:46 -06:00
committed by GitHub
parent 43bd5c89d7
commit 89011aabe0
11 changed files with 563 additions and 5 deletions

View File

@@ -566,6 +566,17 @@ export function CustomNode({
className={`${blockClasses} ${errorClass} ${statusClass}`}
data-id={`custom-node-${id}`}
z-index={1}
data-blockid={data.block_id}
data-blockname={data.title}
data-blocktype={data.blockType}
data-nodetype={data.uiType}
data-category={data.categories[0]?.category.toLowerCase() || ""}
data-inputs={JSON.stringify(
Object.keys(data.inputSchema?.properties || {}),
)}
data-outputs={JSON.stringify(
Object.keys(data.outputSchema?.properties || {}),
)}
>
{/* Header */}
<div

View File

@@ -59,6 +59,7 @@ const NodeHandle: FC<HandleProps> = ({
<div key={keyName} className="handle-container">
<Handle
type="target"
data-testid={`input-handle-${keyName}`}
position={Position.Left}
id={keyName}
className="-ml-[26px]"
@@ -76,6 +77,7 @@ const NodeHandle: FC<HandleProps> = ({
<div key={keyName} className="handle-container justify-end">
<Handle
type="source"
data-testid={`output-handle-${keyName}`}
position={Position.Right}
id={keyName}
className="group -mr-[26px]"

View File

@@ -122,6 +122,8 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
variant="ghost"
size="icon"
data-id="blocks-control-popover-trigger"
data-testid="blocks-control-blocks-button"
name="Blocks"
>
<IconToyBrick />
</Button>
@@ -143,6 +145,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
htmlFor="search-blocks"
className="whitespace-nowrap text-base font-bold text-black 2xl:text-xl"
data-id="blocks-control-label"
data-testid="blocks-control-blocks-label"
>
Blocks
</Label>
@@ -205,6 +208,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
<span
className="block truncate pb-1 text-sm font-semibold"
data-id={`block-name-${block.id}`}
data-testid={`block-name-${block.id}`}
>
<TextRenderer
value={beautifyString(block.name).replace(
@@ -214,7 +218,10 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
truncateLengthLimit={45}
/>
</span>
<span className="block break-all text-xs font-normal text-gray-500">
<span
className="block break-all text-xs font-normal text-gray-500"
data-testid={`block-description-${block.id}`}
>
<TextRenderer
value={block.description}
truncateLengthLimit={165}
@@ -224,6 +231,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
<div
className="flex flex-shrink-0 items-center gap-1"
data-id={`block-tooltip-${block.id}`}
data-testid={`block-add`}
>
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1" />
</div>

View File

@@ -59,6 +59,7 @@ export const ControlPanel = ({
size="icon"
onClick={() => control.onClick()}
data-id={`control-button-${index}`}
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
disabled={control.disabled || false}
>
{control.icon}

View File

@@ -91,6 +91,8 @@ export const SaveControl = ({
variant="ghost"
size="icon"
data-id="save-control-popover-trigger"
data-testid="blocks-control-save-button"
name="Save"
>
<IconSave />
</Button>
@@ -115,6 +117,7 @@ export const SaveControl = ({
value={agentName}
onChange={(e) => onNameChange(e.target.value)}
data-id="save-control-name-input"
data-testid="save-control-name-input"
maxLength={100}
/>
<Label htmlFor="description">Description</Label>
@@ -125,6 +128,7 @@ export const SaveControl = ({
value={agentDescription}
onChange={(e) => onDescriptionChange(e.target.value)}
data-id="save-control-description-input"
data-testid="save-control-description-input"
maxLength={500}
/>
{agentMeta?.version && (
@@ -136,6 +140,7 @@ export const SaveControl = ({
className="col-span-3"
value={agentMeta?.version || "-"}
disabled
data-testid="save-control-version-output"
/>
</>
)}
@@ -146,6 +151,7 @@ export const SaveControl = ({
className="w-full"
onClick={handleSave}
data-id="save-control-save-agent"
data-testid="save-control-save-agent-button"
>
Save {getType()}
</Button>

View File

@@ -34,6 +34,7 @@ export function NavBarButtons({ className }: { className?: string }) {
<Link
key={button.href}
href={button.href}
data-testid={`${button.text.toLowerCase()}-nav-link`}
className={cn(
className,
"flex items-center gap-2 rounded-xl p-3",
@@ -49,6 +50,7 @@ export function NavBarButtons({ className }: { className?: string }) {
{isCloud ? (
<Link
href="/marketplace"
data-testid="marketplace-nav-link"
className={cn(
className,
"flex items-center gap-2 rounded-xl p-3",
@@ -61,6 +63,7 @@ export function NavBarButtons({ className }: { className?: string }) {
</Link>
) : (
<MarketPopup
data-testid="marketplace-nav-link"
className={cn(
className,
"flex items-center gap-2 rounded-xl p-3 text-muted-foreground hover:text-foreground",

View File

@@ -40,7 +40,11 @@ export function InputBlock({
</SelectTrigger>
<SelectContent>
{placeholder_values.map((placeholder, index) => (
<SelectItem key={index} value={placeholder.toString()}>
<SelectItem
key={index}
value={placeholder.toString()}
data-testid={`run-dialog-input-${name}-${placeholder.toString()}`}
>
{placeholder.toString()}
</SelectItem>
))}
@@ -49,6 +53,7 @@ export function InputBlock({
) : (
<Input
id={`${id}-Value`}
data-testid={`run-dialog-input-${name}`}
value={value}
onChange={(e) => onInputChange(id, "value", e.target.value)}
placeholder={placeholder_values?.[0]?.toString() || "Enter value"}

View File

@@ -72,6 +72,7 @@ export function RunnerInputUI({
</div>
<DialogFooter className="px-6 py-4">
<Button
data-testid="run-dialog-run-button"
onClick={scheduledInput ? handleSchedule : handleRun}
className="px-8 py-2 text-lg"
disabled={scheduledInput ? isScheduling : isRunning}

View File

@@ -0,0 +1,222 @@
// profile.spec.ts
import { test } from "./fixtures";
import { BuildPage } from "./pages/build.page";
test.describe("Build", () => {
let buildPage: BuildPage;
test.beforeEach(async ({ page, loginPage, testUser }, testInfo) => {
buildPage = new BuildPage(page);
// Start each test with login using worker auth
await page.goto("/login");
await loginPage.login(testUser.email, testUser.password);
await test.expect(page).toHaveURL("/");
await buildPage.navbar.clickBuildLink();
});
test("user can add a block", async ({ page }) => {
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const block = {
id: "31d1064e-7446-4693-a7d4-65e5ca1180d1",
name: "Add to Dictionary",
description: "Add to Dictionary",
};
await buildPage.addBlock(block);
await buildPage.closeBlocksPanel();
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
});
test("user can add all blocks", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 10);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
// add all the blocks in order
for (const block of blocks) {
await buildPage.addBlock(block);
}
await buildPage.closeBlocksPanel();
// check that all the blocks are visible
for (const block of blocks) {
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
// fill in the input for the agent input block
await buildPage.fillBlockInputByPlaceholder(
blocks.find((b) => b.name === "Agent Input")?.id ?? "",
"Enter Name",
"Agent Input Field",
);
await buildPage.fillBlockInputByPlaceholder(
blocks.find((b) => b.name === "Agent Output")?.id ?? "",
"Enter Name",
"Agent Output Field",
);
// check that we can save the agent with all the blocks
await buildPage.saveAgent("all blocks test", "all blocks test");
// page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
});
test("build navigation is accessible from navbar", async ({ page }) => {
await buildPage.navbar.clickBuildLink();
await test.expect(page).toHaveURL(new RegExp("/build"));
// workaround for #8788
await page.reload();
await page.reload();
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
});
test("user can add two blocks and connect them", async ({
page,
}, testInfo) => {
await test.setTimeout(testInfo.timeout * 10);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
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",
};
const block2 = {
id: "1ff065e9-88e8-4358-9d82-8dc91f622ba9",
name: "Store Value 2",
description: "Store Value Block 2",
};
// 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(new RegExp("/.*build\\?flowID=.+"));
// Wait for the save button to be enabled again
await buildPage.waitForSaveButton();
// Ensure the run button is enabled
await test.expect(buildPage.isRunButtonEnabled()).resolves.toBeTruthy();
// Run the agent
await buildPage.runAgent();
// Wait for processing to complete by checking the completion badge
await buildPage.waitForCompletionBadge();
// Get the first completion badge and verify it's visible
await test
.expect(buildPage.isCompletionBadgeVisible())
.resolves.toBeTruthy();
});
test("user can build an agent with inputs and output blocks", async ({
page,
}) => {
// simple caluclator to double input and output it
// load the pages and prep
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
// find the blocks we want
const blocks = await buildPage.getBlocks();
const inputBlock = blocks.find((b) => b.name === "Agent Input");
const outputBlock = blocks.find((b) => b.name === "Agent Output");
const calculatorBlock = blocks.find((b) => b.name === "Calculator");
if (!inputBlock || !outputBlock || !calculatorBlock) {
throw new Error("Input or output block not found");
}
// add the blocks
await buildPage.addBlock(inputBlock);
await buildPage.addBlock(outputBlock);
await buildPage.addBlock(calculatorBlock);
await buildPage.closeBlocksPanel();
await test.expect(buildPage.hasBlock(inputBlock)).resolves.toBeTruthy();
await test.expect(buildPage.hasBlock(outputBlock)).resolves.toBeTruthy();
await test
.expect(buildPage.hasBlock(calculatorBlock))
.resolves.toBeTruthy();
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",
);
await buildPage.fillBlockInputByPlaceholder(
inputBlock.id,
"Enter Name",
"Value",
);
await buildPage.fillBlockInputByPlaceholder(
outputBlock.id,
"Enter Name",
"Doubled",
);
await buildPage.selectBlockInputValue(
calculatorBlock.id,
"Operation",
"Add",
);
await buildPage.saveAgent(
"Input and Output Blocks Test",
"Testing input and output blocks",
);
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
await buildPage.runAgent();
await buildPage.fillRunDialog({
Value: "10",
});
await buildPage.clickRunDialogRunButton();
await buildPage.waitForCompletionBadge();
await test
.expect(buildPage.isCompletionBadgeVisible())
.resolves.toBeTruthy();
});
});

View File

@@ -0,0 +1,299 @@
import { ElementHandle, Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page";
interface Block {
id: string;
name: string;
description: string;
}
export class BuildPage extends BasePage {
constructor(page: Page) {
super(page);
}
async closeTutorial(): Promise<void> {
await this.page.getByRole("button", { name: "Skip Tutorial" }).click();
}
async openBlocksPanel(): Promise<void> {
if (
!(await this.page.getByTestId("blocks-control-blocks-label").isVisible())
) {
await this.page.getByTestId("blocks-control-blocks-button").click();
}
}
async closeBlocksPanel(): Promise<void> {
if (
await this.page.getByTestId("blocks-control-blocks-label").isVisible()
) {
await this.page.getByTestId("blocks-control-blocks-button").click();
}
}
async saveAgent(
name: string = "Test Agent",
description: string = "",
): Promise<void> {
await this.page.getByTestId("blocks-control-save-button").click();
await this.page.getByTestId("save-control-name-input").fill(name);
await this.page
.getByTestId("save-control-description-input")
.fill(description);
await this.page.getByTestId("save-control-save-agent-button").click();
}
async getBlocks(): Promise<Block[]> {
try {
const blocks = await this.page.locator('[data-id^="block-card-"]').all();
console.log(`found ${blocks.length} blocks`);
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()) || "";
return {
id,
name: name.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:", error);
return [];
}
}
async addBlock(block: Block): Promise<void> {
console.log(`adding block ${block.id} ${block.name} to agent`);
await this.page.getByTestId(`block-name-${block.id}`).click();
}
async isRFNodeVisible(nodeId: string): Promise<boolean> {
return await this.page.getByTestId(`rf__node-${nodeId}`).isVisible();
}
async hasBlock(block: Block): Promise<boolean> {
try {
// Use both ID and name for most precise matching
const node = await this.page
.locator(`[data-blockid="${block.id}"]`)
.first();
return await node.isVisible();
} catch (error) {
console.error("Error checking for block:", error);
return false;
}
}
async getBlockInputs(blockId: string): Promise<string[]> {
try {
const node = await 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 getBlockOutputs(blockId: string): Promise<string[]> {
throw new Error("Not implemented");
// try {
// const node = await this.page
// .locator(`[data-blockid="${blockId}"]`)
// .first();
// const outputsData = await node.getAttribute("data-outputs");
// return outputsData ? JSON.parse(outputsData) : [];
// } catch (error) {
// console.error("Error getting block outputs:", error);
// return [];
// }
}
async build_block_selector(
blockId: string,
dataId?: string,
): Promise<string> {
let selector = dataId
? `[data-id="${dataId}"] [data-blockid="${blockId}"]`
: `[data-blockid="${blockId}"]`;
return selector;
}
async getBlockById(blockId: string, dataId?: string): Promise<Locator> {
return await this.page.locator(
await this.build_block_selector(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> {
const block = await this.getBlockById(blockId, dataId);
const input = await block.getByPlaceholder(placeholder);
await input.fill(value);
}
async selectBlockInputValue(
blockId: string,
inputName: string,
value: string,
dataId?: string,
): Promise<void> {
// First get the button that opens the dropdown
const baseSelector = await this.build_block_selector(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> {
// throw new Error("Not implemented");
const block = await this.getBlockById(blockId);
const input = await block.getByLabel(label);
await input.fill(value);
}
async connectBlockOutputToBlockInputViaDataId(
blockOutputId: string,
blockInputId: string,
): Promise<void> {
try {
// Locate the output element
const outputElement = await this.page.locator(
`[data-id="${blockOutputId}"]`,
);
// Locate the input element
const inputElement = await 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> {
const startBlockBase = await this.build_block_selector(
startBlockId,
startDataId,
);
const endBlockBase = await this.build_block_selector(endBlockId, endDataId);
// Use descendant combinator to find test-id at any depth
const startBlockOutputSelector = `${startBlockBase} [data-testid="output-handle-${startBlockOutputName.toLowerCase()}"]`;
const endBlockInputSelector = `${endBlockBase} [data-testid="input-handle-${endBlockInputName.toLowerCase()}"]`;
// Log for debugging
console.log("Start block selector:", startBlockOutputSelector);
console.log("End block selector:", endBlockInputSelector);
await this.page
.locator(startBlockOutputSelector)
.dragTo(this.page.locator(endBlockInputSelector));
}
async isLoaded(): Promise<boolean> {
try {
await this.page.waitForLoadState("networkidle", { timeout: 10_000 });
return true;
} catch (error) {
return false;
}
}
async isRunButtonEnabled(): Promise<boolean> {
const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
return await runButton.isEnabled();
}
async runAgent(): Promise<void> {
const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
await runButton.click();
}
async fillRunDialog(inputs: Record<string, string>): Promise<void> {
for (const [key, value] of Object.entries(inputs)) {
await this.page.getByTestId(`run-dialog-input-${key}`).fill(value);
}
}
async clickRunDialogRunButton(): Promise<void> {
await this.page.getByTestId("run-dialog-run-button").click();
}
async waitForCompletionBadge(): Promise<void> {
await this.page.waitForSelector(
'[data-id^="badge-"][data-id$="-COMPLETED"]',
);
}
async waitForSaveButton(): Promise<void> {
await this.page.waitForSelector(
'[data-testid="blocks-control-save-button"]:not([disabled])',
);
}
async isCompletionBadgeVisible(): Promise<boolean> {
const completionBadge = this.page
.locator('[data-id^="badge-"][data-id$="-COMPLETED"]')
.first();
return await completionBadge.isVisible();
}
}

View File

@@ -11,15 +11,15 @@ export class NavBar {
}
async clickMonitorLink() {
await this.page.getByTestId("monitor-link").click();
await this.page.getByTestId("monitor-nav-link").click();
}
async clickBuildLink() {
await this.page.getByTestId("build-link").click();
await this.page.getByTestId("build-nav-link").click();
}
async clickMarketplaceLink() {
await this.page.getByTestId("marketplace-link").click();
await this.page.getByTestId("marketplace-nav-link").click();
}
async getUserMenuButton() {