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