Skip to content
Open
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
270 changes: 270 additions & 0 deletions tests/e2e/mock-llm/mock-llm-folder-workspace.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/**
* Mock-LLM E2E test: folder browsing → workspace selection → conversation creation.
*
* Covers two "I can" statements from issue #511:
* - "I can browse local files and folders to choose where to begin"
* - "I can start a conversation against a local Git repo without typing the path"
*
* Flow (serial):
* 1. Open the folder browser, navigate to a known test directory, click
* "Use this folder" — verify the workspace appears in the dropdown
* 2. Select the workspace, type a message, submit — intercept
* POST /api/conversations and assert workspace.working_dir matches
* the selected folder path
* 3. After conversation creation, verify selected_workspace is persisted
* in localStorage under the conversation's metadata key
*/

import { test, expect } from "@playwright/test";
import {
seedLocalStorage,
routeSessionApiKey,
dismissAnalyticsModal,
waitForTestId,
waitForPath,
ensureMockLLMProfile,
resetMockLLM,
deleteConversation,
} from "./utils/mock-llm-helpers";
import * as fs from "fs";
import * as path from "path";

/** A unique directory that is created before the test and cleaned up after. */
const TEST_DIR_BASE = "/tmp/e2e-folder-workspace-test";
const TEST_DIR_NAME = "my-test-project";
const TEST_DIR = path.join(TEST_DIR_BASE, TEST_DIR_NAME);

const METADATA_STORAGE_KEY = "openhands-agent-server-conversation-metadata";

test.describe.configure({ mode: "serial" });

test.describe("mock-LLM folder browser → workspace → conversation", () => {
const conversationIds = new Set<string>();

test.beforeAll(async ({ request }) => {
// Create the test directory hierarchy
fs.mkdirSync(TEST_DIR, { recursive: true });

// Ensure the mock LLM profile is configured so conversations can start
await ensureMockLLMProfile(request);
});

test.beforeEach(async ({ page }) => {
await seedLocalStorage(page);
});

test.afterEach(async ({ request }) => {
await resetMockLLM(request);

// Cleanup conversations created during the test
for (const id of conversationIds) {
try {
await deleteConversation(request, id);
} catch {
// best-effort
}
}
conversationIds.clear();
});

test.afterAll(async () => {
// Remove the test directory
try {
fs.rmSync(TEST_DIR_BASE, { recursive: true, force: true });
} catch {
// best-effort
}
});

// ── Step 1: Browse to a folder and add it as a workspace ────────────

test("step 1: browse to a folder, add it as a workspace, and launch a conversation with the correct working_dir", async ({
page,
}) => {
// Set up passive listener for POST /api/conversations BEFORE navigation.
// Uses page.on('request') (not page.route) to avoid conflicts with
// routeSessionApiKey — only one handler can call continue() per request.
let capturedPayload: Record<string, unknown> | null = null;
const captureConversationPayload = (
req: import("@playwright/test").Request,
) => {
if (
req.method() === "POST" &&
new URL(req.url()).pathname === "/api/conversations"
) {
try {
capturedPayload = req.postDataJSON();
} catch {
// non-JSON body
}
}
};
page.on("request", captureConversationPayload);

await routeSessionApiKey(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "home-chat-launcher");

// ── Open the "Open Workspace" dialog ──
await test.step("open workspace dialog", async () => {
await page.getByTestId("open-workspace-button").click();
await expect(
page.getByTestId("open-workspace-dialog-body"),
).toBeVisible({ timeout: 10_000 });
});

// ── Browse to the test directory using the folder browser UI ──
await test.step("open folder browser and navigate to test directory", async () => {
await page.getByTestId("add-workspaces-button").click();
await expect(page.getByTestId("folder-browser-modal")).toBeVisible({
timeout: 10_000,
});

// Navigate up to root first — click the "up" button repeatedly
// until we reach "/" (path shows "/" or up button is disabled).
const upBtn = page.getByTestId("folder-browser-up");
const currentPathEl = page.getByTestId("folder-browser-current-path");

// Keep clicking up until disabled (at root)
for (let i = 0; i < 10; i++) {
if (await upBtn.isDisabled()) break;
await upBtn.click();
// Small wait for the listing to load
await page.waitForTimeout(300);
}

// Now navigate down: / → tmp → e2e-folder-workspace-test → my-test-project
const tmpEntry = page.getByTestId("folder-browser-entry-tmp");
await expect(tmpEntry).toBeVisible({ timeout: 10_000 });
await tmpEntry.click();
await expect(currentPathEl).toHaveText(/\/tmp/, { timeout: 5_000 });

const baseEntry = page.getByTestId(
`folder-browser-entry-${path.basename(TEST_DIR_BASE)}`,
);
await expect(baseEntry).toBeVisible({ timeout: 10_000 });
await baseEntry.click();

const projectEntry = page.getByTestId(
`folder-browser-entry-${TEST_DIR_NAME}`,
);
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
await projectEntry.click();

// Verify we're at the correct path
await expect(currentPathEl).toHaveText(TEST_DIR, { timeout: 5_000 });

// Click "Use this folder"
await page.getByTestId("folder-browser-use").click();

// Modal should close
await expect(page.getByTestId("folder-browser-modal")).toBeHidden({
timeout: 5_000,
});
});

// ── Select the workspace in the dropdown and confirm ──
// The workspace dialog is still open after the folder browser closed.
// The folder browser's onAdd adds the workspace to the store, so the
// dropdown should now include it.
await test.step("select the workspace in the dropdown and confirm", async () => {
// The workspace dialog should still be visible
await expect(
page.getByTestId("open-workspace-dialog-body"),
).toBeVisible({ timeout: 10_000 });

// The workspace dropdown should contain our test directory.
const dropdown = page.getByTestId("workspace-dropdown");
await expect(dropdown).toBeVisible({ timeout: 10_000 });
await dropdown.click();

// Find and click the entry for our test workspace
const workspaceOption = page.getByRole("option", {
name: new RegExp(TEST_DIR_NAME),
});
await expect(workspaceOption).toBeVisible({ timeout: 10_000 });
await workspaceOption.click();

// Click "Confirm" to accept the workspace selection
const confirmBtn = page.getByRole("button", { name: /confirm/i });
await confirmBtn.click();

// The dialog should close
await expect(
page.getByTestId("open-workspace-dialog-body"),
).toBeHidden({ timeout: 5_000 });
});

// ── Type a message and submit to create a conversation ──
await test.step("submit a message to create a conversation", async () => {
// Type into the home-page chat input (contentEditable div)
const chatInput = page.getByTestId("home-chat-launcher").locator(
'[contenteditable="true"]',
);
await expect(chatInput).toBeVisible({ timeout: 10_000 });
await chatInput.click();

await page.evaluate((msg: string) => {
const el = document.querySelector(
'[data-testid="home-chat-launcher"] [contenteditable="true"]',
);
if (el) {
el.textContent = msg;
el.dispatchEvent(new Event("input", { bubbles: true }));
}
}, "Hello from the workspace test");

// Submit with Enter
await chatInput.press("Enter");

// Wait for navigation to a conversation page
await waitForPath(page, /\/conversations\/.+/, 30_000);
});

// Track the conversation for cleanup
const match = page.url().match(/\/conversations\/([^/?#]+)/);
const conversationId = match?.[1] ? decodeURIComponent(match[1]) : null;
expect(conversationId, "Should be on a conversation page").toBeTruthy();
conversationIds.add(conversationId!);

// ── Verify: POST /api/conversations payload has correct working_dir ──
await test.step("verify working_dir in POST /api/conversations payload", async () => {
expect(
capturedPayload,
"POST /api/conversations payload was not captured",
).not.toBeNull();

const workspace = capturedPayload?.workspace as
| Record<string, unknown>
| undefined;
expect(workspace, "payload should have a workspace object").toBeTruthy();
expect(workspace?.working_dir).toBe(TEST_DIR);
});

// ── Verify: selected_workspace in localStorage ──
await test.step("verify selected_workspace in localStorage", async () => {
const metadata = await page.evaluate(
({ key, convId }) => {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed[convId] ?? null;
} catch {
return null;
}
},
{ key: METADATA_STORAGE_KEY, convId: conversationId! },
);

expect(
metadata,
`localStorage metadata for conversation ${conversationId} should exist`,
).not.toBeNull();
expect(metadata?.selected_workspace).toBe(TEST_DIR);
});

page.off("request", captureConversationPayload);
});
});
Loading
Loading