Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/storybook-playwright-snapshot.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
name: Storybook Playwright Snapshot

on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
should_run:
description: "Run the full Storybook Playwright snapshot workflow"
required: false
default: false
type: boolean

# Cancel in-progress jobs when new workflow is triggered
concurrency:
Expand All @@ -23,6 +31,13 @@ jobs:
storybook-playwright-snapshot:
runs-on: ubuntu-latest
timeout-minutes: 45
if: |
${{
inputs.should_run == true ||
contains(github.event.head_commit.message, '[storybook]') ||
contains(github.event.head_commit.message, '[chromatic]') ||
contains(github.event.head_commit.message, '[screnshot')
}}

steps:
- name: Checkout repository
Expand Down
72 changes: 72 additions & 0 deletions apps/playwright-e2e/helpers/dom-stability-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// kilocode_change - new file
import { type Page } from "@playwright/test"

/**
* Waits for DOM stability by injecting a MutationObserver into the page.
* Monitors all DOM changes and waits for 250ms of inactivity before resolving.
* Includes a 10-second timeout to prevent infinite waiting.
*
* @param page - The Playwright page instance
* @returns Promise that resolves when DOM is stable or timeout occurs
*/
export async function waitForDOMStability(page: Page): Promise<void> {
await page.evaluate(() => {
return new Promise<void>((resolve) => {
const DEBOUNCE_DELAY = 250 // ms
const MAX_TIMEOUT = 10000 // ms

let debounceTimer: NodeJS.Timeout | null = null
let timeoutTimer: NodeJS.Timeout | null = null
let observer: MutationObserver | null = null

const cleanup = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
if (observer) {
observer.disconnect()
observer = null
}
}

const resolveStability = () => {
cleanup()
resolve()
}

const resetDebounceTimer = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(resolveStability, DEBOUNCE_DELAY)
}

// Set up maximum timeout protection
timeoutTimer = setTimeout(() => {
console.log("DOM stability timeout reached (10s)")
resolveStability()
}, MAX_TIMEOUT)

// Create and configure MutationObserver
observer = new MutationObserver(() => {
resetDebounceTimer()
})

// Start observing all types of DOM changes
observer.observe(document.body, {
childList: true, // Node additions/removals
attributes: true, // Attribute changes
characterData: true, // Text content changes
subtree: true, // Monitor entire DOM tree
})

// Start initial debounce timer
resetDebounceTimer()
})
})
}
2 changes: 1 addition & 1 deletion apps/playwright-e2e/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ export * from "./webview-helpers"
export * from "./console-logging"
export * from "./test-setup-helpers"
export * from "./chat-helpers"
export * from "./notification-helpers"
export * from "./dom-stability-helpers"
export * from "./vscode-helpers"
47 changes: 0 additions & 47 deletions apps/playwright-e2e/helpers/notification-helpers.ts

This file was deleted.

10 changes: 7 additions & 3 deletions apps/playwright-e2e/helpers/test-setup-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// kilocode_change - new file
import { type Page } from "@playwright/test"
import { waitForWebviewText, configureApiKeyThroughUI } from "./webview-helpers"
import { verifyExtensionInstalled } from "./vscode-helpers"
import { waitForWebviewText, configureApiKeyThroughUI, waitForModelSelector } from "./webview-helpers"
import { verifyExtensionInstalled, waitForAllExtensionActivation } from "./vscode-helpers"

export async function setupTestEnvironment(page: Page): Promise<void> {
await waitForAllExtensionActivation(page)

await verifyExtensionInstalled(page)
await waitForWebviewText(page, "Welcome to Kilo Code!")

await configureApiKeyThroughUI(page)
await waitForWebviewText(page, "Generate, refactor, and debug code with AI assistance")

await waitForModelSelector(page)
}
35 changes: 28 additions & 7 deletions apps/playwright-e2e/helpers/vscode-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,24 @@ export async function closeAllTabs(page: Page): Promise<void> {

export async function waitForAllExtensionActivation(page: Page): Promise<void> {
try {
const activatingStatus = page.locator("text=Activating Extensions")
const activatingStatusCount = await activatingStatus.count()
if (activatingStatusCount > 0) {
console.log("⌛️ Waiting for `Activating Extensions` to go away...")
await activatingStatus.waitFor({ state: "hidden", timeout: 10000 })
console.log("⏳ Waiting for VSCode initialization to complete...")

// Wait for the status.progress element to disappear
const progressElement = page.locator("#status\\.progress")
const progressExists = (await progressElement.count()) > 0

if (progressExists) {
const ariaLabel = await progressElement.getAttribute("aria-label").catch(() => "Unknown")
console.log(`⌛️ Still initializing: ${ariaLabel}`)

await progressElement.waitFor({ state: "hidden", timeout: 10000 })
console.log("✅ VSCode initialization complete")
} else {
console.log("✅ VSCode initialization already complete")
}
} catch {
// noop
} catch (error) {
console.log("⚠️ Error waiting for VSCode initialization:", error)
// Don't throw - we don't want to fail tests if initialization check fails
}
}

Expand All @@ -50,3 +60,14 @@ export async function switchToTheme(page: Page, themeName: string): Promise<void
await page.keyboard.press("Enter")
await page.waitForTimeout(100)
}

export async function executeVSCodeCommand(page: Page, commandName: string): Promise<void> {
// Open command palette
await page.keyboard.press(`${modifier}+Shift+P`)
await page.waitForTimeout(100)

// Type the command name
await page.keyboard.type(commandName)
await page.keyboard.press("Enter")
await page.waitForTimeout(300)
}
57 changes: 57 additions & 0 deletions apps/playwright-e2e/helpers/webview-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export async function waitForWebviewText(page: Page, text: string, timeout: numb
await expect(webviewFrame.locator("body")).toContainText(text, { timeout })
}

export async function waitForModelSelector(page: Page, timeout: number = 30000): Promise<void> {
const webviewFrame = await findWebview(page)
await expect(webviewFrame.locator('[data-testid="model-selector"]')).toBeVisible({ timeout })
}

export async function postWebviewMessage(page: Page, message: WebviewMessage): Promise<void> {
const webviewFrame = await findWebview(page)

Expand Down Expand Up @@ -86,3 +91,55 @@ export async function configureApiKeyThroughUI(page: Page): Promise<void> {
await submitButton.click()
console.log("✅ Provider configured!")
}

export async function clickSaveSettingsButton(webviewFrame: FrameLocator): Promise<void> {
const saveButton = webviewFrame.locator('[data-testid="save-button"]')
const saveButtonExists = (await saveButton.count()) > 0
if (saveButtonExists) {
await saveButton.click({ force: true }) // Click it even its disabled
}
}

/**
* Freezes all GIFs on the page by converting them to static PNG images.
* Also sets up a MutationObserver to handle dynamically added GIFs.
* Works inside the VSCode extension webview iframe.
*/
export async function freezeGifs(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: "reduce" })

// Get the webview frame to work inside the extension iframe
const webviewFrame = await findWebview(page)

await webviewFrame.locator("body").evaluate(() => {
// Function to freeze a single GIF
const freezeGif = (img: HTMLImageElement) => {
if (!img.src.toLowerCase().includes(".gif")) return
if (img.dataset.gifFrozen === "true") return // Already processed

const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
if (!ctx) return

const frame = new Image()
frame.crossOrigin = "anonymous"
frame.onload = () => {
canvas.width = frame.naturalWidth || frame.width
canvas.height = frame.naturalHeight || frame.height
ctx.drawImage(frame, 0, 0)
img.src = canvas.toDataURL("image/png")
img.dataset.gifFrozen = "true"
}
frame.onerror = () => {
// Fallback: just mark as processed to avoid infinite loops
img.dataset.gifFrozen = "true"
}
frame.src = img.src
}

// Freeze existing GIFs in the webview
document.querySelectorAll('img[src*=".gif"]').forEach((img) => {
freezeGif(img as HTMLImageElement)
})
})
}
6 changes: 0 additions & 6 deletions apps/playwright-e2e/tests/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,13 @@ import {
verifyExtensionInstalled,
configureApiKeyThroughUI,
getChatInput,
closeAllToastNotifications,
} from "../helpers"

test.describe("E2E Chat Test", () => {
test("should configure credentials and send a message", async ({ workbox: page, takeScreenshot }: TestFixtures) => {
await verifyExtensionInstalled(page)
await waitForWebviewText(page, "Welcome to Kilo Code!")

await page.waitForTimeout(1000) // Let the page settle to avoid flakes

await closeAllToastNotifications(page)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notification closing is now built into takeScreenshot

await takeScreenshot("welcome")

await configureApiKeyThroughUI(page)
await waitForWebviewText(page, "Generate, refactor, and debug code with AI assistance")

Expand Down
6 changes: 4 additions & 2 deletions apps/playwright-e2e/tests/playwright-base-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as fs from "fs"
import { fileURLToPath } from "url"
import { camelCase } from "change-case"
import { setupConsoleLogging, cleanLogMessage } from "../helpers/console-logging"
import { waitForAllExtensionActivation, closeAllTabs } from "../helpers"
import { closeAllTabs, executeVSCodeCommand, freezeGifs, waitForDOMStability } from "../helpers"

// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
Expand Down Expand Up @@ -193,8 +193,10 @@ export const test = base.extend<TestFixtures>({

takeScreenshot: async ({ workbox: page }, use) => {
await use(async (name?: string) => {
await waitForAllExtensionActivation(page)
await closeAllTabs(page)
await executeVSCodeCommand(page, "Notifications:Clear")
await freezeGifs(page)
await waitForDOMStability(page)

// Extract test suite from the test file name or use a default
const testInfo = test.info()
Expand Down
9 changes: 4 additions & 5 deletions apps/playwright-e2e/tests/settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { test, expect, type TestFixtures } from "./playwright-base-test"
import { findWebview, upsertApiConfiguration, closeAllToastNotifications, verifyExtensionInstalled } from "../helpers"
import { findWebview, upsertApiConfiguration, verifyExtensionInstalled, clickSaveSettingsButton } from "../helpers"

test.describe("Settings", () => {
test("screenshots", async ({ workbox: page, takeScreenshot }: TestFixtures) => {
await verifyExtensionInstalled(page)
await upsertApiConfiguration(page)

// Open the settings then move the mouse to avoid triggering the tooltip
const webviewFrame = await findWebview(page)
page.locator('[aria-label*="Settings"], [title*="Settings"]').first().click()
await page.mouse.move(0, 0)
await page.mouse.click(0, 0)
await clickSaveSettingsButton(webviewFrame)

const webviewFrame = await findWebview(page)
await expect(webviewFrame.locator('[role="tablist"]')).toBeVisible({ timeout: 10000 })
console.log("✅ Settings view loaded")

Expand All @@ -35,7 +34,7 @@ test.describe("Settings", () => {
const testId = await tabButton.getAttribute("data-testid")
const sectionId = testId?.replace("tab-", "") || `section-${i}`

await closeAllToastNotifications(page)
await clickSaveSettingsButton(webviewFrame) // To avoid flakey screenshots
await takeScreenshot(`${i}-settings-${sectionId}-${tabName.toLowerCase().replace(/\s+/g, "-")}`)
}

Expand Down
2 changes: 1 addition & 1 deletion webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ExtensionStateContextProvider, useExtensionState } from "./context/Exte
import ChatView, { ChatViewRef } from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView"
import WelcomeView from "./components/kilocode/Welcome/WelcomeView" // kilocode_change
import WelcomeView from "./components/kilocode/welcome/WelcomeView" // kilocode_change
import ProfileView from "./components/kilocode/profile/ProfileView" // kilocode_change
import McpView from "./components/mcp/McpView"
import { MarketplaceView } from "./components/marketplace/MarketplaceView"
Expand Down
3 changes: 2 additions & 1 deletion webview-ui/src/components/kilocode/BottomApiConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export const BottomApiConfig = () => {

return (
<>
<div className="w-auto overflow-hidden">
{/* kilocode_change - add data-testid="model-selector" below */}
<div className="w-auto overflow-hidden" data-testid="model-selector">
<ModelSelector
currentApiConfigName={currentApiConfigName}
apiConfiguration={apiConfiguration}
Expand Down