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
This commit is contained in:
Bee
2025-09-09 23:17:47 -07:00
committed by GitHub
parent 92d7cdf593
commit 0bc9326b62
12 changed files with 163 additions and 35 deletions
+6 -6
View File
@@ -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)
+16 -3
View File
@@ -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)
})
+11 -3
View File
@@ -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)
})
@@ -0,0 +1,12 @@
{
"folders": [
{
"name": "Workspace 1",
"path": "workspace"
},
{
"name": "Workspace 2",
"path": "workspace_2"
}
]
}
+21 -11
View File
@@ -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<Socket> = new Set()
@@ -106,10 +113,13 @@ export class ClineApiServerMock {
// Starts the global shared server
public static async startGlobalServer(): Promise<ClineApiServerMock> {
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()
}
})
@@ -0,0 +1,3 @@
# Test Workspace 2
This workspace is used for testing the extension in a controlled environment.
@@ -0,0 +1,9 @@
import anthropic
anthropic.Anthropic().messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[
{"role": "user", "content": "Hello, world"}
]
)
@@ -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)
@@ -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)
@@ -0,0 +1,22 @@
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key="<OPENROUTER_API_KEY>",
)
completion = client.chat.completions.create(
extra_headers={
"HTTP-Referer": "<YOUR_SITE_URL>", # Optional. Site URL for rankings on openrouter.ai.
"X-Title": "<YOUR_SITE_NAME>", # 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)
@@ -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)
+28 -12
View File
@@ -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<ElectronApplication> }>({
openVSCode: async ({ workspaceDir, userDataDir, extensionsDir }, use, testInfo) => {
.extend<WorkspaceType>({
workspaceType: "single",
})
.extend<{ openVSCode: (workspacePath: string) => Promise<ElectronApplication> }>({
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>({
workspaceType: "multi",
})
// Backward compatibility exports
export const getResultsDir = E2ETestHelper.getResultsDir