mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd4938ac66 | |||
| 83e07d3e2a | |||
| c4b54cfbf3 | |||
| d3035d0aab | |||
| 2f0c37cf53 | |||
| 9184f0dc86 | |||
| ce9a2b2651 | |||
| eb291e7dff | |||
| 1c72a77c73 | |||
| 5c3b48c929 | |||
| 2b741c8454 | |||
| 1a47258773 |
@@ -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"]],
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const name = "john"
|
||||
+202
-100
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user