diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 204d37ef..ed96a64e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,13 @@ jobs: run: npm run lint continue-on-error: true + - name: Webview Tests + id: webview_test + if: always() && steps.install_webview.outcome == 'success' + working-directory: webview-ui + run: npm test + continue-on-error: true + - name: Format Check id: format_check if: always() && steps.install_root.outcome == 'success' @@ -118,6 +125,7 @@ jobs: TYPE_CHECK: ${{ steps.type_check.outcome }} ROOT_LINT: ${{ steps.root_lint.outcome }} WEBVIEW_LINT: ${{ steps.webview_lint.outcome }} + WEBVIEW_TEST: ${{ steps.webview_test.outcome }} FORMAT_CHECK: ${{ steps.format_check.outcome }} BUILD: ${{ steps.build.outcome }} AUDIT_ROOT: ${{ steps.audit_root.outcome }} @@ -138,6 +146,7 @@ jobs: echo "| **Type check** | $(status "$TYPE_CHECK") |" echo "| **Root lint** | $(status "$ROOT_LINT") |" echo "| **Webview lint** | $(status "$WEBVIEW_LINT") |" + echo "| **Webview tests** | $(status "$WEBVIEW_TEST") |" echo "| **Format check** | $(status "$FORMAT_CHECK") |" echo "| **Build** | $(status "$BUILD") |" echo "| Audit root _(advisory)_ | $(status "$AUDIT_ROOT") |" @@ -156,13 +165,14 @@ jobs: TYPE_CHECK: ${{ steps.type_check.outcome }} ROOT_LINT: ${{ steps.root_lint.outcome }} WEBVIEW_LINT: ${{ steps.webview_lint.outcome }} + WEBVIEW_TEST: ${{ steps.webview_test.outcome }} FORMAT_CHECK: ${{ steps.format_check.outcome }} BUILD: ${{ steps.build.outcome }} run: | failed=0 for step in CHECKOUT SETUP_NODE INSTALL_ROOT INSTALL_WEBVIEW \ - TYPE_CHECK ROOT_LINT WEBVIEW_LINT FORMAT_CHECK \ - BUILD; do + TYPE_CHECK ROOT_LINT WEBVIEW_LINT \ + WEBVIEW_TEST FORMAT_CHECK BUILD; do eval "val=\$$step" if [ "$val" != "success" ]; then echo "::error::$step failed" @@ -170,3 +180,106 @@ jobs: fi done exit "$failed" + + e2e: + needs: ci + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + env: + PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: | + package-lock.json + webview-ui/package-lock.json + + - name: Restore VS Code Cache + id: cache_vscode_restore + uses: actions/cache/restore@v4 + with: + path: .vscode-test + key: vscode-test-${{ runner.os }}-${{ hashFiles('e2e/global-setup.ts') }}-v2 + restore-keys: | + vscode-test-${{ runner.os }}- + + - name: Restore Playwright Cache + id: cache_playwright_restore + uses: actions/cache/restore@v4 + with: + path: .playwright-browsers + key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-v1 + restore-keys: | + playwright-browsers-${{ runner.os }}- + + - name: Install Root Dependencies + run: npm ci + + - name: Install Webview Dependencies + working-directory: webview-ui + run: npm ci + + - name: Build + run: node esbuild.js + + - name: Build Webview + working-directory: webview-ui + run: npm run build + + - name: Install Playwright Dependencies + id: install_playwright_deps + run: npx playwright install --with-deps chromium + continue-on-error: true + + - name: E2E Tests + id: e2e_test + if: steps.install_playwright_deps.outcome == 'success' + run: npm run e2e + continue-on-error: true + + - name: Save VS Code Cache + if: always() && steps.cache_vscode_restore.outputs.cache-hit != 'true' && steps.e2e_test.outcome == 'success' && hashFiles('.vscode-test/vscode-executable.txt') != '' + uses: actions/cache/save@v4 + with: + path: .vscode-test + key: ${{ steps.cache_vscode_restore.outputs.cache-primary-key }} + + - name: Save Playwright Cache + if: always() && steps.cache_playwright_restore.outputs.cache-hit != 'true' && steps.install_playwright_deps.outcome == 'success' && hashFiles('.playwright-browsers/**') != '' + uses: actions/cache/save@v4 + with: + path: .playwright-browsers + key: ${{ steps.cache_playwright_restore.outputs.cache-primary-key }} + + - name: Write Step Summary + if: always() + shell: bash + env: + OS: ${{ matrix.os }} + INSTALL_PLAYWRIGHT_DEPS: ${{ steps.install_playwright_deps.outcome }} + E2E_TEST: ${{ steps.e2e_test.outcome }} + run: | + status() { + if [ "$1" = "success" ]; then echo "✅ PASS"; else echo "❌ FAIL"; fi + } + { + echo "## E2E Results ($OS)" + echo + echo "| Check | Result |" + echo "| --- | --- |" + echo "| Install Playwright deps | $(status "$INSTALL_PLAYWRIGHT_DEPS") |" + echo "| E2E tests | $(status "$E2E_TEST") |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index bed821cd..ee43fc0a 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -10,6 +10,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} steps: - uses: amannn/action-semantic-pull-request@v6 env: diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml index a44d0d7f..c24c208b 100644 --- a/.github/workflows/update-badges.yml +++ b/.github/workflows/update-badges.yml @@ -8,6 +8,7 @@ on: jobs: update-badges: runs-on: ubuntu-latest + if: ${{ github.repository == 'pablodelucca/pixel-agents' }} steps: - name: Fetch VS Code Marketplace stats diff --git a/.gitignore b/.gitignore index aacf3d32..0452a69f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ Thumbs.db .vscode-test/ /.idea +# E2E test artifacts +test-results/ +playwright-report/ + # Build artifacts *.vsix *.map diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a48084a..54fa623b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,49 @@ These conventions are enforced by custom ESLint rules (`eslint-rules/pixel-agent These rules are set to `warn` — they won't block your PR but will flag violations for cleanup. +## End-to-End Tests + +The `e2e/` directory contains Playwright tests that launch a real VS Code instance with the extension loaded in development mode. + +### Running e2e tests locally + +```bash +# Build the extension first (tests load the compiled output) +npm run build + +# Runs the e2e test +npm run e2e + +# Step-by-step debug mode +npm run e2e:debug +``` + +On the first run, `@vscode/test-electron` will download a stable VS Code release into `.vscode-test/` (≈200 MB). Subsequent runs reuse the cache. + +### Artifacts + +All test artifacts are written to `test-results/e2e/`: + +| Path | Contents | +|---|---| +| `test-results/e2e/videos//` | `.webm` screen recording for every test | +| `playwright-report/e2e/` | Playwright HTML report (`npx playwright show-report playwright-report/e2e`) | +| `test-results/e2e/*.png` | Final screenshots saved on failure | + +On failure, the test output prints the path to the video for that run. + +### Mock claude + +Tests never invoke the real `claude` CLI. Instead, a bash script at `e2e/fixtures/mock-claude` is copied into an isolated `bin/` directory and prepended to `PATH` before VS Code starts. + +The mock: +1. Parses `--session-id ` from its arguments. +2. Appends a line to `$HOME/.claude-mock/invocations.log` so tests can assert it was called. +3. Creates `$HOME/.claude/projects//.jsonl` with a minimal init line so the extension's file-watcher can detect the session. +4. Sleeps for 30 s (keeps the terminal alive) then exits. + +Each test runs with an isolated `HOME` and `--user-data-dir`, so no test state leaks between runs or into your real VS Code profile. + ## Submitting a Pull Request 1. Fork the repo and create a feature branch from `main` diff --git a/e2e/fixtures/mock-claude b/e2e/fixtures/mock-claude new file mode 100755 index 00000000..f8d6d4c7 --- /dev/null +++ b/e2e/fixtures/mock-claude @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Mock 'claude' executable for Pixel Agents e2e tests. +# +# Behaviour: +# 1. Parses --session-id from args. +# 2. Appends an invocation record to $HOME/.claude-mock/invocations.log. +# 3. Creates the expected JSONL file under $HOME/.claude/projects//.jsonl +# using the same path-hash algorithm as agentManager.ts +# (replace every non-[a-zA-Z0-9-] char with '-'). +# 4. Writes a minimal valid JSONL line so the extension file-watcher can proceed. +# 5. Stays alive for up to 30 s (tests can kill it once assertions pass). + +set -euo pipefail + +SESSION_ID="" +PREV="" +for arg in "$@"; do + if [ "$PREV" = "--session-id" ]; then + SESSION_ID="$arg" + fi + PREV="$arg" +done + +LOG_DIR="${HOME}/.claude-mock" +mkdir -p "$LOG_DIR" +echo "$(date -Iseconds) session-id=${SESSION_ID} cwd=$(pwd) args=$*" >> "${LOG_DIR}/invocations.log" + +if [ -n "$SESSION_ID" ]; then + CWD="$(pwd)" + # Replicate agentManager.ts: workspacePath.replace(/[^a-zA-Z0-9-]/g, '-') + DIR_NAME="$(printf '%s' "$CWD" | tr -c 'a-zA-Z0-9-' '-')" + PROJECT_DIR="${HOME}/.claude/projects/${DIR_NAME}" + mkdir -p "$PROJECT_DIR" + JSONL_FILE="${PROJECT_DIR}/${SESSION_ID}.jsonl" + + # Write a minimal system init line so the extension watcher sees the file. + printf '{"type":"system","subtype":"init","content":"mock-claude-ready"}\n' >> "$JSONL_FILE" +fi + +# Stay alive so the VS Code terminal doesn't immediately close. +sleep 30 & +SLEEP_PID=$! + +# Clean exit on SIGTERM/SIGINT. +trap 'kill $SLEEP_PID 2>/dev/null; exit 0' SIGTERM SIGINT + +wait $SLEEP_PID || true diff --git a/e2e/fixtures/mock-claude.cmd b/e2e/fixtures/mock-claude.cmd new file mode 100644 index 00000000..754a3634 --- /dev/null +++ b/e2e/fixtures/mock-claude.cmd @@ -0,0 +1,49 @@ +@echo off +REM Mock 'claude' executable for Pixel Agents e2e tests (Windows). +REM +REM Behaviour: +REM 1. Parses --session-id from args. +REM 2. Appends an invocation record to %HOME%\.claude-mock\invocations.log. +REM 3. Creates the expected JSONL file under %HOME%\.claude\projects\\.jsonl +REM 4. Stays alive for up to 30 s (tests can kill it once assertions pass). + +setlocal enabledelayedexpansion + +set "SESSION_ID=" +set "PREV=" + +:parse_args +if "%~1"=="" goto done_args +if "!PREV!"=="--session-id" set "SESSION_ID=%~1" +set "PREV=%~1" +shift +goto parse_args +:done_args + +REM Use HOME if set (our e2e sets it), fall back to USERPROFILE +if defined HOME ( + set "MOCK_HOME=%HOME%" +) else ( + set "MOCK_HOME=%USERPROFILE%" +) + +set "LOG_DIR=%MOCK_HOME%\.claude-mock" +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" +echo %DATE% %TIME% session-id=%SESSION_ID% cwd=%CD% args=%* >> "%LOG_DIR%\invocations.log" + +if "%SESSION_ID%"=="" goto stay_alive + +REM Replicate agentManager.ts: workspacePath.replace(/[^a-zA-Z0-9-]/g, '-') +REM PowerShell one-liner to do the regex replace +for /f "delims=" %%D in ('powershell -NoProfile -Command "[regex]::Replace('%CD%', '[^a-zA-Z0-9-]', '-')"') do set "DIR_NAME=%%D" + +set "PROJECT_DIR=%MOCK_HOME%\.claude\projects\%DIR_NAME%" +if not exist "%PROJECT_DIR%" mkdir "%PROJECT_DIR%" + +set "JSONL_FILE=%PROJECT_DIR%\%SESSION_ID%.jsonl" +echo {"type":"system","subtype":"init","content":"mock-claude-ready"} >> "%JSONL_FILE%" + +:stay_alive +REM Stay alive so the VS Code terminal doesn't immediately close. +REM Use ping to localhost as a cross-platform sleep (timeout command requires console). +ping -n 31 127.0.0.1 > nul 2>&1 diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..d71dcd7e --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,18 @@ +import { downloadAndUnzipVSCode } from '@vscode/test-electron'; +import fs from 'fs'; +import path from 'path'; + +export const VSCODE_CACHE_DIR = path.join(__dirname, '../.vscode-test'); +export const VSCODE_PATH_FILE = path.join(VSCODE_CACHE_DIR, 'vscode-executable.txt'); + +export default async function globalSetup(): Promise { + console.log('[e2e] Ensuring VS Code is downloaded...'); + const vscodePath = await downloadAndUnzipVSCode({ + version: 'stable', + cachePath: VSCODE_CACHE_DIR, + }); + console.log(`[e2e] VS Code executable: ${vscodePath}`); + + fs.mkdirSync(VSCODE_CACHE_DIR, { recursive: true }); + fs.writeFileSync(VSCODE_PATH_FILE, vscodePath, 'utf8'); +} diff --git a/e2e/helpers/launch.ts b/e2e/helpers/launch.ts new file mode 100644 index 00000000..809efa4e --- /dev/null +++ b/e2e/helpers/launch.ts @@ -0,0 +1,227 @@ +import { _electron as electron } from '@playwright/test'; +import type { ElectronApplication, Page } from '@playwright/test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const REPO_ROOT = path.join(__dirname, '../..'); +const VSCODE_PATH_FILE = path.join(REPO_ROOT, '.vscode-test/vscode-executable.txt'); +const MOCK_CLAUDE_PATH = path.join(REPO_ROOT, 'e2e/fixtures/mock-claude'); +const MOCK_CLAUDE_CMD_PATH = path.join(REPO_ROOT, 'e2e/fixtures/mock-claude.cmd'); +const ARTIFACTS_DIR = path.join(REPO_ROOT, 'test-results/e2e'); +const IS_WINDOWS = process.platform === 'win32'; +const PATH_SEP = IS_WINDOWS ? ';' : ':'; + +export interface VSCodeSession { + app: ElectronApplication; + window: Page; + /** Isolated HOME directory for this test session. */ + tmpHome: string; + /** Workspace directory opened in VS Code. */ + workspaceDir: string; + /** Path to the mock invocations log. */ + mockLogFile: string; + cleanup: () => Promise; +} + +/** + * Launch VS Code with the Pixel Agents extension loaded in development mode. + * + * Uses an isolated temp HOME and injects the mock `claude` binary at the + * front of PATH so no real Claude CLI is needed. + */ +export async function launchVSCode(testTitle: string): Promise { + const vscodePath = fs.readFileSync(VSCODE_PATH_FILE, 'utf8').trim(); + + // --- Isolated temp directories --- + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'pixel-e2e-')); + const tmpHome = path.join(tmpBase, 'home'); + const workspaceDir = path.join(tmpBase, 'workspace'); + const userDataDir = path.join(tmpBase, 'userdata'); + const mockBinDir = path.join(tmpBase, 'bin'); + + fs.mkdirSync(tmpHome, { recursive: true }); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(userDataDir, { recursive: true }); + fs.mkdirSync(mockBinDir, { recursive: true }); + + // On Windows, os.tmpdir() may return an 8.3 short path (e.g. RUNNER~1) while + // child processes see the long path (e.g. runneradmin) via %CD%. Normalize to + // the canonical long path so the project hash computed here matches mock-claude. + // fs.realpathSync only resolves symlinks; .native uses GetFinalPathNameByHandleW + // which also resolves 8.3 short names to their full form. + const resolvedWorkspaceDir = IS_WINDOWS ? fs.realpathSync.native(workspaceDir) : workspaceDir; + + // macOS: create a temporary keychain so the OS doesn't show "Keychain Not Found" dialog. + // The isolated HOME has no keychain, and VS Code/Electron's safeStorage triggers a system prompt. + if (process.platform === 'darwin') { + const keychainDir = path.join(tmpHome, 'Library', 'Keychains'); + fs.mkdirSync(keychainDir, { recursive: true }); + const keychainPath = path.join(keychainDir, 'login.keychain-db'); + try { + const { execSync } = require('child_process'); + execSync(`security create-keychain -p "" "${keychainPath}"`, { stdio: 'ignore' }); + execSync(`security default-keychain -s "${keychainPath}"`, { + stdio: 'ignore', + env: { ...process.env, HOME: tmpHome }, + }); + } catch { + // keychain creation failure is non-fatal, test may still work + } + } + + // Copy mock-claude into an isolated bin dir + if (IS_WINDOWS) { + // Windows: copy the .cmd batch file as 'claude.cmd' + fs.copyFileSync(MOCK_CLAUDE_CMD_PATH, path.join(mockBinDir, 'claude.cmd')); + } else { + const mockDest = path.join(mockBinDir, 'claude'); + fs.copyFileSync(MOCK_CLAUDE_PATH, mockDest); + fs.chmodSync(mockDest, 0o755); + } + + // macOS: VS Code's integrated terminal resolves PATH from the login shell, + // ignoring the process env. Define a custom terminal profile that uses a + // non-login shell with our mock bin dir in PATH. On Linux the process env + // propagates directly, so no custom profile is needed. + if (process.platform === 'darwin') { + const userSettingsDir = path.join(userDataDir, 'User'); + fs.mkdirSync(userSettingsDir, { recursive: true }); + fs.writeFileSync( + path.join(userSettingsDir, 'settings.json'), + JSON.stringify( + { + 'terminal.integrated.profiles.osx': { + e2e: { + path: '/bin/zsh', + args: ['--no-globalrcs'], + env: { + PATH: `${mockBinDir}:/usr/local/bin:/usr/bin:/bin`, + HOME: tmpHome, + ZDOTDIR: tmpHome, + }, + }, + }, + 'terminal.integrated.defaultProfile.osx': 'e2e', + 'terminal.integrated.inheritEnv': false, + }, + null, + 2, + ), + ); + } + + const mockLogFile = path.join(tmpHome, '.claude-mock', 'invocations.log'); + + // --- Video output dir --- + const safeTitle = testTitle.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); + const videoDir = path.join(ARTIFACTS_DIR, 'videos', safeTitle); + fs.mkdirSync(videoDir, { recursive: true }); + + // --- Environment for VS Code process --- + const env: Record = { + ...(process.env as Record), + HOME: tmpHome, + // Prepend mock bin so 'claude' resolves to our mock + PATH: `${mockBinDir}${PATH_SEP}${process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin'}`, + // Prevent VS Code from trying to talk to real accounts / telemetry + VSCODE_TELEMETRY_DISABLED: '1', + }; + + // --- VS Code launch args --- + const args = [ + // Load our extension in dev mode (this overrides the installed version) + `--extensionDevelopmentPath=${REPO_ROOT}`, + // Disable all other extensions so tests are isolated + '--disable-extensions', + // Isolated user-data (settings, state, etc.) + `--user-data-dir=${userDataDir}`, + // Skip interactive prompts + '--disable-workspace-trust', + '--skip-release-notes', + '--skip-welcome', + '--no-sandbox', + // Disable GPU acceleration: prevents Electron GPU-sandbox stalls in headless + // CI environments (required on macOS arm64 runners, harmless elsewhere). + '--disable-gpu', + // On Linux, use the Ozone headless platform so Electron runs without a + // display server (equivalent to what --disable-gpu achieves on macOS/Windows). + ...(process.platform === 'linux' ? ['--ozone-platform=headless'] : []), + // Open the workspace folder + resolvedWorkspaceDir, + ]; + + const cleanup = async (): Promise => { + try { + if (app) { + await app.close(); + } + } catch { + // ignore close errors + } + // macOS: deregister the temporary keychain to avoid orphaned references + if (process.platform === 'darwin') { + try { + const keychainPath = path.join(tmpHome, 'Library', 'Keychains', 'login.keychain-db'); + const { execSync } = require('child_process'); + execSync(`security delete-keychain "${keychainPath}"`, { stdio: 'ignore' }); + } catch { + // keychain may not exist or already be removed + } + } + try { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }; + + let app: ElectronApplication | undefined; + + try { + // Playwright's video recording freezes VS Code's renderer on Windows, + // so only enable it on non-Windows platforms. + const launchOptions: Parameters[0] = { + executablePath: vscodePath, + args, + env, + cwd: resolvedWorkspaceDir, + timeout: 60_000, + }; + if (!IS_WINDOWS) { + launchOptions.recordVideo = { + dir: videoDir, + size: { width: 1280, height: 800 }, + }; + } + + app = await electron.launch(launchOptions); + + const window = await app.firstWindow(); + + // The Ozone headless backend ignores --window-size CLI flags, so VS Code + // opens at a tiny default size on Linux. Resize via the Electron API after + // the window exists — getAllWindows() is empty before firstWindow() resolves. + if (process.platform === 'linux') { + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.setSize(1280, 800); + }); + // Give VS Code's layout system time to respond to the resize before tests + // start measuring panel heights. + await window.waitForTimeout(500); + } + + return { app, window, tmpHome, workspaceDir: resolvedWorkspaceDir, mockLogFile, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +/** + * Wait for VS Code's workbench to be fully ready before interacting. + */ +export async function waitForWorkbench(window: Page): Promise { + // VS Code renders a div.monaco-workbench when the shell is ready + await window.waitForSelector('.monaco-workbench', { timeout: 60_000 }); +} diff --git a/e2e/helpers/webview.ts b/e2e/helpers/webview.ts new file mode 100644 index 00000000..8c964861 --- /dev/null +++ b/e2e/helpers/webview.ts @@ -0,0 +1,128 @@ +import type { Frame, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +const WEBVIEW_TIMEOUT_MS = 30_000; +const PANEL_OPEN_TIMEOUT_MS = 15_000; +const MIN_PANEL_HEIGHT_PX = 320; + +async function runCommand(window: Page, command: string): Promise { + // Retry the full command palette interaction up to 3 times. + // macOS CI can swallow keypresses or fail to populate results. + for (let attempt = 0; attempt < 3; attempt++) { + // Dismiss any previous quick-input state + await window.keyboard.press('Escape'); + await window.waitForTimeout(300); + + try { + await window.keyboard.press('F1'); + await window.waitForSelector('.quick-input-widget .quick-input-filter input', { + state: 'visible', + timeout: 5_000, + }); + await window.keyboard.type(command); + // Wait for a list row matching the typed command (not stale results) + await window.waitForSelector(`.quick-input-list .monaco-list-row[aria-label*="${command}"]`, { + timeout: 5_000, + }); + break; + } catch { + if (attempt === 2) { + throw new Error(`Command palette failed after 3 attempts for "${command}"`); + } + } + } + await window.keyboard.press('Enter'); + await window + .waitForSelector('.quick-input-widget', { + state: 'hidden', + timeout: PANEL_OPEN_TIMEOUT_MS, + }) + .catch(() => { + // Some commands update layout without immediately dismissing quick input. + }); +} + +async function getPanelHeight(window: Page): Promise { + return window.evaluate(() => { + const panel = + document.querySelector('[id="workbench.panel.bottom"]') ?? + document.querySelector('.part.panel'); + + return Math.round(panel?.getBoundingClientRect().height ?? 0); + }); +} + +async function ensurePanelIsLarge(window: Page): Promise { + if ((await getPanelHeight(window)) > MIN_PANEL_HEIGHT_PX) { + return; + } + + await runCommand(window, 'View: Toggle Maximized Panel'); + + await expect + .poll(() => getPanelHeight(window), { + message: 'Expected the bottom panel to be resized for the Pixel Agents webview', + timeout: PANEL_OPEN_TIMEOUT_MS, + intervals: [250, 500, 1000], + }) + .toBeGreaterThan(MIN_PANEL_HEIGHT_PX); +} + +/** + * Open the Pixel Agents panel via the Command Palette and wait for the + * "Pixel Agents: Show Panel" command to execute. + */ +export async function openPixelAgentsPanel(window: Page): Promise { + await runCommand(window, 'Pixel Agents: Show Panel'); + + // Wait for the panel container to appear + await window + .waitForSelector('[id="workbench.panel.bottom"], .part.panel', { + timeout: PANEL_OPEN_TIMEOUT_MS, + }) + .catch(() => { + // Panel might not use this id; just continue + }); + + await ensurePanelIsLarge(window); +} + +/** + * Find and return the Pixel Agents webview frame. + * + * VS Code renders WebviewViewProvider content in an