From 0bc9326b62d757e667dc4c37678d0d2fc063fbc2 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:17:47 -0700 Subject: [PATCH] Add multi-root workspace support to e2e tests (#6106) * Add multi-root workspace support to e2e diff editor tests - Extract shared diff editor test logic into reusable function - Add WorkspaceType interface and workspace fixture configuration - Create test.code-workspace file for multi-root workspace testing - Add workspace_2 fixture directory with README - Update test helpers to support both single and multi-root workspaces - Add dedicated test case for diff editor in multi-root workspace * Add verbose logging control and improve workspace parameter naming in e2e tests - Add CLINE_E2E_TESTS_VERBOSE environment variable to control mock server logging - Replace console.log with conditional log function in ClineApiServerMock - Rename 'workspace' parameter to 'workspaceType' for clarity - Rename 'workspace' function parameter to 'workspacePath' for better semantics * Add doc string * rename --- src/test/e2e/chat.test.ts | 12 +++--- src/test/e2e/diff.test.ts | 19 +++++++-- src/test/e2e/editor.test.ts | 14 +++++-- .../e2e/fixtures/multiroots.code-workspace | 12 ++++++ src/test/e2e/fixtures/server/index.ts | 32 ++++++++++----- src/test/e2e/fixtures/workspace_2/README.md | 3 ++ .../workspace_2/providers/anthropic.py | 9 +++++ .../fixtures/workspace_2/providers/google.py | 10 +++++ .../fixtures/workspace_2/providers/openai.py | 9 +++++ .../workspace_2/providers/openrouter.py | 22 ++++++++++ .../e2e/fixtures/workspace_2/providers/xai.py | 16 ++++++++ src/test/e2e/utils/helpers.ts | 40 +++++++++++++------ 12 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 src/test/e2e/fixtures/multiroots.code-workspace create mode 100644 src/test/e2e/fixtures/workspace_2/README.md create mode 100644 src/test/e2e/fixtures/workspace_2/providers/anthropic.py create mode 100644 src/test/e2e/fixtures/workspace_2/providers/google.py create mode 100644 src/test/e2e/fixtures/workspace_2/providers/openai.py create mode 100644 src/test/e2e/fixtures/workspace_2/providers/openrouter.py create mode 100644 src/test/e2e/fixtures/workspace_2/providers/xai.py diff --git a/src/test/e2e/chat.test.ts b/src/test/e2e/chat.test.ts index 0ad01d33ce..e36571d6bd 100644 --- a/src/test/e2e/chat.test.ts +++ b/src/test/e2e/chat.test.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test" -import { e2e } from "./utils/helpers" +import { e2e, e2eMultiRoot } from "./utils/helpers" -e2e("Chat - can send messages and switch between modes", async ({ helper, page, sidebar }) => { +e2e("Chat - can send messages and switch between modes", async ({ helper, sidebar }) => { // Sign in await helper.signin(sidebar) @@ -48,7 +48,7 @@ e2e("Chat - can send messages and switch between modes", async ({ helper, page, await expect(sidebar.getByText("API Request Failed")).toBeVisible() }) -e2e("Chat - slash commands preserve following text", async ({ helper, page, sidebar }) => { +e2e("Chat - slash commands preserve following text", async ({ helper, sidebar }) => { // Sign in await helper.signin(sidebar) @@ -68,7 +68,7 @@ e2e("Chat - slash commands preserve following text", async ({ helper, page, side await expect(inputbox).toHaveValue("/newtask following text should be preserved") }) -e2e("Chat - @ mentions preserve following text", async ({ helper, page, sidebar }) => { +e2e("Chat - @ mentions preserve following text", async ({ helper, sidebar }) => { // Sign in await helper.signin(sidebar) @@ -88,7 +88,7 @@ e2e("Chat - @ mentions preserve following text", async ({ helper, page, sidebar await expect(inputbox).toHaveValue("@problems following text should be preserved") }) -e2e("Chat - partial slash command completion preserves text", async ({ helper, page, sidebar }) => { +e2e("Chat - partial slash command completion preserves text", async ({ helper, sidebar }) => { // Sign in await helper.signin(sidebar) @@ -108,7 +108,7 @@ e2e("Chat - partial slash command completion preserves text", async ({ helper, p await expect(inputbox).toHaveValue("/newtask some important text after") }) -e2e("Chat - partial @ mention completion preserves text", async ({ helper, page, sidebar }) => { +e2eMultiRoot("[Multi-roots] Chat - partial @ mention completion preserves text", async ({ helper, sidebar }) => { // Sign in await helper.signin(sidebar) diff --git a/src/test/e2e/diff.test.ts b/src/test/e2e/diff.test.ts index 78d2986541..90a7640fbc 100644 --- a/src/test/e2e/diff.test.ts +++ b/src/test/e2e/diff.test.ts @@ -1,8 +1,13 @@ -import { expect } from "@playwright/test" +import { expect, Frame, Page } from "@playwright/test" import { cleanChatView } from "./utils/common" -import { e2e } from "./utils/helpers" +import { e2e, e2eMultiRoot } from "./utils/helpers" -e2e("Diff editor", async ({ page, sidebar, helper }) => { +/** + * Shared test logic for diff editor tests + * @param page - Playwright page object + * @param sidebar - Sidebar frame for the Cline extension + */ +async function testDiffEditor(page: Page, sidebar: Frame) { await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 }) // Submit a message await cleanChatView(page) @@ -43,4 +48,12 @@ e2e("Diff editor", async ({ page, sidebar, helper }) => { await expect(diffEditor).toBeVisible() await page.close() +} + +e2e("Diff editor", async ({ page, sidebar }) => { + await testDiffEditor(page, sidebar) +}) + +e2eMultiRoot("[Multi-roots] Diff editor", async ({ page, sidebar }) => { + await testDiffEditor(page, sidebar) }) diff --git a/src/test/e2e/editor.test.ts b/src/test/e2e/editor.test.ts index 532897a892..3a6ae68275 100644 --- a/src/test/e2e/editor.test.ts +++ b/src/test/e2e/editor.test.ts @@ -1,8 +1,8 @@ -import { expect } from "@playwright/test" +import { expect, Frame, Page } from "@playwright/test" import { addSelectedCodeToClineWebview, getClineEditorWebviewFrame, openTab, toggleNotifications } from "./utils/common" -import { e2e } from "./utils/helpers" +import { e2e, e2eMultiRoot } from "./utils/helpers" -e2e("code actions and editor panel", async ({ page, sidebar, helper }) => { +async function editorTest(page: Page, sidebar: Frame) { await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 }) // Sidebar - input should start empty const sidebarInput = sidebar.getByTestId("chat-input") @@ -32,4 +32,12 @@ e2e("code actions and editor panel", async ({ page, sidebar, helper }) => { await expect(clineEditorWebview.getByTestId("chat-input")).toBeEmpty() await addSelectedCodeToClineWebview(page) await expect(clineEditorWebview.getByTestId("chat-input")).not.toBeEmpty() +} + +e2e("Code actions and editor panel", async ({ page, sidebar }) => { + await editorTest(page, sidebar) +}) + +e2eMultiRoot("[Multi-roots] Code actions and editor panel", async ({ page, sidebar }) => { + await editorTest(page, sidebar) }) diff --git a/src/test/e2e/fixtures/multiroots.code-workspace b/src/test/e2e/fixtures/multiroots.code-workspace new file mode 100644 index 0000000000..05f4f96fa9 --- /dev/null +++ b/src/test/e2e/fixtures/multiroots.code-workspace @@ -0,0 +1,12 @@ +{ + "folders": [ + { + "name": "Workspace 1", + "path": "workspace" + }, + { + "name": "Workspace 2", + "path": "workspace_2" + } + ] +} diff --git a/src/test/e2e/fixtures/server/index.ts b/src/test/e2e/fixtures/server/index.ts index 183bd42511..200bb695bb 100644 --- a/src/test/e2e/fixtures/server/index.ts +++ b/src/test/e2e/fixtures/server/index.ts @@ -10,6 +10,13 @@ const E2E_API_SERVER_PORT = 7777 export const MOCK_CLINE_API_SERVER_URL = `http://localhost:${E2E_API_SERVER_PORT}` +const useVerboseLogging = process.env.CLINE_E2E_TESTS_VERBOSE === "true" +function log(...args: unknown[]) { + if (useVerboseLogging) { + console.log("[ClineApiServerMock]", ...args) + } +} + export class ClineApiServerMock { static globalSharedServer: ClineApiServerMock | null = null static globalSockets: Set = new Set() @@ -106,10 +113,13 @@ export class ClineApiServerMock { // Starts the global shared server public static async startGlobalServer(): Promise { + log("=== SERVER FIXTURE CALLED ===") if (ClineApiServerMock.globalSharedServer) { + log("Using existing global server") return ClineApiServerMock.globalSharedServer } + log("Starting global server...") const server = createServer((req: IncomingMessage, res: ServerResponse) => { // Parse URL and method const parsedUrl = parse(req.url || "", true) @@ -136,7 +146,7 @@ export class ClineApiServerMock { // Helper to send API response const sendApiResponse = (data: unknown, status = 200) => { - console.log(`API Response: ${JSON.stringify(data)}`) + log(`API Response: ${JSON.stringify(data)}`) sendJson({ success: true, data }, status) } @@ -157,7 +167,7 @@ export class ClineApiServerMock { // Authenticate the token and set current user if (isAuthRequired && authToken) { - console.log(`Authenticating token: ${authToken}`) + log(`Authenticating token: ${authToken}`) const user = ClineApiServerMock.globalSharedServer!.API_USER.getUserByToken(authToken) if (!user) { return sendApiError("Invalid token", 401) @@ -165,12 +175,12 @@ export class ClineApiServerMock { ClineApiServerMock.globalSharedServer!.setCurrentUser(user) } - console.log("=== MOCK SERVER REQUEST ===") - console.log("Method:", method) - console.log("Path:", path) - console.log("Query:", JSON.stringify(query)) - console.log("Headers:", JSON.stringify(req.headers)) - console.log("===============") + log("=== MOCK SERVER REQUEST ===") + log("Method:", method) + log("Path:", path) + log("Query:", JSON.stringify(query)) + log("Headers:", JSON.stringify(req.headers)) + log("===============") // Route handling const handleRequest = async () => { @@ -253,7 +263,7 @@ export class ClineApiServerMock { } const body = await readBody() const { orgId } = params - console.log("Fetching organization usage transactions for", { + log("Fetching organization usage transactions for", { orgId, body, }) @@ -264,7 +274,7 @@ export class ClineApiServerMock { if (endpoint === "/users/active-account" && method === "PUT") { const body = await readBody() - console.log("Switching active account") + log("Switching active account") const { organizationId } = JSON.parse(body) controller.setUserHasOrganization(!!organizationId) const currentUser = controller.API_USER.getCurrentUser() @@ -481,7 +491,7 @@ export class ClineApiServerMock { console.error(`Failed to start server on port ${E2E_API_SERVER_PORT}:`, error) reject(error) } else { - console.log(`ClineApiServerMock listening on port ${E2E_API_SERVER_PORT}`) + log(`ClineApiServerMock listening on port ${E2E_API_SERVER_PORT}`) resolve() } }) diff --git a/src/test/e2e/fixtures/workspace_2/README.md b/src/test/e2e/fixtures/workspace_2/README.md new file mode 100644 index 0000000000..678a7b248d --- /dev/null +++ b/src/test/e2e/fixtures/workspace_2/README.md @@ -0,0 +1,3 @@ +# Test Workspace 2 + +This workspace is used for testing the extension in a controlled environment. \ No newline at end of file diff --git a/src/test/e2e/fixtures/workspace_2/providers/anthropic.py b/src/test/e2e/fixtures/workspace_2/providers/anthropic.py new file mode 100644 index 0000000000..92e9d1e6b8 --- /dev/null +++ b/src/test/e2e/fixtures/workspace_2/providers/anthropic.py @@ -0,0 +1,9 @@ +import anthropic + +anthropic.Anthropic().messages.create( + model="claude-sonnet-4-20250514", + max_tokens=1024, + messages=[ + {"role": "user", "content": "Hello, world"} + ] +) \ No newline at end of file diff --git a/src/test/e2e/fixtures/workspace_2/providers/google.py b/src/test/e2e/fixtures/workspace_2/providers/google.py new file mode 100644 index 0000000000..84dfc9e3df --- /dev/null +++ b/src/test/e2e/fixtures/workspace_2/providers/google.py @@ -0,0 +1,10 @@ +from google import genai + +client = genai.Client() + +response = client.models.generate_content( + model="gemini-2.5-flash", + contents="Explain how AI works in a few words", +) + +print(response.text) \ No newline at end of file diff --git a/src/test/e2e/fixtures/workspace_2/providers/openai.py b/src/test/e2e/fixtures/workspace_2/providers/openai.py new file mode 100644 index 0000000000..6fe1e2d2eb --- /dev/null +++ b/src/test/e2e/fixtures/workspace_2/providers/openai.py @@ -0,0 +1,9 @@ +from openai import OpenAI +client = OpenAI() + +response = client.responses.create( + model="gpt-5", + input="Write a one-sentence bedtime story about a unicorn." +) + +print(response.output_text) \ No newline at end of file diff --git a/src/test/e2e/fixtures/workspace_2/providers/openrouter.py b/src/test/e2e/fixtures/workspace_2/providers/openrouter.py new file mode 100644 index 0000000000..861838e1b8 --- /dev/null +++ b/src/test/e2e/fixtures/workspace_2/providers/openrouter.py @@ -0,0 +1,22 @@ +from openai import OpenAI + +client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key="", +) + +completion = client.chat.completions.create( + extra_headers={ + "HTTP-Referer": "", # Optional. Site URL for rankings on openrouter.ai. + "X-Title": "", # Optional. Site title for rankings on openrouter.ai. + }, + model="openai/gpt-4o", + messages=[ + { + "role": "user", + "content": "What is the meaning of life?" + } + ] +) + +print(completion.choices[0].message.content) diff --git a/src/test/e2e/fixtures/workspace_2/providers/xai.py b/src/test/e2e/fixtures/workspace_2/providers/xai.py new file mode 100644 index 0000000000..eb8e2cc0f8 --- /dev/null +++ b/src/test/e2e/fixtures/workspace_2/providers/xai.py @@ -0,0 +1,16 @@ +import os + +from xai_sdk import Client +from xai_sdk.chat import user, system + +client = Client( + api_key=os.getenv("XAI_API_KEY"), + timeout=3600, # Override default timeout with longer timeout for reasoning models +) + +chat = client.chat.create(model="grok-4") +chat.append(system("You are a PhD-level mathematician.")) +chat.append(user("What is 2 + 2?")) + +response = chat.sample() +print(response.content) \ No newline at end of file diff --git a/src/test/e2e/utils/helpers.ts b/src/test/e2e/utils/helpers.ts index e564c379ea..8d39eba260 100644 --- a/src/test/e2e/utils/helpers.ts +++ b/src/test/e2e/utils/helpers.ts @@ -8,10 +8,15 @@ import { ClineApiServerMock } from "../fixtures/server" interface E2ETestDirectories { workspaceDir: string + multiRootWorkspaceDir: string userDataDir: string extensionsDir: string } +export interface WorkspaceType { + workspaceType: "single" | "multi" +} + export class E2ETestHelper { // Constants public static readonly CODEBASE_ROOT_DIR = path.resolve(__dirname, "..", "..", "..", "..") @@ -160,6 +165,8 @@ export class E2ETestHelper { * This test configuration provides a comprehensive setup for end-to-end testing of the Cline VS Code extension, * including server mocking, temporary directories, VS Code instance management, and helper utilities. * + * NOTE: Default to run in single-root workspace; use `e2eMultiRoot` for multi-root workspace tests. + * * @extends test - Base Playwright test with multiple fixture extensions * * Fixtures provided: @@ -201,14 +208,9 @@ export class E2ETestHelper { export const e2e = test .extend<{ server: ClineApiServerMock | null }>({ server: async ({}, use) => { - console.log("=== SERVER FIXTURE CALLED ===") // Start server if it doesn't exist if (!ClineApiServerMock.globalSharedServer) { - console.log("Starting global server...") await ClineApiServerMock.startGlobalServer() - console.log("Global server started successfully") - } else { - console.log("Using existing global server") } await use(ClineApiServerMock.globalSharedServer) }, @@ -217,6 +219,10 @@ export const e2e = test workspaceDir: async ({}, use) => { await use(path.join(E2ETestHelper.E2E_TESTS_DIR, "fixtures", "workspace")) }, + multiRootWorkspaceDir: async ({}, use) => { + // DOCS: https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces + await use(path.join(E2ETestHelper.E2E_TESTS_DIR, "fixtures", "multiroots.code-workspace")) + }, userDataDir: async ({}, use) => { await use(mkdtempSync(path.join(os.tmpdir(), "vsce"))) }, @@ -224,11 +230,14 @@ export const e2e = test await use(mkdtempSync(path.join(os.tmpdir(), "vsce"))) }, }) - .extend<{ openVSCode: () => Promise }>({ - openVSCode: async ({ workspaceDir, userDataDir, extensionsDir }, use, testInfo) => { + .extend({ + workspaceType: "single", + }) + .extend<{ openVSCode: (workspacePath: string) => Promise }>({ + openVSCode: async ({ userDataDir, extensionsDir }, use, testInfo) => { const executablePath = await downloadAndUnzipVSCode("stable", undefined, new SilentReporter()) - await use(async () => { + await use(async (workspacePath: string) => { const app = await _electron.launch({ executablePath, env: { @@ -238,7 +247,6 @@ export const e2e = test CLINE_ENVIRONMENT: "local", GRPC_RECORDER_FILE_NAME: E2ETestHelper.generateTestFileName(testInfo.title, testInfo.project.name), // GRPC_RECORDER_ENABLED: "true", - // GRPC_RECORDER_TESTS_FILTERS_ENABLED: "true" // IS_DEV: "true", // DEV_WORKSPACE_FOLDER: E2ETestHelper.CODEBASE_ROOT_DIR, }, @@ -255,7 +263,7 @@ export const e2e = test `--extensions-dir=${extensionsDir}`, `--install-extension=${path.join(E2ETestHelper.CODEBASE_ROOT_DIR, "dist", "e2e.vsix")}`, `--extensionDevelopmentPath=${E2ETestHelper.CODEBASE_ROOT_DIR}`, - workspaceDir, + workspacePath, ], }) await E2ETestHelper.waitUntil(() => app.windows().length > 0) @@ -264,8 +272,9 @@ export const e2e = test }, }) .extend<{ app: ElectronApplication }>({ - app: async ({ openVSCode, userDataDir, extensionsDir }, use) => { - const app = await openVSCode() + app: async ({ openVSCode, userDataDir, extensionsDir, workspaceType, workspaceDir, multiRootWorkspaceDir }, use) => { + const workspacePath = workspaceType === "single" ? workspaceDir : multiRootWorkspaceDir + const app = await openVSCode(workspacePath) try { await use(app) @@ -299,5 +308,12 @@ export const e2e = test }, }) +/** + * Multi-root workspace variant of the e2e test fixture + */ +export const e2eMultiRoot = e2e.extend({ + workspaceType: "multi", +}) + // Backward compatibility exports export const getResultsDir = E2ETestHelper.getResultsDir