Browse Source

e2e test: add mock service for cline API & new test for diff editor (#5196)

* Add mock api service and E2E test infrastructure

- Create AuthServiceMock for testing with mock user data and API responses
- Add AuthProvider interface to standardize authentication providers
- Implement E2E test fixtures with mock server and workspace setup
- Add comprehensive E2E tests for authentication and core functionality
- Export DEFAULT_CLINE_APP_URL config and make getEnvironmentConfig more flexible
- Update AuthService to use mock implementation during E2E tests

* format

* import

* refactor mock server

* rename data

* wait for text

* wait for edit

* increase timeout for windows

* clean up

* rename test and add orgs
Bee 5 months ago
parent
commit
c3a97c3eda

+ 6 - 3
playwright.config.ts

@@ -1,14 +1,17 @@
 import { defineConfig } from "@playwright/test"
 
-const isGitHubAction = !!process.env.CI
+const isGitHubAction = !!process?.env?.CI
+const isWindow = process?.platform?.startsWith("win")
+
+const DEFAULT_TIMEOUT = isWindow ? 40000 : 20000
 
 export default defineConfig({
 	workers: 1,
 	retries: 1,
 	testDir: "src/test/e2e",
-	timeout: 20000,
+	timeout: DEFAULT_TIMEOUT,
 	expect: {
-		timeout: 20000,
+		timeout: DEFAULT_TIMEOUT,
 	},
 	fullyParallel: true,
 	reporter: isGitHubAction ? [["github"], ["list"]] : [["list"]],

+ 20 - 13
src/services/auth/AuthService.ts

@@ -11,7 +11,7 @@ import { openExternal } from "@/utils/env"
 const DefaultClineAccountURI = `${clineEnvConfig.appBaseUrl}/auth`
 let authProviders: any[] = []
 
-type ServiceConfig = {
+export type ServiceConfig = {
 	URI?: string
 	[key: string]: any
 }
@@ -49,13 +49,13 @@ export interface ClineAccountOrganization {
 // TODO: Add logic to handle multiple webviews getting auth updates.
 
 export class AuthService {
-	private static instance: AuthService | null = null
-	private _config: ServiceConfig
-	private _authenticated: boolean = false
-	private _clineAuthInfo: ClineAuthInfo | null = null
-	private _provider: { provider: FirebaseAuthProvider } | null = null
-	private _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler<AuthState>]>()
-	private _context: vscode.ExtensionContext
+	protected static instance: AuthService | null = null
+	protected _config: ServiceConfig
+	protected _authenticated: boolean = false
+	protected _clineAuthInfo: ClineAuthInfo | null = null
+	protected _provider: { provider: FirebaseAuthProvider } | null = null
+	protected _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler<AuthState>]>()
+	protected _context: vscode.ExtensionContext
 
 	/**
 	 * Creates an instance of AuthService.
@@ -63,7 +63,7 @@ export class AuthService {
 	 * @param authProvider - Optional authentication provider to use.
 	 * @param controller - Optional reference to the Controller instance.
 	 */
-	private constructor(context: vscode.ExtensionContext, config: ServiceConfig, authProvider?: any) {
+	protected constructor(context: vscode.ExtensionContext, config: ServiceConfig, authProvider?: any) {
 		const providerName = authProvider || "firebase"
 		this._config = Object.assign({ URI: DefaultClineAccountURI }, config)
 
@@ -110,12 +110,19 @@ export class AuthService {
 				console.warn("Extension context was not provided to AuthService.getInstance, using default context")
 				context = {} as vscode.ExtensionContext
 			}
-			AuthService.instance = new AuthService(context, config || {}, authProvider)
+			if (process.env.E2E_TEST) {
+				// Use require instead of import to avoid circular dependency issues
+				// eslint-disable-next-line @typescript-eslint/no-var-requires
+				const { AuthServiceMock } = require("./AuthServiceMock")
+				AuthService.instance = AuthServiceMock.getInstance(context, config || {}, authProvider)
+			} else {
+				AuthService.instance = new AuthService(context, config || {}, authProvider)
+			}
 		}
-		if (context !== undefined) {
+		if (context !== undefined && AuthService.instance) {
 			AuthService.instance.context = context
 		}
-		return AuthService.instance
+		return AuthService.instance!
 	}
 
 	set context(context: vscode.ExtensionContext) {
@@ -146,7 +153,7 @@ export class AuthService {
 		return this._clineAuthInfo.idToken
 	}
 
-	private _setProvider(providerName: string): void {
+	protected _setProvider(providerName: string): void {
 		const providerConfig = authProviders.find((provider) => provider.name === providerName)
 		if (!providerConfig) {
 			throw new Error(`Auth provider "${providerName}" not found`)

+ 146 - 0
src/services/auth/AuthServiceMock.ts

@@ -0,0 +1,146 @@
+import { String } from "@shared/proto/cline/common"
+import type vscode from "vscode"
+import { clineEnvConfig } from "@/config"
+import { WebviewProvider } from "@/core/webview"
+import type { UserResponse } from "@/shared/ClineAccount"
+import { AuthService, type ServiceConfig } from "./AuthService"
+
+export class AuthServiceMock extends AuthService {
+	protected constructor(context: vscode.ExtensionContext, config: ServiceConfig, authProvider?: any) {
+		super(context, config, authProvider)
+
+		if (process?.env?.CLINE_ENVIRONMENT !== "local") {
+			throw new Error("AuthServiceMock should only be used in local environment for testing purposes.")
+		}
+
+		this._config = Object.assign({ URI: clineEnvConfig.apiBaseUrl }, config)
+
+		const providerName = "firebase"
+		this._setProvider(providerName)
+
+		this._context = context
+	}
+
+	/**
+	 * Gets the singleton instance of AuthServiceMock.
+	 */
+	public static override getInstance(
+		context?: vscode.ExtensionContext,
+		config?: ServiceConfig,
+		authProvider?: any,
+	): AuthServiceMock {
+		if (!AuthServiceMock.instance) {
+			if (!context) {
+				console.warn("Extension context was not provided to AuthServiceMock.getInstance, using default context")
+				context = {} as vscode.ExtensionContext
+			}
+			AuthServiceMock.instance = new AuthServiceMock(context, config || {}, authProvider)
+		}
+		if (context !== undefined) {
+			AuthServiceMock.instance.context = context
+		}
+		return AuthServiceMock.instance
+	}
+
+	override async getAuthToken(): Promise<string | null> {
+		if (!this._clineAuthInfo) {
+			return null
+		}
+		return this._clineAuthInfo.idToken
+	}
+
+	override async createAuthRequest(): Promise<String> {
+		// Use URL object for more graceful query construction
+		const authUrl = new URL(clineEnvConfig.apiBaseUrl)
+		const authUrlString = authUrl.toString()
+		// Call the parent implementation
+		if (this._authenticated && this._clineAuthInfo) {
+			console.log("Already authenticated with mock server")
+			return String.create({ value: authUrlString })
+		}
+
+		try {
+			// Fetch user data from mock server
+			const meUri = new URL("/api/v1/users/me", clineEnvConfig.apiBaseUrl)
+			const tokenType = "personal"
+			const testToken = `test-${tokenType}-token`
+			const response = await fetch(meUri, {
+				method: "GET",
+				headers: {
+					Authorization: `Bearer ${testToken}`,
+					"Content-Type": "application/json",
+				},
+			})
+
+			if (!response.ok) {
+				throw new Error(`Mock server authentication failed: ${response.status} ${response.statusText}`)
+			}
+
+			const responseData = await response.json()
+
+			if (!responseData.success || !responseData.data) {
+				throw new Error("Invalid response from mock server")
+			}
+
+			const userData = responseData.data as UserResponse
+
+			// Convert UserResponse to ClineAuthInfo format
+			this._clineAuthInfo = {
+				idToken: testToken,
+				userInfo: {
+					id: userData.id,
+					email: userData.email,
+					displayName: userData.displayName,
+					createdAt: userData.createdAt,
+					organizations: userData.organizations.map((org) => ({
+						active: org.active,
+						memberId: org.memberId,
+						name: org.name,
+						organizationId: org.organizationId,
+						roles: org.roles,
+					})),
+				},
+			}
+
+			console.log(`Successfully authenticated with mock server as ${userData.displayName} (${userData.email})`)
+
+			const visibleWebview = WebviewProvider.getVisibleInstance()
+			await visibleWebview?.controller.handleAuthCallback(testToken, "mock")
+		} catch (error) {
+			console.error("Error signing in with mock server:", error)
+			this._authenticated = false
+			this._clineAuthInfo = null
+			throw error
+		}
+
+		return String.create({ value: authUrlString })
+	}
+
+	override async handleAuthCallback(_token: string, _provider: string): Promise<void> {
+		try {
+			this._authenticated = true
+			await this.sendAuthStatusUpdate()
+		} catch (error) {
+			console.error("Error signing in with custom token:", error)
+			throw error
+		}
+	}
+
+	override async restoreRefreshTokenAndRetrieveAuthInfo(): Promise<void> {
+		try {
+			if (this._clineAuthInfo) {
+				this._authenticated = true
+				await this.sendAuthStatusUpdate()
+			} else {
+				console.warn("No user found after restoring auth token")
+				this._authenticated = false
+				this._clineAuthInfo = null
+			}
+		} catch (error) {
+			console.error("Error restoring auth token:", error)
+			this._authenticated = false
+			this._clineAuthInfo = null
+			return
+		}
+	}
+}

+ 14 - 3
src/test/e2e/auth.test.ts

@@ -1,7 +1,9 @@
 import { expect } from "@playwright/test"
 import { e2e } from "./utils/helpers"
 
+// Test for setting up API keys
 e2e("Auth - can set up API keys", async ({ page, sidebar }) => {
+	// Use the page object to interact with editor outside the sidebar
 	// Verify initial state
 	await expect(sidebar.getByRole("button", { name: "Get Started for Free" })).toBeVisible()
 	await expect(sidebar.getByRole("button", { name: "Use your own API key" })).toBeVisible()
@@ -13,7 +15,6 @@ e2e("Auth - can set up API keys", async ({ page, sidebar }) => {
 
 	// Verify provider selector is visible and set to OpenRouter
 	await expect(sidebar.locator("slot").filter({ hasText: /^OpenRouter$/ })).toBeVisible()
-
 	// Test Cline provider option
 	await providerSelector.click({ delay: 100 })
 	await expect(sidebar.getByRole("option", { name: "Cline" })).toBeVisible()
@@ -24,7 +25,9 @@ e2e("Auth - can set up API keys", async ({ page, sidebar }) => {
 	await providerSelector.click({ delay: 100 })
 	await sidebar.getByRole("option", { name: "OpenRouter" }).click({ delay: 100 })
 
-	const apiKeyInput = sidebar.getByRole("textbox", { name: "OpenRouter API Key" })
+	const apiKeyInput = sidebar.getByRole("textbox", {
+		name: "OpenRouter API Key",
+	})
 	await apiKeyInput.fill("test-api-key")
 	await expect(apiKeyInput).toHaveValue("test-api-key")
 	await apiKeyInput.click({ delay: 100 })
@@ -50,8 +53,16 @@ e2e("Auth - can set up API keys", async ({ page, sidebar }) => {
 	await expect(helpBanner).not.toBeVisible()
 
 	// Verify the release banner is visible for new installs and can be closed.
-	const releaseBanner = sidebar.getByRole("heading", { name: /^🎉 New in v\d/ })
+	const releaseBanner = sidebar.getByRole("heading", {
+		name: /^🎉 New in v\d/,
+	})
 	await expect(releaseBanner).toBeVisible()
 	await sidebar.getByTestId("close-button").locator("span").first().click()
 	await expect(releaseBanner).not.toBeVisible()
+
+	// Sidebar menu should now be visible
+	// await expect(sidebar.getByRole("button", { name: "Account", exact: true })).toBeVisible()
+
+	// await sidebar.getByRole("button", { name: "Settings" }).click()
+	// await expect(sidebar.getByRole("button", { name: "Done" })).toBeVisible()
 })

+ 61 - 0
src/test/e2e/diff.test.ts

@@ -0,0 +1,61 @@
+import { expect } from "@playwright/test"
+import { e2e } from "./utils/helpers"
+
+e2e("Diff editor", async ({ page, sidebar }) => {
+	await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 })
+
+	await expect(sidebar.getByText(/cline:anthropic\/claude/, { exact: true })).toBeVisible()
+
+	// Verify the help improve banner is visible and can be closed.
+	const helpBanner = sidebar.getByText("Help Improve Cline")
+	await expect(helpBanner).toBeVisible()
+	await sidebar.getByRole("button", { name: "Close banner and enable" }).click()
+	await expect(helpBanner).not.toBeVisible()
+
+	// Verify the release banner is visible for new installs and can be closed.
+	const releaseBanner = sidebar.getByRole("heading", {
+		name: /^🎉 New in v\d/,
+	})
+	await expect(releaseBanner).toBeVisible()
+	await sidebar.getByTestId("close-button").locator("span").first().click()
+	await expect(releaseBanner).not.toBeVisible()
+
+	// Submit a message
+	const inputbox = sidebar.getByTestId("chat-input")
+	await expect(inputbox).toBeVisible()
+
+	await inputbox.fill("Hello, Cline!")
+	await expect(inputbox).toHaveValue("Hello, Cline!")
+	await sidebar.getByTestId("send-button").click({ delay: 100 })
+	await expect(inputbox).toHaveValue("")
+
+	// Loading State initially
+	await expect(sidebar.getByText("API Request...")).toBeVisible()
+
+	// Back to home page with history
+	await sidebar.getByRole("button", { name: "Start New Task" }).click()
+	await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
+	await expect(sidebar.getByText("Hello, Cline!")).toBeVisible() // History with the previous sent message
+	await expect(sidebar.getByText("Tokens:")).toBeVisible() // History with token usage
+
+	// Submit a file edit request
+	await sidebar.getByTestId("chat-input").click()
+	await sidebar.getByTestId("chat-input").fill("edit_request")
+	await sidebar.getByTestId("send-button").click({ delay: 100 })
+
+	// Wait for the sidebar to load the file edit request
+	await sidebar.waitForSelector('span:has-text("Cline wants to edit this file:")')
+
+	// Cline should respond with a file edit request
+	await expect(sidebar.getByText("Cline wants to edit this file:")).toBeVisible()
+
+	// Cline Diff Editor should open with the file name and diff
+	await expect(page.getByText("test.ts: Original ↔ Cline's")).toBeVisible()
+
+	// Diff editor should show the original and modified content
+	await expect(
+		page.locator(
+			".monaco-editor.modified-in-monaco-diff-editor > .overflow-guard > .monaco-scrollable-element.editor-scrollable > .lines-content > div:nth-child(4)",
+		),
+	).toBeVisible()
+})

+ 79 - 0
src/test/e2e/fixtures/server/api.ts

@@ -0,0 +1,79 @@
+export const E2E_REGISTERED_MOCK_ENDPOINTS = {
+	"/api/v1": {
+		GET: [
+			"/generation",
+			"/organizations/{orgId}/balance",
+			"/organizations/{orgId}/members/{memberId}/usages",
+			"/users/me",
+			"/users/{userId}/balance",
+			"/users/{userId}/usages",
+			"/users/{userId}/payments",
+		],
+		POST: ["/chat/completions"],
+		PUT: ["/users/active-account"],
+	},
+	"/.test": {
+		GET: [],
+		POST: ["/auth", "/setUserBalance", "/setUserHasOrganization", "/setOrgBalance"],
+		PUT: [],
+	},
+	"/health": {
+		POST: [],
+		GET: ["/", "/ping"],
+		PUT: [],
+	},
+}
+
+const replace_in_file = `I successfully replaced "john" with "cline" in the test.ts file. The change has been completed and the file now contains:
+
+\`\`\`typescript
+export const name = "cline"
+\`\`\`
+
+The TypeScript errors shown in the output are unrelated to this change - they appear to be existing issues in the broader codebase related to missing type definitions and dependencies. The specific task of updating the name in test.ts has been completed successfully.
+
+<attempt_completion>
+<result>
+I have successfully replaced the name "john" with "cline" in the test.ts file. The file now exports:
+
+\`\`\`typescript
+export const name = "cline"
+\`\`\`
+
+The change has been applied and saved to the file.
+</result>
+</attempt_completion>`
+
+const edit_request = `<thinking>
+The user wants me to replace the name "john" with "cline" in the test.ts file. I can see the file content provided:
+
+\`\`\`typescript
+export const name = "john"
+\`\`\`
+
+I need to change "john" to "cline". This is a simple targeted edit, so I should use the replace_in_file tool rather than write_to_file since I\'m only changing one small part of the file.
+
+I need to:
+1. Use replace_in_file to change "john" to "cline" in the test.ts file
+2. The SEARCH block should match the exact content: \`export const name = "john"\`
+3. The REPLACE block should be: \`export const name = "cline"\`
+</thinking>
+
+I\'ll replace "john" with "cline" in the test.ts file.
+
+<replace_in_file>
+<path>test.ts</path>
+<diff>
+------- SEARCH
+export const name = "john"
+=======
+export const name = "cline"
++++++++ REPLACE
+</diff>
+</replace_in_file>`
+
+export const E2E_MOCK_API_RESPONSES = {
+	DEFAULT: "Hello! I'm a mock Cline API response.",
+	REPLACE_REQUEST: replace_in_file,
+	EDIT_REQUEST: edit_request,
+}

+ 218 - 0
src/test/e2e/fixtures/server/data.ts

@@ -0,0 +1,218 @@
+import type {
+	BalanceResponse,
+	OrganizationBalanceResponse,
+	OrganizationUsageTransaction,
+	PaymentTransaction,
+	UsageTransaction,
+	UserResponse,
+} from "../../../../shared/ClineAccount"
+
+const organizations = [
+	{
+		organizationId: "random-org-id",
+		memberId: "random-member-id",
+		name: "Test Organization",
+		roles: ["member"],
+		active: false,
+	},
+] satisfies UserResponse["organizations"]
+
+export class ClineDataMock {
+	public static readonly USERS = [
+		{
+			name: "test-personal-user",
+			orgId: undefined,
+			uid: "test-member-789",
+			token: "test-personal-token",
+			email: "[email protected]",
+			displayName: "Personal User",
+			photoUrl: "https://example.com/personal-photo.jpg",
+			organizations,
+		},
+		{
+			name: "test-enterprise-user",
+			orgId: "test-org-789",
+			uid: "test-member-012",
+			token: "test-enterprise-token",
+			email: "[email protected]",
+			displayName: "Enterprise User",
+			photoUrl: "https://example.com/photo.jpg",
+			organizations,
+		},
+	]
+
+	// Helper method to get user by name from USERS array
+	public static getUserByName(name: string) {
+		return ClineDataMock.USERS.find((u) => u.name === name)
+	}
+
+	// Helper method to get user by token from USERS array
+	public static findUserByToken(token: string) {
+		return ClineDataMock.USERS.find((u) => u.token === token)
+	}
+
+	// Helper method to get all available tokens for testing
+	public static getAllTokens() {
+		return ClineDataMock.USERS.map((u) => ({ name: u.name, token: u.token }))
+	}
+
+	// Helper method to get default tokens by type
+	public static getDefaultToken(type: "personal" | "enterprise") {
+		const user = ClineDataMock.USERS.find((u) => (type === "personal" ? !u.orgId : !!u.orgId))
+		return user?.token
+	}
+
+	constructor(userType?: "personal" | "enterprise") {
+		if (userType === "personal") {
+			const userData = ClineDataMock.findUserByToken("test-personal-token")
+			this._currentUser = userData ? this._createUserResponse(userData) : null
+		} else if (userType === "enterprise") {
+			const userData = ClineDataMock.findUserByToken("test-enterprise-token")
+			this._currentUser = userData ? this._createUserResponse(userData) : null
+		} else {
+			this._currentUser = null // Default to no user
+		}
+	}
+
+	// Mock generation data for usage tracking
+	private readonly mockGenerations = new Map<string, any>()
+
+	public getGeneration(generationId: string): any {
+		return this.mockGenerations.get(generationId)
+	}
+
+	private _currentUser: UserResponse | null = null
+
+	public getCurrentUser(): UserResponse | null {
+		return this._currentUser
+	}
+
+	public setCurrentUser(user: UserResponse | null) {
+		this._currentUser = user
+	}
+
+	// Helper method to switch to a specific user type for testing
+	public switchToUserType(type: "personal" | "enterprise"): UserResponse {
+		const token = ClineDataMock.getDefaultToken(type)
+		if (!token) {
+			throw new Error(`No ${type} user found in USERS array`)
+		}
+		return this.getUserByToken(token)
+	}
+	// Helper to create UserResponse from USERS array data
+	private _createUserResponse(userData: (typeof ClineDataMock.USERS)[0]): UserResponse {
+		const currentTime = new Date().toISOString()
+
+		return {
+			id: userData.uid,
+			email: userData.email,
+			displayName: userData.displayName,
+			photoUrl: userData.photoUrl,
+			createdAt: currentTime,
+			updatedAt: currentTime,
+			organizations,
+		}
+	}
+
+	public getUserByToken(token?: string): UserResponse {
+		// Use default personal token if none provided
+		const actualToken = token || ClineDataMock.getDefaultToken("personal") || "test-personal-token"
+		const currentUser = this._getUserByToken(actualToken)
+		this.setCurrentUser(currentUser)
+		return currentUser
+	}
+
+	// Helper function to get user data based on auth token
+	private _getUserByToken(token: string): UserResponse {
+		const match = ClineDataMock.findUserByToken(token)
+
+		if (!match) {
+			// Default fallback user for backward compatibility
+			return {
+				id: "random-user-id",
+				email: "[email protected]",
+				displayName: "Test User",
+				photoUrl: "https://example.com/photo.jpg",
+				createdAt: new Date().toISOString(),
+				updatedAt: new Date().toISOString(),
+				organizations,
+			}
+		}
+
+		return this._createUserResponse(match)
+	}
+
+	public getMockBalance(userId: string): BalanceResponse {
+		return {
+			balance: 100000, // Sufficient credits for testing
+			userId,
+		}
+	}
+
+	public getMockOrgBalance(organizationId: string): OrganizationBalanceResponse {
+		return {
+			balance: 500.0,
+			organizationId,
+		}
+	}
+
+	public getMockUsageTransactions(
+		userId: string,
+		orgId?: string,
+		max = 5,
+	): UsageTransaction[] | OrganizationUsageTransaction[] {
+		console.log("Generating mock usage transactions for", { orgId, userId })
+		const usages: (OrganizationUsageTransaction | UsageTransaction)[] = []
+		const currentTime = new Date().toISOString()
+		const memberDisplayName = this._currentUser?.displayName || "Test User"
+		const memberEmail = this._currentUser?.email || "[email protected]"
+		const firstUsage = orgId ? 6000 : 1000
+
+		for (let i = 0; i < max; i++) {
+			const completionTokens = Math.floor(Math.random() * 100) + 50 // 50-150 tokens
+			const randomCost = i === 0 ? firstUsage : Math.random() * 0.1 + 0.01 // $0.01-$0.11
+
+			usages.push({
+				id: `usage-${i + 1}`,
+				aiInferenceProviderName: "anthropic",
+				aiModelName: orgId ? "claude-4-opus-latest" : "claude-4-sonnet-latest",
+				aiModelTypeName: "chat",
+				completionTokens,
+				costUsd: Number(randomCost.toFixed(2)),
+				createdAt: currentTime,
+				creditsUsed: Number(randomCost.toFixed(2)),
+				generationId: `gen-${i + 1}`,
+				memberDisplayName,
+				memberEmail,
+				organizationId: orgId || "",
+				promptTokens: 100,
+				totalTokens: 150,
+				userId,
+				metadata: {
+					additionalProp1: "mock-data",
+					additionalProp2: "e2e-test",
+					additionalProp3: "mock-api",
+				},
+			})
+		}
+		return usages
+	}
+
+	public getMockPaymentTransactions(creatorId: string, max = 5): PaymentTransaction[] {
+		const transactions: PaymentTransaction[] = []
+		const currentTime = new Date().toISOString()
+
+		for (let i = 0; i < max; i++) {
+			const amountCents = Math.floor(Math.random() * 10000) + 1000 // $10.00-$110.00
+			const credits = Math.random() * 100 + 10 // 10-110 credits
+
+			transactions.push({
+				paidAt: currentTime,
+				creatorId,
+				amountCents,
+				credits,
+			})
+		}
+		return transactions
+	}
+}

+ 471 - 0
src/test/e2e/fixtures/server/index.ts

@@ -0,0 +1,471 @@
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
+import type { Socket } from "node:net"
+import { parse } from "node:url"
+import { v4 as uuidv4 } from "uuid"
+import type { BalanceResponse, OrganizationBalanceResponse, UserResponse } from "../../../../shared/ClineAccount"
+import { E2E_MOCK_API_RESPONSES, E2E_REGISTERED_MOCK_ENDPOINTS } from "./api"
+import { ClineDataMock } from "./data"
+
+const E2E_API_SERVER_PORT = 7777
+
+export const MOCK_CLINE_API_SERVER_URL = `http://localhost:${E2E_API_SERVER_PORT}`
+
+export class ClineApiServerMock {
+	private currentUser: UserResponse | null = null
+	private userBalance = 100.5 // Default sufficient balance
+	private orgBalance = 500.0
+	private userHasOrganization = false
+	public generationCounter = 0
+
+	public readonly API_USER = new ClineDataMock("personal")
+
+	constructor(public readonly server: Server) {}
+
+	// Test helper methods
+	public setUserBalance(balance: number) {
+		this.userBalance = balance
+	}
+
+	public setUserHasOrganization(hasOrg: boolean) {
+		this.userHasOrganization = hasOrg
+		const user = this.currentUser
+		if (!user) {
+			return
+		}
+		user.organizations[0].active = hasOrg
+		this.setCurrentUser(user)
+	}
+
+	public setOrgBalance(balance: number) {
+		this.orgBalance = balance
+	}
+
+	public setCurrentUser(user: UserResponse | null) {
+		this.API_USER.setCurrentUser(user)
+		this.currentUser = user
+	}
+
+	// Helper to match routes against registered endpoints and extract parameters
+	private static matchRoute(
+		path: string,
+		method: string,
+	): {
+		matched: boolean
+		baseRoute?: string
+		endpoint?: string
+		params?: Record<string, string>
+	} {
+		for (const [baseRoute, methods] of Object.entries(E2E_REGISTERED_MOCK_ENDPOINTS)) {
+			const methodEndpoints = methods[method as keyof typeof methods]
+			if (!methodEndpoints) {
+				continue
+			}
+
+			for (const endpoint of methodEndpoints) {
+				const fullPattern = `${baseRoute}${endpoint}`
+				const params: Record<string, string> = {}
+
+				// Convert pattern like "/users/{userId}/balance" to a regex
+				const regexPattern = fullPattern.replace(/\{([^}]+)\}/g, () => {
+					return "([^/]+)"
+				})
+
+				const regex = new RegExp(`^${regexPattern}$`)
+				const match = path.match(regex)
+
+				if (match) {
+					// Extract parameter names from the pattern
+					const paramNames: string[] = []
+					const paramRegex = /\{([^}]+)\}/g
+					let paramMatch: RegExpExecArray | null = paramRegex.exec(fullPattern)
+					while (paramMatch !== null) {
+						paramNames.push(paramMatch[1])
+						paramMatch = paramRegex.exec(fullPattern)
+					}
+
+					// Map captured groups to parameter names
+					for (let i = 0; i < paramNames.length; i++) {
+						params[paramNames[i]] = match[i + 1]
+					}
+
+					return {
+						matched: true,
+						baseRoute,
+						endpoint,
+						params,
+					}
+				}
+			}
+		}
+
+		return { matched: false }
+	}
+
+	// Runs a mock Cline API server for testing
+	public static async run<T>(around: (server: ClineApiServerMock) => Promise<T>): Promise<T> {
+		const server = createServer((req: IncomingMessage, res: ServerResponse) => {
+			// Parse URL and method
+			const parsedUrl = parse(req.url || "", true)
+			const path = parsedUrl.pathname || ""
+			const query = parsedUrl.query
+			const method = req.method || "GET"
+
+			// Helper to read request body
+			const readBody = (): Promise<string> => {
+				return new Promise((resolve) => {
+					let body = ""
+					req.on("data", (chunk) => {
+						body += chunk.toString()
+					})
+					req.on("end", () => resolve(body))
+				})
+			}
+
+			// Helper to send JSON response
+			const sendJson = (data: unknown, status = 200) => {
+				res.writeHead(status, { "Content-Type": "application/json" })
+				res.end(JSON.stringify(data))
+			}
+
+			// Helper to send API response
+			const sendApiResponse = (data: unknown, status = 200) => {
+				console.log(`API Response: ${JSON.stringify(data)}`)
+				sendJson({ success: true, data }, status)
+			}
+
+			const sendApiError = (error: string, status = 400) => {
+				console.error("API Error: %s", error, status)
+				sendJson({ success: false, error }, status)
+			}
+
+			// Authentication middleware
+			const authHeader = req.headers.authorization
+			const isAuthRequired = !path.startsWith("/.test/") && path !== "/health"
+
+			if (isAuthRequired && (!authHeader || !authHeader.startsWith("Bearer "))) {
+				return sendApiError("Unauthorized", 401)
+			}
+
+			const authToken = authHeader?.substring(7) // Remove "Bearer " prefix
+
+			// Authenticate the token and set current user
+			if (isAuthRequired && authToken) {
+				console.log(`Authenticating token: ${authToken}`)
+				const user = controller.API_USER.getUserByToken(authToken)
+				if (!user) {
+					return sendApiError("Invalid token", 401)
+				}
+				controller.setCurrentUser(user)
+			}
+
+			console.log("Received %s request for %s query %s", method, path, JSON.stringify(query))
+
+			// Route handling
+			const handleRequest = async () => {
+				// Try to match the route using registered endpoints
+				const routeMatch = ClineApiServerMock.matchRoute(path, method)
+
+				if (!routeMatch.matched) {
+					return sendJson({ error: "Not found" }, 404)
+				}
+
+				const { baseRoute, endpoint, params = {} } = routeMatch
+
+				// Health check endpoints
+				if (baseRoute === "/health") {
+					if (endpoint === "/" && method === "GET") {
+						return sendJson({
+							status: "ok",
+							timestamp: new Date().toISOString(),
+						})
+					}
+				}
+
+				// API v1 endpoints
+				if (baseRoute === "/api/v1") {
+					// User endpoints
+					if (endpoint === "/users/me" && method === "GET") {
+						const currentUser = controller.currentUser
+						if (!currentUser) {
+							return sendApiError("Unauthorized", 401)
+						}
+						return sendApiResponse(currentUser)
+					}
+
+					if (endpoint === "/users/{userId}/balance" && method === "GET") {
+						const { userId } = params
+						const balance: BalanceResponse = {
+							balance: controller.userBalance,
+							userId,
+						}
+						return sendApiResponse(balance)
+					}
+
+					if (endpoint === "/users/{userId}/usages" && method === "GET") {
+						const { userId } = params
+						const currentUser = controller.currentUser
+						if (currentUser?.id !== userId) {
+							return sendApiError("Unauthorized", 401)
+						}
+						return sendApiResponse({
+							items: controller.API_USER.getMockUsageTransactions(userId),
+						})
+					}
+
+					if (endpoint === "/users/{userId}/payments" && method === "GET") {
+						const { userId } = params
+						const currentUser = controller.currentUser
+						if (currentUser?.id !== userId) {
+							return sendApiError("Unauthorized", 401)
+						}
+						return sendApiResponse({
+							paymentTransactions: controller.API_USER.getMockPaymentTransactions(userId),
+						})
+					}
+
+					// Organization endpoints
+					if (endpoint === "/organizations/{orgId}/balance" && method === "GET") {
+						const { orgId } = params
+						const balance: OrganizationBalanceResponse = {
+							balance: controller.orgBalance,
+							organizationId: orgId,
+						}
+						return sendApiResponse(balance)
+					}
+
+					if (endpoint === "/organizations/{orgId}/members/{memberId}/usages" && method === "GET") {
+						const currentUser = controller.currentUser
+						if (!currentUser) {
+							return sendApiError("Unauthorized", 401)
+						}
+						const body = await readBody()
+						const { orgId } = params
+						console.log("Fetching organization usage transactions for", {
+							orgId,
+							body,
+						})
+						return sendApiResponse({
+							items: controller.API_USER.getMockUsageTransactions(currentUser.id, orgId),
+						})
+					}
+
+					if (endpoint === "/users/active-account" && method === "PUT") {
+						const body = await readBody()
+						console.log("Switching active account")
+						const { organizationId } = JSON.parse(body)
+						controller.setUserHasOrganization(!!organizationId)
+						const currentUser = controller.API_USER.getCurrentUser()
+						if (!currentUser) {
+							return sendApiError("No current user found", 400)
+						}
+						if (organizationId === null) {
+							for (const org of currentUser.organizations) {
+								org.active = false
+							}
+						} else {
+							const orgIndex = currentUser.organizations.findIndex((org) => org.organizationId === organizationId)
+							if (orgIndex === -1) {
+								return sendApiError("Organization not found", 404)
+							}
+							currentUser.organizations[orgIndex].active = controller.userHasOrganization
+						}
+						controller.setCurrentUser(currentUser)
+						return sendApiResponse("Account switched successfully")
+					}
+
+					// Chat completions endpoint
+					if (endpoint === "/chat/completions" && method === "POST") {
+						if (!controller.userHasOrganization && controller.userBalance <= 0) {
+							return sendApiError(
+								JSON.stringify({
+									code: "insufficient_credits",
+									current_balance: controller.userBalance,
+									message: "Not enough credits available",
+								}),
+								402,
+							)
+						}
+
+						const body = await readBody()
+						const parsed = JSON.parse(body)
+						const { _messages, model = "claude-3-5-sonnet-20241022", stream = true } = parsed
+						let responseText = E2E_MOCK_API_RESPONSES.DEFAULT
+						if (body.includes("[replace_in_file for 'test.ts'] Result:")) {
+							responseText = E2E_MOCK_API_RESPONSES.REPLACE_REQUEST
+						}
+						if (body.includes("edit_request")) {
+							responseText = E2E_MOCK_API_RESPONSES.EDIT_REQUEST
+						}
+
+						const generationId = `gen_${++controller.generationCounter}_${Date.now()}`
+
+						if (stream) {
+							res.writeHead(200, {
+								"Content-Type": "text/plain",
+								"Cache-Control": "no-cache",
+								Connection: "keep-alive",
+							})
+
+							const randomUUID = uuidv4()
+
+							responseText += `\n\nGenerated UUID: ${randomUUID}`
+
+							const chunks = responseText.split(" ")
+							let chunkIndex = 0
+
+							const sendChunk = () => {
+								if (chunkIndex < chunks.length) {
+									const chunk = {
+										id: generationId,
+										object: "chat.completion.chunk",
+										created: Math.floor(Date.now() / 1000),
+										model,
+										choices: [
+											{
+												index: 0,
+												delta: {
+													content: chunks[chunkIndex] + (chunkIndex < chunks.length - 1 ? " " : ""),
+												},
+												finish_reason: null,
+											},
+										],
+									}
+									res.write(`data: ${JSON.stringify(chunk)}\n\n`)
+									chunkIndex++
+									setTimeout(sendChunk, 50)
+								} else {
+									const finalChunk = {
+										id: generationId,
+										object: "chat.completion.chunk",
+										created: Math.floor(Date.now() / 1000),
+										model,
+										choices: [
+											{
+												index: 0,
+												delta: {},
+												finish_reason: "stop",
+											},
+										],
+										usage: {
+											prompt_tokens: 140,
+											completion_tokens: responseText.length,
+											total_tokens: 140 + responseText.length,
+											cost: (140 + responseText.length) * 0.00015,
+										},
+									}
+									res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
+									res.write("data: [DONE]\n\n")
+									res.end()
+								}
+							}
+
+							sendChunk()
+							return
+						} else {
+							const response = {
+								id: generationId,
+								object: "chat.completion",
+								created: Math.floor(Date.now() / 1000),
+								model,
+								choices: [
+									{
+										index: 0,
+										message: {
+											role: "assistant",
+											content: "Hello! I'm a mock Cline API response.",
+										},
+										finish_reason: "stop",
+									},
+								],
+								usage: {
+									prompt_tokens: 140,
+									completion_tokens: responseText.length,
+									total_tokens: 140 + responseText.length,
+									cost: (140 + responseText.length) * 0.00015,
+								},
+							}
+							return sendJson(response)
+						}
+					}
+
+					// Generation details endpoint
+					if (endpoint === "/generation" && method === "GET") {
+						const generationId = query.id as string
+						const generation = controller.API_USER.getGeneration(generationId)
+
+						if (!generation) {
+							return sendJson({ error: "Generation not found" }, 404)
+						}
+
+						return sendJson(generation)
+					}
+				}
+
+				// Test helper endpoints
+				if (baseRoute === "/.test") {
+					if (endpoint === "/auth" && method === "POST") {
+						const user = controller.API_USER.getUserByToken()
+						if (!user) {
+							return sendApiError("Invalid token", 401)
+						}
+						controller.setCurrentUser(user)
+						return
+					}
+
+					if (endpoint === "/setUserBalance" && method === "POST") {
+						const body = await readBody()
+						const { balance } = JSON.parse(body)
+						controller.setUserBalance(balance)
+						res.writeHead(200)
+						res.end()
+						return
+					}
+
+					if (endpoint === "/setUserHasOrganization" && method === "POST") {
+						const body = await readBody()
+						const { hasOrg } = JSON.parse(body)
+						controller.setUserHasOrganization(hasOrg)
+						res.writeHead(200)
+						res.end()
+						return
+					}
+
+					if (endpoint === "/setOrgBalance" && method === "POST") {
+						const body = await readBody()
+						const { balance } = JSON.parse(body)
+						controller.setOrgBalance(balance)
+						res.writeHead(200)
+						res.end()
+						return
+					}
+				}
+
+				// If we get here, the route was matched but not handled
+				return sendJson({ error: "Endpoint not implemented" }, 500)
+			}
+
+			handleRequest().catch((err) => {
+				console.error("Request handling error:", err)
+				sendApiError("Internal server error", 500)
+			})
+		})
+
+		// Initialize the controller after the server is created
+		const controller = new ClineApiServerMock(server)
+
+		server.listen(E2E_API_SERVER_PORT)
+
+		// Track connections for proper cleanup
+		const sockets = new Set<Socket>()
+		server.on("connection", (socket) => sockets.add(socket))
+
+		const result = await around(controller)
+
+		// Clean shutdown
+		const serverClosed = new Promise((resolve) => server.close(resolve))
+		sockets.forEach((socket) => socket.destroy())
+		await serverClosed
+
+		return result
+	}
+}

+ 1 - 0
src/test/e2e/fixtures/workspace/test.ts

@@ -0,0 +1 @@
+export const name = "john"

+ 194 - 92
src/test/e2e/utils/helpers.ts

@@ -1,9 +1,10 @@
-import { type ElectronApplication, type Frame, type Page, test, expect } from "@playwright/test"
-import { type PathLike, type RmOptions, mkdtempSync, rmSync } from "node:fs"
-import { _electron } from "playwright"
-import { SilentReporter, downloadAndUnzipVSCode } from "@vscode/test-electron"
+import { mkdtempSync, type PathLike, type RmOptions, rmSync } from "node:fs"
 import * as os from "node:os"
 import * as path from "node:path"
+import { type ElectronApplication, expect, type Frame, type Page, test } from "@playwright/test"
+import { downloadAndUnzipVSCode, SilentReporter } from "@vscode/test-electron"
+import { _electron } from "playwright"
+import { ClineApiServerMock } from "../fixtures/server"
 
 interface E2ETestDirectories {
 	workspaceDir: string
@@ -11,113 +12,192 @@ interface E2ETestDirectories {
 	extensionsDir: string
 }
 
-// Constants
-const CODEBASE_ROOT_DIR = path.resolve(__dirname, "..", "..", "..", "..")
-const E2E_TESTS_DIR = path.join(CODEBASE_ROOT_DIR, "src", "test", "e2e")
+export class E2ETestHelper {
+	// Constants
+	public static readonly CODEBASE_ROOT_DIR = path.resolve(__dirname, "..", "..", "..", "..")
+	public static readonly E2E_TESTS_DIR = path.join(E2ETestHelper.CODEBASE_ROOT_DIR, "src", "test", "e2e")
 
-// Path utilities
-const escapeToPath = (text: string): string => text.trim().toLowerCase().replaceAll(/\W/g, "_")
-const getResultsDir = (testName = "", label?: string): string => {
-	const testDir = path.join(CODEBASE_ROOT_DIR, "test-results", "playwright", escapeToPath(testName))
-	return label ? path.join(testDir, label) : testDir
-}
+	// Instance properties for caching
+	private cachedFrame: Frame | null = null
 
-async function waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 5000): Promise<void> {
-	let delay = 10
-	const start = Date.now()
+	constructor() {
+		// Initialize any instance-specific state if needed
+	}
 
-	while (!(await predicate())) {
-		if (Date.now() - start > maxDelay) {
-			throw new Error(`waitUntil timeout after ${maxDelay}ms`)
-		}
-		await new Promise((resolve) => setTimeout(resolve, delay))
-		delay = Math.min(delay << 1, 1000) // Cap at 1s
+	// Path utilities
+	public static escapeToPath(text: string): string {
+		return text.trim().toLowerCase().replaceAll(/\W/g, "_")
+	}
+
+	public static getResultsDir(testName = "", label?: string): string {
+		const testDir = path.join(
+			E2ETestHelper.CODEBASE_ROOT_DIR,
+			"test-results",
+			"playwright",
+			E2ETestHelper.escapeToPath(testName),
+		)
+		return label ? path.join(testDir, label) : testDir
 	}
-}
 
-export async function getSidebar(page: Page): Promise<Frame> {
-	let cachedFrame: Frame | null = null
+	public static async waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 5000): Promise<void> {
+		let delay = 10
+		const start = Date.now()
 
-	const findSidebarFrame = async (): Promise<Frame | null> => {
-		// Check cached frame first
-		if (cachedFrame && !cachedFrame.isDetached()) {
-			return cachedFrame
+		while (!(await predicate())) {
+			if (Date.now() - start > maxDelay) {
+				throw new Error(`waitUntil timeout after ${maxDelay}ms`)
+			}
+			await new Promise((resolve) => setTimeout(resolve, delay))
+			delay = Math.min(delay << 1, 1000) // Cap at 1s
 		}
+	}
 
-		for (const frame of page.frames()) {
-			if (frame.isDetached()) {
-				continue
+	public async getSidebar(page: Page): Promise<Frame> {
+		const findSidebarFrame = async (): Promise<Frame | null> => {
+			// Check cached frame first
+			if (this.cachedFrame && !this.cachedFrame.isDetached()) {
+				return this.cachedFrame
 			}
 
-			try {
-				const title = await frame.title()
-				if (title.startsWith("Cline")) {
-					cachedFrame = frame
-					return frame
+			for (const frame of page.frames()) {
+				if (frame.isDetached()) {
+					continue
 				}
-			} catch (error: any) {
-				if (!error.message.includes("detached") && !error.message.includes("navigation")) {
-					throw error
+
+				try {
+					const title = await frame.title()
+					if (title.startsWith("Cline")) {
+						this.cachedFrame = frame
+						return frame
+					}
+				} catch (error: any) {
+					if (!error.message.includes("detached") && !error.message.includes("navigation")) {
+						throw error
+					}
 				}
 			}
+			return null
 		}
-		return null
-	}
 
-	await waitUntil(async () => (await findSidebarFrame()) !== null)
-	return (await findSidebarFrame()) || page.mainFrame()
-}
+		await E2ETestHelper.waitUntil(async () => (await findSidebarFrame()) !== null)
+		return (await findSidebarFrame()) || page.mainFrame()
+	}
 
-export async function rmForRetries(path: PathLike, options?: RmOptions): Promise<void> {
-	const maxAttempts = 3 // Reduced from 5
+	public static async rmForRetries(path: PathLike, options?: RmOptions): Promise<void> {
+		const maxAttempts = 3 // Reduced from 5
 
-	for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-		try {
-			rmSync(path, options)
-			return
-		} catch (error) {
-			if (attempt === maxAttempts) {
-				throw new Error(`Failed to rmSync ${path} after ${maxAttempts} attempts: ${error}`)
+		for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+			try {
+				rmSync(path, options)
+				return
+			} catch (error) {
+				if (attempt === maxAttempts) {
+					throw new Error(`Failed to rmSync ${path} after ${maxAttempts} attempts: ${error}`)
+				}
+				await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) // Progressive delay
 			}
-			await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) // Progressive delay
 		}
 	}
-}
 
-export async function signin(webview: Frame): Promise<void> {
-	const byokButton = webview.getByRole("button", { name: "Use your own API key" })
-	await expect(byokButton).toBeVisible()
+	public static async signin(webview: Frame): Promise<void> {
+		const byokButton = webview.getByRole("button", {
+			name: "Use your own API key",
+		})
+		await expect(byokButton).toBeVisible()
 
-	await byokButton.click()
+		await byokButton.click()
 
-	// Complete setup with OpenRouter
-	const apiKeyInput = webview.getByRole("textbox", { name: "OpenRouter API Key" })
-	await apiKeyInput.fill("test-api-key")
-	await webview.getByRole("button", { name: "Let's go!" }).click()
+		// Complete setup with OpenRouter
+		const apiKeyInput = webview.getByRole("textbox", {
+			name: "OpenRouter API Key",
+		})
+		await apiKeyInput.fill("test-api-key")
+		await webview.getByRole("button", { name: "Let's go!" }).click()
 
-	// Verify start up page is no longer visible
-	await expect(webview.locator("#api-provider div").first()).not.toBeVisible()
-	await expect(byokButton).not.toBeVisible()
-}
+		// Verify start up page is no longer visible
+		await expect(webview.locator("#api-provider div").first()).not.toBeVisible()
+		await expect(byokButton).not.toBeVisible()
+	}
 
-export async function openClineSidebar(page: Page): Promise<void> {
-	await page.getByRole("tab", { name: /Cline/ }).locator("a").click()
-}
+	public static async openClineSidebar(page: Page): Promise<void> {
+		await page.getByRole("tab", { name: /Cline/ }).locator("a").click()
+	}
+
+	public static async runCommandPalette(page: Page, command: string): Promise<void> {
+		await page.locator("li").filter({ hasText: "[Extension Development Host]" }).first().click()
+		const editorSearchBar = page.getByRole("textbox", {
+			name: "Search files by name (append",
+		})
+		await expect(editorSearchBar).toBeVisible()
+		await editorSearchBar.click()
+		await editorSearchBar.fill(`>${command}`)
+		await page.keyboard.press("Enter")
+	}
 
-export async function runCommandPalette(page: Page, command: string): Promise<void> {
-	await page.locator("li").filter({ hasText: "[Extension Development Host]" }).first().click()
-	const editorSearchBar = page.getByRole("textbox", { name: "Search files by name (append" })
-	await expect(editorSearchBar).toBeVisible()
-	await editorSearchBar.click()
-	await editorSearchBar.fill(`>${command}`)
-	await page.keyboard.press("Enter")
+	// Clear cached frame when needed
+	public clearCachedFrame(): void {
+		this.cachedFrame = null
+	}
 }
 
-// Test configuration
+/**
+ * NOTE: Use the `e2e` test fixture for all E2E tests to test the Cline extension.
+ *
+ * Extended Playwright test configuration for Cline E2E testing.
+ *
+ * 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.
+ *
+ * @extends test - Base Playwright test with multiple fixture extensions
+ *
+ * Fixtures provided:
+ * - `server`: ClineApiServerMock instance for API mocking
+ * - `workspaceDir`: Path to the test workspace directory
+ * - `userDataDir`: Temporary directory for VS Code user data
+ * - `extensionsDir`: Temporary directory for VS Code extensions
+ * - `openVSCode`: Function that returns a Promise resolving to an ElectronApplication instance
+ * - `app`: ElectronApplication instance with automatic cleanup
+ * - `helper`: E2ETestHelper instance for test utilities
+ * - `page`: Playwright Page object representing the main VS Code window with Cline sidebar opened
+ * - `sidebar`: Playwright Frame object representing the Cline extension's sidebar iframe
+ *
+ * @returns Extended test object with all fixtures available for E2E test scenarios:
+ * - **server**: Automatically starts and manages a ClineApiServerMock instance
+ * - **workspaceDir**: Sets up a test workspace directory from fixtures
+ * - **userDataDir**: Creates a temporary directory for VS Code user data
+ * - **extensionsDir**: Creates a temporary directory for VS Code extensions
+ * - **openVSCode**: Factory function that launches VS Code with proper configuration for testing
+ * - **app**: Manages the VS Code ElectronApplication lifecycle with automatic cleanup
+ * - **helper**: Provides E2ETestHelper utilities for test operations
+ * - **page**: Configures the main VS Code window with notifications disabled and Cline sidebar open
+ * - **sidebar**: Provides access to the Cline extension's sidebar frame
+ *
+ * @example
+ * ```typescript
+ * e2e('should perform basic operations', async ({ sidebar, helper }) => {
+ *   // Test implementation using the configured sidebar and helper
+ * });
+ * ```
+ *
+ * @remarks
+ * - Automatically handles VS Code download and setup
+ * - Installs the Cline extension in development mode
+ * - Records test videos for debugging
+ * - Performs cleanup of temporary directories after each test
+ * - Configures VS Code with disabled updates, workspace trust, and welcome screens
+ */
 export const e2e = test
+	.extend<{ server: ClineApiServerMock }>({
+		server: [
+			async ({}, use) => {
+				ClineApiServerMock.run(async (server) => await use(server))
+			},
+			{ auto: true },
+		],
+	})
 	.extend<E2ETestDirectories>({
 		workspaceDir: async ({}, use) => {
-			await use(path.join(E2E_TESTS_DIR, "fixtures", "workspace"))
+			await use(path.join(E2ETestHelper.E2E_TESTS_DIR, "fixtures", "workspace"))
 		},
 		userDataDir: async ({}, use) => {
 			await use(mkdtempSync(path.join(os.tmpdir(), "vsce")))
@@ -133,8 +213,17 @@ export const e2e = test
 			await use(async () => {
 				const app = await _electron.launch({
 					executablePath,
-					env: { ...process.env, TEMP_PROFILE: "true", E2E_TEST: "true" },
-					recordVideo: { dir: getResultsDir(testInfo.title, "recordings") },
+					env: {
+						...process.env,
+						TEMP_PROFILE: "true",
+						E2E_TEST: "true",
+						CLINE_ENVIRONMENT: "local",
+						// IS_DEV: "true",
+						// DEV_WORKSPACE_FOLDER: E2ETestHelper.CODEBASE_ROOT_DIR,
+					},
+					recordVideo: {
+						dir: E2ETestHelper.getResultsDir(testInfo.title, "recordings"),
+					},
 					args: [
 						"--no-sandbox",
 						"--disable-updates",
@@ -143,12 +232,12 @@ export const e2e = test
 						"--skip-release-notes",
 						`--user-data-dir=${userDataDir}`,
 						`--extensions-dir=${extensionsDir}`,
-						`--install-extension=${path.join(CODEBASE_ROOT_DIR, "dist", "e2e.vsix")}`,
-						`--extensionDevelopmentPath=${CODEBASE_ROOT_DIR}`,
+						`--install-extension=${path.join(E2ETestHelper.CODEBASE_ROOT_DIR, "dist", "e2e.vsix")}`,
+						`--extensionDevelopmentPath=${E2ETestHelper.CODEBASE_ROOT_DIR}`,
 						workspaceDir,
 					],
 				})
-				await waitUntil(() => app.windows().length > 0)
+				await E2ETestHelper.waitUntil(() => app.windows().length > 0)
 				return app
 			})
 		},
@@ -163,25 +252,38 @@ export const e2e = test
 				await app.close()
 				// Cleanup in parallel
 				await Promise.allSettled([
-					rmForRetries(userDataDir, { recursive: true }),
-					rmForRetries(extensionsDir, { recursive: true }),
+					E2ETestHelper.rmForRetries(userDataDir, { recursive: true }),
+					E2ETestHelper.rmForRetries(extensionsDir, { recursive: true }),
 				])
 			}
 		},
 	})
+	.extend<{ helper: E2ETestHelper }>({
+		helper: async ({}, use) => {
+			const helper = new E2ETestHelper()
+			await use(helper)
+		},
+	})
 	.extend({
 		page: async ({ app }, use) => {
 			const page = await app.firstWindow()
-			await runCommandPalette(page, "notifications: toggle do not disturb")
-			await openClineSidebar(page)
+			await E2ETestHelper.runCommandPalette(page, "notifications: toggle do not disturb")
+			await E2ETestHelper.openClineSidebar(page)
 			await use(page)
 		},
 	})
 	.extend<{ sidebar: Frame }>({
-		sidebar: async ({ page }, use) => {
-			const sidebar = await getSidebar(page)
+		sidebar: async ({ page, helper }, use) => {
+			const sidebar = await helper.getSidebar(page)
 			await use(sidebar)
 		},
 	})
 
-export { getResultsDir }
+// Backward compatibility exports
+export const getResultsDir = E2ETestHelper.getResultsDir
+export const getSidebar = (page: Page) => new E2ETestHelper().getSidebar(page)
+export const rmForRetries = E2ETestHelper.rmForRetries
+export const signin = E2ETestHelper.signin
+export const openClineSidebar = E2ETestHelper.openClineSidebar
+export const runCommandPalette = E2ETestHelper.runCommandPalette
+export const waitUntil = E2ETestHelper.waitUntil