Compare commits

..

4 Commits

Author SHA1 Message Date
abeatrix 5120fdb571 update helpers 2025-07-29 23:04:22 -07:00
abeatrix 88f19494b9 IS_DEV 2025-07-29 22:25:45 -07:00
abeatrix 0aa7ebaa13 Rename Playwright test project names to match 2025-07-29 21:42:26 -07:00
abeatrix 386abe061d refactor: e2e test setup to use Playwright projects with global server
- Replace globalSetup/globalTeardown with Playwright projects configuration
- Rename setup.ts to global.setup.ts and teardown.ts to global.teardown.ts
- Convert ClineApiServerMock to use shared global server instance
- Add proper dependency management between setup, tests, and cleanup phases
- Improve server connection tracking and cleanup handling
2025-07-29 20:03:41 -07:00
8 changed files with 130 additions and 57 deletions
+16 -2
View File
@@ -13,6 +13,20 @@ export default defineConfig({
},
fullyParallel: true,
reporter: isCI ? [["github"], ["list"]] : [["list"]],
globalSetup: require.resolve("./src/test/e2e/utils/setup"),
globalTeardown: require.resolve("./src/test/e2e/utils/teardown"),
projects: [
{
name: "setup test environment",
testMatch: /global\.setup\.ts/,
},
{
name: "e2e tests",
testMatch: /.*\.test\.ts/,
dependencies: ["setup test environment"],
},
{
name: "cleanup test environment",
testMatch: /global\.teardown\.ts/,
dependencies: ["e2e tests"],
},
],
})
+3 -3
View File
@@ -262,8 +262,8 @@ export abstract class WebviewProvider {
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
// Only show the error message if not in development mode.
if (!process.env.IS_DEV) {
// Only show the error message when in development mode.
if (process.env.IS_DEV) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message:
@@ -304,7 +304,7 @@ export abstract class WebviewProvider {
<!DOCTYPE html>
<html lang="en">
<head>
<script src="http://localhost:8097"></script>
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
+56 -16
View File
@@ -11,6 +11,9 @@ const E2E_API_SERVER_PORT = 7777
export const MOCK_CLINE_API_SERVER_URL = `http://localhost:${E2E_API_SERVER_PORT}`
export class ClineApiServerMock {
static globalSharedServer: ClineApiServerMock | null = null
static globalSockets: Set<Socket> = new Set()
private currentUser: UserResponse | null = null
private userBalance = 100.5 // Default sufficient balance
private orgBalance = 500.0
@@ -101,8 +104,12 @@ export class ClineApiServerMock {
return { matched: false }
}
// Runs a mock Cline API server for testing
public static async run<T>(around: (server: ClineApiServerMock) => Promise<T>): Promise<T> {
// Starts the global shared server
public static async startGlobalServer(): Promise<ClineApiServerMock> {
if (ClineApiServerMock.globalSharedServer) {
return ClineApiServerMock.globalSharedServer
}
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
// Parse URL and method
const parsedUrl = parse(req.url || "", true)
@@ -151,14 +158,19 @@ export class ClineApiServerMock {
// Authenticate the token and set current user
if (isAuthRequired && authToken) {
console.log(`Authenticating token: ${authToken}`)
const user = controller.API_USER.getUserByToken(authToken)
const user = ClineApiServerMock.globalSharedServer!.API_USER.getUserByToken(authToken)
if (!user) {
return sendApiError("Invalid token", 401)
}
controller.setCurrentUser(user)
ClineApiServerMock.globalSharedServer!.setCurrentUser(user)
}
console.log("Received %s request for %s query %s", method, path, JSON.stringify(query))
console.log("=== MOCK SERVER REQUEST ===")
console.log("Method:", method)
console.log("Path:", path)
console.log("Query:", JSON.stringify(query))
console.log("Headers:", JSON.stringify(req.headers))
console.log("===============")
// Route handling
const handleRequest = async () => {
@@ -170,6 +182,7 @@ export class ClineApiServerMock {
}
const { baseRoute, endpoint, params = {} } = routeMatch
const controller = ClineApiServerMock.globalSharedServer!
// Health check endpoints
if (baseRoute === "/health") {
@@ -332,7 +345,7 @@ export class ClineApiServerMock {
}
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
chunkIndex++
setTimeout(sendChunk, 20)
setTimeout(sendChunk, 50)
} else {
const finalChunk = {
id: generationId,
@@ -452,20 +465,47 @@ export class ClineApiServerMock {
// Initialize the controller after the server is created
const controller = new ClineApiServerMock(server)
server.listen(E2E_API_SERVER_PORT)
ClineApiServerMock.globalSharedServer = controller
// Track connections for proper cleanup
const sockets = new Set<Socket>()
server.on("connection", (socket) => sockets.add(socket))
server.on("connection", (socket) => {
ClineApiServerMock.globalSockets.add(socket)
socket.on("close", () => {
ClineApiServerMock.globalSockets.delete(socket)
})
})
const result = await around(controller)
await new Promise<void>((resolve, reject) => {
server.listen(E2E_API_SERVER_PORT, (error?: Error) => {
if (error) {
console.error(`Failed to start server on port ${E2E_API_SERVER_PORT}:`, error)
reject(error)
} else {
console.log(`ClineApiServerMock listening on port ${E2E_API_SERVER_PORT}`)
resolve()
}
})
})
// Clean shutdown
const serverClosed = new Promise((resolve) => server.close(resolve))
sockets.forEach((socket) => socket.destroy())
await serverClosed
return controller
}
return result
// Stops the global shared server
public static async stopGlobalServer(): Promise<void> {
if (!ClineApiServerMock.globalSharedServer) {
return
}
const server = ClineApiServerMock.globalSharedServer.server
// Clean shutdown - destroy all socket connections first
ClineApiServerMock.globalSockets.forEach((socket) => socket.destroy())
ClineApiServerMock.globalSockets.clear()
await new Promise<void>((resolve) => {
server.close(() => resolve())
})
ClineApiServerMock.globalSharedServer = null
}
}
+27
View File
@@ -0,0 +1,27 @@
import { rmSync } from "node:fs"
import { test as setup } from "@playwright/test"
import { getResultsDir } from "./helpers"
setup("setup test environment", async () => {
try {
const path = getResultsDir()
const options = { recursive: true, force: true }
const maxAttempts = 2
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}`)
}
console.error(`Failed to rmSync ${path} after ${attempt} attempts: ${error}`)
await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) // Progressive delay
}
}
} catch (error) {
console.error(`Error during setup: ${error}`)
}
})
@@ -1,9 +1,10 @@
import fs from "node:fs/promises"
import path from "node:path"
import type { FullConfig } from "playwright/test"
import { test as teardown } from "@playwright/test"
import { ClineApiServerMock } from "../fixtures/server"
import { getResultsDir, rmForRetries } from "./helpers"
export default async function (_: FullConfig) {
teardown("cleanup test environment", async () => {
const assetsDir = getResultsDir()
try {
@@ -21,10 +22,12 @@ export default async function (_: FullConfig) {
}
}),
)
await ClineApiServerMock.stopGlobalServer()
console.log("ClineApiServerMock stopped successfully.")
} catch (error) {
// Silently handle case where assets directory doesn't exist
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
})
+17 -11
View File
@@ -129,7 +129,7 @@ export class E2ETestHelper {
name: "Search files by name (append",
})
await expect(editorSearchBar).toBeVisible()
await editorSearchBar.click()
await editorSearchBar.click({ delay: 100 }) // Ensure focus
await editorSearchBar.fill(`>${command}`)
await page.keyboard.press("Enter")
}
@@ -151,7 +151,7 @@ export class E2ETestHelper {
* @extends test - Base Playwright test with multiple fixture extensions
*
* Fixtures provided:
* - `server`: ClineApiServerMock instance for API mocking
* - `server`: Shared ClineApiServerMock instance for API mocking (reused across all tests)
* - `workspaceDir`: Path to the test workspace directory
* - `userDataDir`: Temporary directory for VS Code user data
* - `extensionsDir`: Temporary directory for VS Code extensions
@@ -187,13 +187,19 @@ export class E2ETestHelper {
* - 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<{ server: ClineApiServerMock | null }>({
server: async ({}, use) => {
console.log("=== SERVER FIXTURE CALLED ===")
// Start server if it doesn't exist
if (!ClineApiServerMock.globalSharedServer) {
console.log("Starting global server...")
await ClineApiServerMock.startGlobalServer()
console.log("Global server started successfully")
} else {
console.log("Using existing global server")
}
await use(ClineApiServerMock.globalSharedServer)
},
})
.extend<E2ETestDirectories>({
workspaceDir: async ({}, use) => {
@@ -268,12 +274,12 @@ export const e2e = test
page: async ({ app }, use) => {
const page = await app.firstWindow()
await E2ETestHelper.runCommandPalette(page, "notifications: toggle do not disturb")
await E2ETestHelper.openClineSidebar(page)
await use(page)
},
})
.extend<{ sidebar: Frame }>({
sidebar: async ({ page, helper }, use) => {
sidebar: async ({ page, helper, server }, use) => {
await E2ETestHelper.openClineSidebar(page)
const sidebar = await helper.getSidebar(page)
await use(sidebar)
},
-22
View File
@@ -1,22 +0,0 @@
import { rmSync } from "node:fs"
import { getResultsDir } from "./helpers"
export default async function (): Promise<void> {
const path = getResultsDir()
const options = { recursive: true, force: true }
const maxAttempts = 2
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}`)
}
console.error(`Failed to rmSync ${path} after ${attempt} attempts: ${error}`)
await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) // Progressive delay
}
}
}
@@ -18,6 +18,11 @@ export function useDebouncedInput<T>(
// Local state to prevent jumpy input - initialize once
const [localValue, setLocalValue] = useState(initialValue)
// Update local value when initialValue changes (e.g., when component remounts with new data)
useEffect(() => {
setLocalValue(initialValue)
}, [initialValue])
// Debounced backend save - saves after user stops changing value
useDebounceEffect(
() => {