Compare commits

...

12 Commits

Author SHA1 Message Date
abeatrix cd4938ac66 rename test and add orgs 2025-07-28 19:35:40 -07:00
abeatrix 83e07d3e2a Merge branch 'main' into bee/mock-server 2025-07-28 19:07:12 -07:00
abeatrix c4b54cfbf3 clean up 2025-07-28 18:07:46 -07:00
abeatrix d3035d0aab increase timeout for windows 2025-07-28 16:41:08 -07:00
abeatrix 2f0c37cf53 wait for edit 2025-07-28 16:18:46 -07:00
abeatrix 9184f0dc86 wait for text 2025-07-28 15:34:40 -07:00
abeatrix ce9a2b2651 rename data 2025-07-28 15:20:43 -07:00
abeatrix eb291e7dff refactor mock server 2025-07-28 10:43:03 -07:00
abeatrix 1c72a77c73 import 2025-07-28 09:28:12 -07:00
abeatrix 5c3b48c929 Merge branch 'main' into bee/mock-server 2025-07-26 13:16:01 -07:00
abeatrix 2b741c8454 format 2025-07-26 13:14:56 -07:00
abeatrix 1a47258773 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
2025-07-24 13:23:31 -07:00
10 changed files with 1218 additions and 119 deletions
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: "personal@example.com",
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: "test@example.com",
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: "test@example.com",
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 || "test@example.com"
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
View File
@@ -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
View File
@@ -0,0 +1 @@
export const name = "john"
+202 -100
View File
@@ -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()
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
constructor() {
// Initialize any instance-specific state if needed
}
}
export async function getSidebar(page: Page): Promise<Frame> {
let cachedFrame: Frame | null = null
// Path utilities
public static escapeToPath(text: string): string {
return text.trim().toLowerCase().replaceAll(/\W/g, "_")
}
const findSidebarFrame = async (): Promise<Frame | null> => {
// Check cached frame first
if (cachedFrame && !cachedFrame.isDetached()) {
return cachedFrame
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
}
public static async waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 5000): Promise<void> {
let delay = 10
const start = Date.now()
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
}
for (const frame of page.frames()) {
if (frame.isDetached()) {
continue
}
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
}
await E2ETestHelper.waitUntil(async () => (await findSidebarFrame()) !== null)
return (await findSidebarFrame()) || page.mainFrame()
}
public static async rmForRetries(path: PathLike, options?: RmOptions): Promise<void> {
const maxAttempts = 3 // Reduced from 5
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const title = await frame.title()
if (title.startsWith("Cline")) {
cachedFrame = frame
return frame
}
} catch (error: any) {
if (!error.message.includes("detached") && !error.message.includes("navigation")) {
throw error
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
}
}
return null
}
await waitUntil(async () => (await findSidebarFrame()) !== null)
return (await findSidebarFrame()) || page.mainFrame()
}
public static async signin(webview: Frame): Promise<void> {
const byokButton = webview.getByRole("button", {
name: "Use your own API key",
})
await expect(byokButton).toBeVisible()
export async function rmForRetries(path: PathLike, options?: RmOptions): Promise<void> {
const maxAttempts = 3 // Reduced from 5
await byokButton.click()
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
}
// 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()
}
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")
}
// Clear cached frame when needed
public clearCachedFrame(): void {
this.cachedFrame = null
}
}
export async function signin(webview: Frame): Promise<void> {
const byokButton = webview.getByRole("button", { name: "Use your own API key" })
await expect(byokButton).toBeVisible()
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()
// 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()
}
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")
}
// 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