Repository: Significant-Gravitas/AutoGPT (language: python)

Issue:
 integration test: Export an agent to a file.

 integration test: Import an agent from a file.



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 225a4b93b029..1838a28a6169 100644
--- a/.gitignore
+++ b/.gitignore
@@ -173,3 +173,4 @@ LICENSE.rtf
 autogpt_platform/backend/settings.py
 /.auth
 /autogpt_platform/frontend/.auth
+.test-contents
\ No newline at end of file
diff --git a/autogpt_platform/frontend/src/app/page.tsx b/autogpt_platform/frontend/src/app/page.tsx
index 8637bd55bf5f..6b94861bc714 100644
--- a/autogpt_platform/frontend/src/app/page.tsx
+++ b/autogpt_platform/frontend/src/app/page.tsx
@@ -84,7 +84,10 @@ const Monitor = () => {
   };
 
   return (
-    <div className="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10">
+    <div
+      className="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10"
+      data-testid="monitor-page"
+    >
       <AgentFlowList
         className={column1}
         flows={flows}
diff --git a/autogpt_platform/frontend/src/components/agent-import-form.tsx b/autogpt_platform/frontend/src/components/agent-import-form.tsx
index f7fc72ba2a57..c3ddbf0ba885 100644
--- a/autogpt_platform/frontend/src/components/agent-import-form.tsx
+++ b/autogpt_platform/frontend/src/components/agent-import-form.tsx
@@ -132,6 +132,7 @@ export const AgentImportForm: React.FC<
                 <Input
                   type="file"
                   accept="application/json"
+                  data-testid="import-agent-file-input"
                   onChange={(e) => {
                     const file = e.target.files?.[0];
                     if (file) {
@@ -181,7 +182,7 @@ export const AgentImportForm: React.FC<
             <FormItem>
               <FormLabel>Agent name</FormLabel>
               <FormControl>
-                <Input {...field} />
+                <Input {...field} data-testid="agent-name-input" />
               </FormControl>
               <FormMessage />
             </FormItem>
@@ -195,7 +196,7 @@ export const AgentImportForm: React.FC<
             <FormItem>
               <FormLabel>Agent description</FormLabel>
               <FormControl>
-                <Textarea {...field} />
+                <Textarea {...field} data-testid="agent-description-input" />
               </FormControl>
               <FormMessage />
             </FormItem>
@@ -218,6 +219,7 @@ export const AgentImportForm: React.FC<
                     Agent
                   </span>
                   <Switch
+                    data-testid="import-as-template-switch"
                     disabled={field.disabled}
                     checked={field.value}
                     onCheckedChange={field.onChange}
@@ -235,7 +237,12 @@ export const AgentImportForm: React.FC<
             </FormItem>
           )}
         />
-        <Button type="submit" className="w-full" disabled={!agentObject}>
+        <Button
+          type="submit"
+          className="w-full"
+          disabled={!agentObject}
+          data-testid="import-agent-submit"
+        >
           <EnterIcon className="mr-2" /> Import & Edit
         </Button>
       </form>
diff --git a/autogpt_platform/frontend/src/components/monitor/AgentFlowList.tsx b/autogpt_platform/frontend/src/components/monitor/AgentFlowList.tsx
index e9fda65a69a1..b27792d73ba5 100644
--- a/autogpt_platform/frontend/src/components/monitor/AgentFlowList.tsx
+++ b/autogpt_platform/frontend/src/components/monitor/AgentFlowList.tsx
@@ -68,6 +68,7 @@ export const AgentFlowList = ({
                 <Button
                   variant="outline"
                   className={"rounded-l-none border-l-0 px-2"}
+                  data-testid="create-agent-dropdown"
                 >
                   <ChevronDownIcon />
                 </Button>
@@ -75,7 +76,7 @@ export const AgentFlowList = ({
 
               <DropdownMenuContent>
                 <DialogTrigger asChild>
-                  <DropdownMenuItem>
+                  <DropdownMenuItem data-testid="import-agent-from-file">
                     <EnterIcon className="mr-2" /> Import from file
                   </DropdownMenuItem>
                 </DialogTrigger>
@@ -134,7 +135,7 @@ export const AgentFlowList = ({
               {flowRuns && <TableHead>Last run</TableHead>}
             </TableRow>
           </TableHeader>
-          <TableBody>
+          <TableBody data-testid="agent-flow-list-body">
             {flows
               .map((flow) => {
                 let runCount = 0,
@@ -162,6 +163,8 @@ export const AgentFlowList = ({
               .map(({ flow, runCount, lastRun }) => (
                 <TableRow
                   key={flow.id}
+                  data-testid={flow.id}
+                  data-name={flow.name}
                   className="cursor-pointer"
                   onClick={() => onSelectFlow(flow)}
                   data-state={selectedFlow?.id == flow.id ? "selected" : null}
diff --git a/autogpt_platform/frontend/src/components/monitor/FlowInfo.tsx b/autogpt_platform/frontend/src/components/monitor/FlowInfo.tsx
index 3cd62b012a55..e223589e425c 100644
--- a/autogpt_platform/frontend/src/components/monitor/FlowInfo.tsx
+++ b/autogpt_platform/frontend/src/components/monitor/FlowInfo.tsx
@@ -115,6 +115,7 @@ export const FlowInfo: React.FC<
             variant="outline"
             className="px-2.5"
             title="Export to a JSON-file"
+            data-testid="export-button"
             onClick={async () =>
               exportAsJSONFile(
                 safeCopyGraph(
@@ -129,7 +130,11 @@ export const FlowInfo: React.FC<
           >
             <ExitIcon className="mr-2" /> Export
           </Button>
-          <Button variant="outline" onClick={() => setIsDeleteModalOpen(true)}>
+          <Button
+            variant="outline"
+            onClick={() => setIsDeleteModalOpen(true)}
+            data-testid="delete-button"
+          >
             <Trash2Icon className="h-full" />
           </Button>
         </div>
diff --git a/autogpt_platform/frontend/src/components/monitor/FlowRunsList.tsx b/autogpt_platform/frontend/src/components/monitor/FlowRunsList.tsx
index 51d16a927dcd..c4b8efe232a4 100644
--- a/autogpt_platform/frontend/src/components/monitor/FlowRunsList.tsx
+++ b/autogpt_platform/frontend/src/components/monitor/FlowRunsList.tsx
@@ -35,10 +35,13 @@ export const FlowRunsList: React.FC<{
             <TableHead>Duration</TableHead>
           </TableRow>
         </TableHeader>
-        <TableBody>
+        <TableBody data-testid="flow-runs-list-body">
           {runs.map((run) => (
             <TableRow
               key={run.id}
+              data-testid={`flow-run-${run.id}-graph-${run.graphID}`}
+              data-runid={run.id}
+              data-graphid={run.graphID}
               className="cursor-pointer"
               onClick={() => onSelectRun(run)}
               data-state={selectedRun?.id == run.id ? "selected" : null}


Test patch:
diff --git a/autogpt_platform/frontend/src/tests/build.spec.ts b/autogpt_platform/frontend/src/tests/build.spec.ts
index 17c4291d4072..69a8672703ca 100644
--- a/autogpt_platform/frontend/src/tests/build.spec.ts
+++ b/autogpt_platform/frontend/src/tests/build.spec.ts
@@ -28,13 +28,11 @@ test.describe("Build", () => { //(1)!
   test("user can add a block", async ({ page }) => { //(6)!
     await test.expect(buildPage.isLoaded()).resolves.toBeTruthy(); //(7)!
     await test.expect(page).toHaveURL(new RegExp("/.*build")); //(8)!
+
     await buildPage.closeTutorial(); //(9)!
     await buildPage.openBlocksPanel(); //(10)!
-    const block = {
-      id: "31d1064e-7446-4693-a7d4-65e5ca1180d1",
-      name: "Add to Dictionary",
-      description: "Add to Dictionary",
-    };
+    const block = await buildPage.getBasicBlock();
+
     await buildPage.addBlock(block); //(11)!
     await buildPage.closeBlocksPanel(); //(12)!
     await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy(); //(13)!
diff --git a/autogpt_platform/frontend/src/tests/monitor.spec.ts b/autogpt_platform/frontend/src/tests/monitor.spec.ts
new file mode 100644
index 000000000000..7552251a02a2
--- /dev/null
+++ b/autogpt_platform/frontend/src/tests/monitor.spec.ts
@@ -0,0 +1,125 @@
+import { expect, TestInfo } from "@playwright/test";
+import { test } from "./fixtures";
+import { BuildPage } from "./pages/build.page";
+import { MonitorPage } from "./pages/monitor.page";
+import { v4 as uuidv4 } from "uuid";
+import * as fs from "fs/promises";
+import path from "path";
+// --8<-- [start:AttachAgentId]
+
+test.describe("Monitor", () => {
+  let buildPage: BuildPage;
+  let monitorPage: MonitorPage;
+
+  test.beforeEach(async ({ page, loginPage, testUser }, testInfo: TestInfo) => {
+    buildPage = new BuildPage(page);
+    monitorPage = new MonitorPage(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("/");
+
+    // add a test agent
+    const basicBlock = await buildPage.getBasicBlock();
+    const id = uuidv4();
+    await buildPage.createSingleBlockAgent(
+      `test-agent-${id}`,
+      `test-agent-description-${id}`,
+      basicBlock,
+    );
+    await buildPage.runAgent();
+    await monitorPage.navbar.clickMonitorLink();
+    await monitorPage.waitForPageLoad();
+    await test.expect(monitorPage.isLoaded()).resolves.toBeTruthy();
+    testInfo.attach("agent-id", { body: id });
+  });
+  // --8<-- [end:AttachAgentId]
+
+  test.afterAll(async ({}) => {
+    // clear out the downloads folder
+    console.log(
+      `clearing out the downloads folder ${monitorPage.downloadsFolder}`,
+    );
+
+    await fs.rm(`${monitorPage.downloadsFolder}/monitor`, {
+      recursive: true,
+      force: true,
+    });
+  });
+
+  test("user can view agents", async ({ page }) => {
+    const agents = await monitorPage.listAgents();
+    // there should be at least one agent
+    await test.expect(agents.length).toBeGreaterThan(0);
+  });
+
+  test("user can export and import agents", async ({
+    page,
+  }, testInfo: TestInfo) => {
+    // --8<-- [start:ReadAgentId]
+    if (testInfo.attachments.length === 0 || !testInfo.attachments[0].body) {
+      throw new Error("No agent id attached to the test");
+    }
+    const id = testInfo.attachments[0].body.toString();
+    // --8<-- [end:ReadAgentId]
+    const agents = await monitorPage.listAgents();
+
+    const downloadPromise = page.waitForEvent("download");
+    await monitorPage.exportToFile(
+      agents.find((a) => a.id === id) || agents[0],
+    );
+    const download = await downloadPromise;
+
+    // Wait for the download process to complete and save the downloaded file somewhere.
+    await download.saveAs(
+      `${monitorPage.downloadsFolder}/monitor/${download.suggestedFilename()}`,
+    );
+    console.log(`downloaded file to ${download.suggestedFilename()}`);
+    await test.expect(download.suggestedFilename()).toBeDefined();
+    // test-agent-uuid-v1.json
+    if (id) {
+      await test.expect(download.suggestedFilename()).toContain(id);
+    }
+    await test.expect(download.suggestedFilename()).toContain("test-agent-");
+    await test.expect(download.suggestedFilename()).toContain("v1.json");
+
+    // import the agent
+    const preImportAgents = await monitorPage.listAgents();
+    const filesInFolder = await fs.readdir(
+      `${monitorPage.downloadsFolder}/monitor`,
+    );
+    const importFile = filesInFolder.find((f) => f.includes(id));
+    if (!importFile) {
+      throw new Error(`No import file found for agent ${id}`);
+    }
+    const baseName = importFile.split(".")[0];
+    await monitorPage.importFromFile(
+      path.resolve(monitorPage.downloadsFolder, "monitor"),
+      importFile,
+      baseName + "-imported",
+    );
+
+    // You'll be dropped at the build page, so hit run and then go back to monitor
+    await buildPage.runAgent();
+    await monitorPage.navbar.clickMonitorLink();
+    await monitorPage.waitForPageLoad();
+
+    const postImportAgents = await monitorPage.listAgents();
+    await test
+      .expect(postImportAgents.length)
+      .toBeGreaterThan(preImportAgents.length);
+    console.log(`postImportAgents: ${JSON.stringify(postImportAgents)}`);
+    const importedAgent = postImportAgents.find(
+      (a) => a.name === `${baseName}-imported`,
+    );
+    await test.expect(importedAgent).toBeDefined();
+  });
+
+  test("user can view runs", async ({ page }) => {
+    const runs = await monitorPage.listRuns();
+    console.log(runs);
+    // there should be at least one run
+    await test.expect(runs.length).toBeGreaterThan(0);
+  });
+});
diff --git a/autogpt_platform/frontend/src/tests/pages/base.page.ts b/autogpt_platform/frontend/src/tests/pages/base.page.ts
index f011c68178e4..7267b66430a7 100644
--- a/autogpt_platform/frontend/src/tests/pages/base.page.ts
+++ b/autogpt_platform/frontend/src/tests/pages/base.page.ts
@@ -3,6 +3,7 @@ import { NavBar } from "./navbar.page";
 
 export class BasePage {
   readonly navbar: NavBar;
+  readonly downloadsFolder = "./.test-contents";
 
   constructor(protected page: Page) {
     this.navbar = new NavBar(page);
@@ -10,6 +11,7 @@ export class BasePage {
 
   async waitForPageLoad() {
     // Common page load waiting logic
+    console.log(`waiting for page to load`);
     await this.page.waitForLoadState("networkidle", { timeout: 10000 });
   }
 }
diff --git a/autogpt_platform/frontend/src/tests/pages/build.page.ts b/autogpt_platform/frontend/src/tests/pages/build.page.ts
index bef7cf4d25bb..298d398cd06d 100644
--- a/autogpt_platform/frontend/src/tests/pages/build.page.ts
+++ b/autogpt_platform/frontend/src/tests/pages/build.page.ts
@@ -13,7 +13,12 @@ export class BuildPage extends BasePage {
   }
 
   async closeTutorial(): Promise<void> {
-    await this.page.getByRole("button", { name: "Skip Tutorial" }).click();
+    console.log(`closing tutorial`);
+    try {
+      await this.page.getByRole("button", { name: "Skip Tutorial" }).click();
+    } catch (error) {
+      console.info("Error closing tutorial:", error);
+    }
   }
 
   async openBlocksPanel(): Promise<void> {
@@ -25,6 +30,7 @@ export class BuildPage extends BasePage {
   }
 
   async closeBlocksPanel(): Promise<void> {
+    console.log(`closing blocks panel`);
     if (
       await this.page.getByTestId("blocks-control-blocks-label").isVisible()
     ) {
@@ -36,6 +42,7 @@ export class BuildPage extends BasePage {
     name: string = "Test Agent",
     description: string = "",
   ): Promise<void> {
+    console.log(`saving agent ${name} with description ${description}`);
     await this.page.getByTestId("blocks-control-save-button").click();
     await this.page.getByTestId("save-control-name-input").fill(name);
     await this.page
@@ -45,6 +52,7 @@ export class BuildPage extends BasePage {
   }
 
   async getBlocks(): Promise<Block[]> {
+    console.log(`getting blocks in sidebar panel`);
     try {
       const blocks = await this.page.locator('[data-id^="block-card-"]').all();
 
@@ -89,10 +97,14 @@ export class BuildPage extends BasePage {
   }
 
   async isRFNodeVisible(nodeId: string): Promise<boolean> {
+    console.log(`checking if RF node ${nodeId} is visible on page`);
     return await this.page.getByTestId(`rf__node-${nodeId}`).isVisible();
   }
 
   async hasBlock(block: Block): Promise<boolean> {
+    console.log(
+      `checking if block ${block.id} ${block.name} is visible on page`,
+    );
     try {
       // Use both ID and name for most precise matching
       const node = await this.page
@@ -106,6 +118,7 @@ export class BuildPage extends BasePage {
   }
 
   async getBlockInputs(blockId: string): Promise<string[]> {
+    console.log(`getting block ${blockId} inputs`);
     try {
       const node = await this.page
         .locator(`[data-blockid="${blockId}"]`)
@@ -132,10 +145,7 @@ export class BuildPage extends BasePage {
     // }
   }
 
-  async build_block_selector(
-    blockId: string,
-    dataId?: string,
-  ): Promise<string> {
+  async _buildBlockSelector(blockId: string, dataId?: string): Promise<string> {
     let selector = dataId
       ? `[data-id="${dataId}"] [data-blockid="${blockId}"]`
       : `[data-blockid="${blockId}"]`;
@@ -143,8 +153,9 @@ export class BuildPage extends BasePage {
   }
 
   async getBlockById(blockId: string, dataId?: string): Promise<Locator> {
+    console.log(`getting block ${blockId} with dataId ${dataId}`);
     return await this.page.locator(
-      await this.build_block_selector(blockId, dataId),
+      await this._buildBlockSelector(blockId, dataId),
     );
   }
 
@@ -157,6 +168,9 @@ export class BuildPage extends BasePage {
     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 = await block.getByPlaceholder(placeholder);
     await input.fill(value);
@@ -168,8 +182,11 @@ export class BuildPage extends BasePage {
     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.build_block_selector(blockId, dataId);
+    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"]`;
@@ -198,7 +215,7 @@ export class BuildPage extends BasePage {
     label: string,
     value: string,
   ): Promise<void> {
-    // throw new Error("Not implemented");
+    console.log(`filling block input ${label} with value ${value}`);
     const block = await this.getBlockById(blockId);
     const input = await block.getByLabel(label);
     await input.fill(value);
@@ -208,6 +225,9 @@ export class BuildPage extends BasePage {
     blockOutputId: string,
     blockInputId: string,
   ): Promise<void> {
+    console.log(
+      `connecting block output ${blockOutputId} to block input ${blockInputId}`,
+    );
     try {
       // Locate the output element
       const outputElement = await this.page.locator(
@@ -232,11 +252,14 @@ export class BuildPage extends BasePage {
     startDataId?: string,
     endDataId?: string,
   ): Promise<void> {
-    const startBlockBase = await this.build_block_selector(
+    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.build_block_selector(endBlockId, endDataId);
+    const endBlockBase = await this._buildBlockSelector(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()}"]`;
@@ -251,6 +274,7 @@ export class BuildPage extends BasePage {
   }
 
   async isLoaded(): Promise<boolean> {
+    console.log(`checking if build page is loaded`);
     try {
       await this.page.waitForLoadState("networkidle", { timeout: 10_000 });
       return true;
@@ -260,40 +284,78 @@ export class BuildPage extends BasePage {
   }
 
   async isRunButtonEnabled(): Promise<boolean> {
+    console.log(`checking if run button is enabled`);
     const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
     return await runButton.isEnabled();
   }
 
   async runAgent(): Promise<void> {
+    console.log(`clicking run button`);
     const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
     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(`run-dialog-input-${key}`).fill(value);
     }
   }
   async clickRunDialogRunButton(): Promise<void> {
+    console.log(`clicking run button`);
     await this.page.getByTestId("run-dialog-run-button").click();
   }
 
   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="blocks-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 createSingleBlockAgent(
+    name: string,
+    description: string,
+    block: Block,
+  ): Promise<void> {
+    console.log(`creating single block agent ${name}`);
+    await this.navbar.clickBuildLink();
+    await this.closeTutorial();
+    await this.openBlocksPanel();
+    await this.addBlock(block);
+    await this.saveAgent(name, description);
+    await this.waitForVersionField();
+  }
+
+  async getBasicBlock(): Promise<Block> {
+    return {
+      id: "31d1064e-7446-4693-a7d4-65e5ca1180d1",
+      name: "Add to Dictionary",
+      description: "Add to Dictionary",
+    };
+  }
 }
diff --git a/autogpt_platform/frontend/src/tests/pages/monitor.page.ts b/autogpt_platform/frontend/src/tests/pages/monitor.page.ts
new file mode 100644
index 000000000000..e696cd028c80
--- /dev/null
+++ b/autogpt_platform/frontend/src/tests/pages/monitor.page.ts
@@ -0,0 +1,235 @@
+import { ElementHandle, Locator, Page } from "@playwright/test";
+import { BasePage } from "./base.page";
+import path from "path";
+
+interface Agent {
+  id: string;
+  name: string;
+  runCount: number;
+  lastRun: string;
+}
+
+interface Run {
+  id: string;
+  agentId: string;
+  agentName: string;
+  started: string;
+  duration: number;
+  status: string;
+}
+
+interface AgentRun extends Agent {
+  runs: Run[];
+}
+
+interface Schedule {
+  id: string;
+  graphName: string;
+  nextExecution: string;
+  schedule: string;
+  actions: string[];
+}
+
+enum ImportType {
+  AGENT = "agent",
+  TEMPLATE = "template",
+}
+
+export class MonitorPage extends BasePage {
+  constructor(page: Page) {
+    super(page);
+  }
+
+  async isLoaded(): Promise<boolean> {
+    console.log(`checking if monitor page is loaded`);
+    try {
+      // Wait for network to settle first
+      await this.page.waitForLoadState("networkidle", { timeout: 10_000 });
+
+      // Wait for the monitor page container
+      await this.page.getByTestId("monitor-page").waitFor({
+        state: "visible",
+        timeout: 10_000,
+      });
+
+      // Wait for table headers to be visible (indicates table structure is ready)
+      await this.page.locator("thead th").first().waitFor({
+        state: "visible",
+        timeout: 5_000,
+      });
+
+      // Wait for either a table row or an empty tbody to be present
+      await Promise.race([
+        // Wait for at least one row
+        this.page.locator("tbody tr[data-testid]").first().waitFor({
+          state: "visible",
+          timeout: 5_000,
+        }),
+        // OR wait for an empty tbody (indicating no agents but table is loaded)
+        this.page
+          .locator("tbody[data-testid='agent-flow-list-body']:empty")
+          .waitFor({
+            state: "visible",
+            timeout: 5_000,
+          }),
+      ]);
+
+      return true;
+    } catch (error) {
+      return false;
+    }
+  }
+
+  async listAgents(): Promise<Agent[]> {
+    console.log(`listing agents`);
+    // Wait for table rows to be available
+    const rows = await this.page.locator("tbody tr[data-testid]").all();
+
+    const agents: Agent[] = [];
+
+    for (const row of rows) {
+      // Get the id from data-testid attribute
+      const id = (await row.getAttribute("data-testid")) || "";
+
+      // Get columns - there are 3 cells per row (name, run count, last run)
+      const cells = await row.locator("td").all();
+
+      // Extract name from first cell
+      const name = (await row.getAttribute("data-name")) || "";
+
+      // Extract run count from second cell
+      const runCountText = (await cells[1].textContent()) || "0";
+      const runCount = parseInt(runCountText, 10);
+
+      // Extract last run from third cell's title attribute (contains full timestamp)
+      // If no title, the cell will be empty indicating no last run
+      const lastRunCell = cells[2];
+      const lastRun = (await lastRunCell.getAttribute("title")) || "";
+
+      agents.push({
+        id,
+        name,
+        runCount,
+        lastRun,
+      });
+    }
+
+    return agents;
+  }
+
+  async listRuns(filter?: Agent): Promise<Run[]> {
+    console.log(`listing runs`);
+    // Wait for the runs table to be loaded - look for table header "Agent"
+    await this.page.locator("[data-testid='flow-runs-list-body']").waitFor();
+
+    // Get all run rows
+    const rows = await this.page
+      .locator('tbody tr[data-testid^="flow-run-"]')
+      .all();
+
+    const runs: Run[] = [];
+
+    for (const row of rows) {
+      const runId = (await row.getAttribute("data-runid")) || "";
+      const agentId = (await row.getAttribute("data-graphid")) || "";
+
+      // Get columns
+      const cells = await row.locator("td").all();
+
+      // Parse data from cells
+      const agentName = (await cells[0].textContent()) || "";
+      const started = (await cells[1].textContent()) || "";
+      const status = (await cells[2].locator("div").textContent()) || "";
+      const duration = (await cells[3].textContent()) || "";
+
+      // Only add if no filter or if matches filter
+      if (!filter || filter.id === agentId) {
+        runs.push({
+          id: runId,
+          agentId: agentId,
+          agentName: agentName.trim(),
+          started: started.trim(),
+          duration: parseFloat(duration.replace("s", "")),
+          status: status.toLowerCase().trim(),
+        });
+      }
+    }
+
+    return runs;
+  }
+  async listSchedules(): Promise<Schedule[]> {
+    console.log(`listing schedules`);
+    return [];
+  }
+
+  async clickAgent(id: string) {
+    console.log(`selecting agent ${id}`);
+    await this.page.getByTestId(id).click();
+  }
+
+  async clickCreateAgent(): Promise<void> {
+    console.log(`clicking create agent`);
+    await this.page.getByRole("link", { name: "Create" }).click();
+  }
+
+  async importFromFile(
+    directory: string,
+    file: string,
+    name?: string,
+    description?: string,
+    importType: ImportType = ImportType.AGENT,
+  ) {
+    console.log(
+      `importing from directory: ${directory} file: ${file} name: ${name} description: ${description} importType: ${importType}`,
+    );
+    await this.page.getByTestId("create-agent-dropdown").click();
+    await this.page.getByTestId("import-agent-from-file").click();
+
+    await this.page
+      .getByTestId("import-agent-file-input")
+      .setInputFiles(path.join(directory, file));
+    if (name) {
+      console.log(`filling agent name: ${name}`);
+      await this.page.getByTestId("agent-name-input").fill(name);
+    }
+    if (description) {
+      console.log(`filling agent description: ${description}`);
+      await this.page.getByTestId("agent-description-input").fill(description);
+    }
+    if (importType === ImportType.TEMPLATE) {
+      console.log(`clicking import as template switch`);
+      await this.page.getByTestId("import-as-template-switch").click();
+    }
+    console.log(`clicking import agent submit`);
+    await this.page.getByTestId("import-agent-submit").click();
+  }
+
+  async deleteAgent(agent: Agent) {
+    console.log(`deleting agent ${agent.id} ${agent.name}`);
+  }
+
+  async clickAllVersions(agent: Agent) {
+    console.log(`clicking all versions for agent ${agent.id} ${agent.name}`);
+  }
+
+  async openInBuilder(agent: Agent) {
+    console.log(`opening agent ${agent.id} ${agent.name} in builder`);
+  }
+
+  async exportToFile(agent: Agent) {
+    await this.clickAgent(agent.id);
+
+    console.log(`exporting agent ${agent.id} ${agent.name} to file`);
+    await this.page.getByTestId("export-button").click();
+  }
+
+  async selectRun(agent: Agent, run: Run) {
+    console.log(`selecting run ${run.id} for agent ${agent.id} ${agent.name}`);
+  }
+
+  async openOutputs(agent: Agent, run: Run) {
+    console.log(
+      `opening outputs for run ${run.id} of agent ${agent.id} ${agent.name}`,
+    );
+  }
+}
diff --git a/autogpt_platform/frontend/src/tests/profile.spec.ts b/autogpt_platform/frontend/src/tests/profile.spec.ts
index 50b205fcaa6e..6d88eb2be009 100644
--- a/autogpt_platform/frontend/src/tests/profile.spec.ts
+++ b/autogpt_platform/frontend/src/tests/profile.spec.ts
@@ -1,4 +1,3 @@
-// profile.spec.ts
 import { test } from "./fixtures";
 import { ProfilePage } from "./pages/profile.page";
 
diff --git a/docs/content/platform/contributing/tests.md b/docs/content/platform/contributing/tests.md
index 358ca0a70c8f..bc0422db6597 100644
--- a/docs/content/platform/contributing/tests.md
+++ b/docs/content/platform/contributing/tests.md
@@ -2,6 +2,10 @@
 
 We use [Playwright](https://playwright.dev/) for our testing framework.
 
+## Before you start
+
+Almost all of the tests require that you are running the frontend and backend servers. You will hit strange and hard to debug errors if you don't have them running because the tests will try to interact with the application when it's not running in an interactable state.
+
 ## Running the tests
 
 To run the tests, you can use the following commands:
@@ -142,6 +146,19 @@ A good test suite will have a healthy mix of focused and non-focused tests.
 12. The `await buildPage.closeBlocksPanel();` is used to close the blocks panel on the build page.
 13. The `await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();` is used to check that the block has been added to the build page.
 
+### Passing information between tests
+
+You can pass information between tests using the `testInfo` object. This is useful for things like passing the id of an agent between beforeAll so that you can have a shared setup for multiple tests.
+
+```typescript title="frontend/src/tests/monitor.spec.ts"
+--8<-- "autogpt_platform/frontend/src/tests/monitor.spec.ts:AttachAgentId"
+
+  test("test can read the agent id", async ({ page }, testInfo) => {
+    --8<-- "autogpt_platform/frontend/src/tests/monitor.spec.ts:ReadAgentId"
+    /// ... Do something with the agent id here
+  });
+});
+```
 
 ## See Also