mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Add CLI/vscode integration (#9085)
Co-authored-by: OpenHands-Gemini <openhands@all-hands.dev> Co-authored-by: Claude 3.5 Sonnet <claude-3-5-sonnet@anthropic.com>
This commit is contained in:
parent
ece556c047
commit
ef502ccba8
2
.github/workflows/ghcr-build.yml
vendored
2
.github/workflows/ghcr-build.yml
vendored
@ -54,6 +54,7 @@ jobs:
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@ -103,6 +104,7 @@ jobs:
|
||||
ghcr_build_runtime:
|
||||
name: Build Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
@ -0,0 +1,156 @@
|
||||
# Workflow that validates the VSCode extension builds correctly
|
||||
name: VSCode Extension CI
|
||||
|
||||
# * Always run on "main"
|
||||
# * Run on PRs that have changes in the VSCode extension folder or this workflow
|
||||
# * Run on tags that start with "ext-v"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'ext-v*'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openhands/integrations/vscode/**'
|
||||
- 'build_vscode.py'
|
||||
- '.github/workflows/vscode-extension-build.yml'
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Validate VSCode extension builds correctly
|
||||
validate-vscode-extension:
|
||||
name: Validate VSCode Extension Build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install VSCode extension dependencies
|
||||
working-directory: ./openhands/integrations/vscode
|
||||
run: npm ci
|
||||
|
||||
- name: Build VSCode extension via build_vscode.py
|
||||
run: python build_vscode.py
|
||||
env:
|
||||
# Ensure we don't skip the build
|
||||
SKIP_VSCODE_BUILD: ""
|
||||
|
||||
- name: Validate .vsix file
|
||||
run: |
|
||||
# Verify the .vsix was created and is valid
|
||||
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
|
||||
echo "✅ VSCode extension built successfully"
|
||||
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
# Basic validation that the .vsix is a valid zip file
|
||||
echo "🔍 Validating .vsix structure..."
|
||||
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
echo "✅ VSCode extension validation passed"
|
||||
else
|
||||
echo "❌ VSCode extension build failed - .vsix not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload VSCode extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment on PR with artifact link
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Get file size for display
|
||||
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
|
||||
const stats = fs.statSync(vsixPath);
|
||||
const fileSizeKB = Math.round(stats.size / 1024);
|
||||
|
||||
const comment = `## 🔧 VSCode Extension Built Successfully!
|
||||
|
||||
The VSCode extension has been built and is ready for testing.
|
||||
|
||||
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
|
||||
|
||||
**🚀 To install**:
|
||||
1. Download the artifact from the workflow run above
|
||||
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
|
||||
3. Select the downloaded \`.vsix\` file
|
||||
|
||||
**✅ Tested with**: Node.js 22
|
||||
**🔍 Validation**: File structure and integrity verified
|
||||
|
||||
---
|
||||
*Built from commit ${{ github.sha }}*`;
|
||||
|
||||
// Check if we already commented on this PR and delete it
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('VSCode Extension Built Successfully')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: validate-vscode-extension
|
||||
if: startsWith(github.ref, 'refs/tags/ext-v')
|
||||
|
||||
steps:
|
||||
- name: Download .vsix artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: ./
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1.16.0
|
||||
with:
|
||||
artifacts: "*.vsix"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@ -15,10 +15,13 @@ make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.
|
||||
|
||||
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
|
||||
|
||||
|
||||
|
||||
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
|
||||
|
||||
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
|
||||
|
||||
@ -60,6 +63,22 @@ Frontend:
|
||||
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
|
||||
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
|
||||
|
||||
VSCode Extension:
|
||||
- Located in the `openhands/integrations/vscode` directory
|
||||
- Setup: Run `npm install` in the extension directory
|
||||
- Linting:
|
||||
- Run linting with fixes: `npm run lint:fix`
|
||||
- Check only: `npm run lint`
|
||||
- Type checking: `npm run typecheck`
|
||||
- Building:
|
||||
- Compile TypeScript: `npm run compile`
|
||||
- Package extension: `npm run package-vsix`
|
||||
- Testing:
|
||||
- Run tests: `npm run test`
|
||||
- Development Best Practices:
|
||||
- Use `vscode.window.createOutputChannel()` for debug logging instead of `showErrorMessage()` popups
|
||||
- Pre-commit process runs both frontend and backend checks when committing extension changes
|
||||
|
||||
## Template for Github Pull Request
|
||||
|
||||
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
|
||||
|
||||
114
build_vscode.py
Normal file
114
build_vscode.py
Normal file
@ -0,0 +1,114 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
# This script is intended to be run by Poetry during the build process.
|
||||
|
||||
# Define the expected name of the .vsix file based on the extension's package.json
|
||||
# This should match the name and version in openhands-vscode/package.json
|
||||
EXTENSION_NAME = 'openhands-vscode'
|
||||
EXTENSION_VERSION = '0.0.1'
|
||||
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
|
||||
|
||||
# Paths
|
||||
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
|
||||
|
||||
|
||||
def check_node_version():
|
||||
"""Check if Node.js version is sufficient for building the extension."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['node', '--version'], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Extract major version number (e.g., "v12.22.9" -> 12)
|
||||
major_version = int(version_str.lstrip('v').split('.')[0])
|
||||
return major_version >= 18 # Align with frontend actual usage (18.20.1)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def build_vscode_extension():
|
||||
"""Builds the VS Code extension."""
|
||||
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
|
||||
|
||||
# Check if VSCode extension build is disabled via environment variable
|
||||
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
|
||||
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
|
||||
if vsix_path.exists():
|
||||
print(f'--- Using existing VS Code extension: {vsix_path} ---')
|
||||
else:
|
||||
print('--- No pre-built VS Code extension found ---')
|
||||
return
|
||||
|
||||
# Check Node.js version - if insufficient, use pre-built extension as fallback
|
||||
if not check_node_version():
|
||||
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
|
||||
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
|
||||
print('--- Using pre-built extension if available ---')
|
||||
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
else:
|
||||
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
|
||||
return
|
||||
|
||||
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
|
||||
|
||||
try:
|
||||
# Ensure npm dependencies are installed
|
||||
print('--- Running npm install for VS Code extension ---')
|
||||
subprocess.run(
|
||||
['npm', 'install'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Package the extension
|
||||
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
|
||||
subprocess.run(
|
||||
['npm', 'run', 'package-vsix'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Verify the generated .vsix file exists
|
||||
if not vsix_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f'VS Code extension package not found after build: {vsix_path}'
|
||||
)
|
||||
|
||||
print(f'--- VS Code extension built successfully: {vsix_path} ---')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
|
||||
print('--- Continuing without building extension ---')
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
|
||||
|
||||
def build(setup_kwargs):
|
||||
"""
|
||||
This function is called by Poetry during the build process.
|
||||
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
|
||||
"""
|
||||
print('--- Running custom Poetry build script (build_vscode.py) ---')
|
||||
|
||||
# Build the VS Code extension and place the .vsix file
|
||||
build_vscode_extension()
|
||||
|
||||
# Poetry will handle including files based on pyproject.toml `include` patterns.
|
||||
# Ensure openhands/integrations/vscode/*.vsix is included there.
|
||||
|
||||
print('--- Custom Poetry build script (build_vscode.py) finished ---')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Running build_vscode.py directly for testing VS Code extension packaging...')
|
||||
build_vscode_extension()
|
||||
print('Direct execution of build_vscode.py finished.')
|
||||
@ -32,6 +32,7 @@ from openhands.cli.tui import (
|
||||
from openhands.cli.utils import (
|
||||
update_usage_metrics,
|
||||
)
|
||||
from openhands.cli.vscode_extension import attempt_vscode_extension_install
|
||||
from openhands.controller import AgentController
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import (
|
||||
@ -368,6 +369,9 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
# Load config from toml and override with command line arguments
|
||||
config: OpenHandsConfig = setup_config_from_args(args)
|
||||
|
||||
# Attempt to install VS Code extension if applicable (one-time attempt)
|
||||
attempt_vscode_extension_install()
|
||||
|
||||
# Load settings from Settings Store
|
||||
# TODO: Make this generic?
|
||||
settings_store = await FileSettingsStore.get_instance(config=config, user_id=None)
|
||||
|
||||
318
openhands/cli/vscode_extension.py
Normal file
318
openhands/cli/vscode_extension.py
Normal file
@ -0,0 +1,318 @@
|
||||
import importlib.resources
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from urllib.error import URLError
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def download_latest_vsix_from_github() -> str | None:
|
||||
"""Download latest .vsix from GitHub releases.
|
||||
|
||||
Returns:
|
||||
Path to downloaded .vsix file, or None if failed
|
||||
"""
|
||||
api_url = 'https://api.github.com/repos/All-Hands-AI/OpenHands/releases'
|
||||
try:
|
||||
with urllib.request.urlopen(api_url, timeout=10) as response:
|
||||
if response.status != 200:
|
||||
logger.debug(
|
||||
f'GitHub API request failed with status: {response.status}'
|
||||
)
|
||||
return None
|
||||
releases = json.loads(response.read().decode())
|
||||
# The GitHub API returns releases in reverse chronological order (newest first).
|
||||
# We iterate through them and use the first one that matches our extension prefix.
|
||||
for release in releases:
|
||||
if release.get('tag_name', '').startswith('ext-v'):
|
||||
for asset in release.get('assets', []):
|
||||
if asset.get('name', '').endswith('.vsix'):
|
||||
download_url = asset.get('browser_download_url')
|
||||
if not download_url:
|
||||
continue
|
||||
with urllib.request.urlopen(
|
||||
download_url, timeout=30
|
||||
) as download_response:
|
||||
if download_response.status != 200:
|
||||
logger.debug(
|
||||
f'Failed to download .vsix with status: {download_response.status}'
|
||||
)
|
||||
continue
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix='.vsix'
|
||||
) as tmp_file:
|
||||
tmp_file.write(download_response.read())
|
||||
return tmp_file.name
|
||||
# Found the latest extension release but no .vsix asset
|
||||
return None
|
||||
except (URLError, TimeoutError, json.JSONDecodeError) as e:
|
||||
logger.debug(f'Failed to download from GitHub releases: {e}')
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def attempt_vscode_extension_install():
|
||||
"""
|
||||
Checks if running in a supported editor and attempts to install the OpenHands companion extension.
|
||||
This is a best-effort, one-time attempt.
|
||||
"""
|
||||
# 1. Check if we are in a supported editor environment
|
||||
is_vscode_like = os.environ.get('TERM_PROGRAM') == 'vscode'
|
||||
is_windsurf = (
|
||||
os.environ.get('__CFBundleIdentifier') == 'com.exafunction.windsurf'
|
||||
or 'windsurf' in os.environ.get('PATH', '').lower()
|
||||
or any(
|
||||
'windsurf' in val.lower()
|
||||
for val in os.environ.values()
|
||||
if isinstance(val, str)
|
||||
)
|
||||
)
|
||||
if not (is_vscode_like or is_windsurf):
|
||||
return
|
||||
|
||||
# 2. Determine editor-specific commands and flags
|
||||
if is_windsurf:
|
||||
editor_command, editor_name, flag_suffix = 'surf', 'Windsurf', 'windsurf'
|
||||
else:
|
||||
editor_command, editor_name, flag_suffix = 'code', 'VS Code', 'vscode'
|
||||
|
||||
# 3. Check if we've already successfully installed the extension.
|
||||
flag_dir = pathlib.Path.home() / '.openhands'
|
||||
flag_file = flag_dir / f'.{flag_suffix}_extension_installed'
|
||||
extension_id = 'openhands.openhands-vscode'
|
||||
|
||||
try:
|
||||
flag_dir.mkdir(parents=True, exist_ok=True)
|
||||
if flag_file.exists():
|
||||
return # Already successfully installed, exit.
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
f'Could not create or check {editor_name} extension flag directory: {e}'
|
||||
)
|
||||
return # Don't proceed if we can't manage the flag.
|
||||
|
||||
# 4. Check if the extension is already installed (even without our flag).
|
||||
if _is_extension_installed(editor_command, extension_id):
|
||||
print(f'INFO: OpenHands {editor_name} extension is already installed.')
|
||||
# Create flag to avoid future checks
|
||||
_mark_installation_successful(flag_file, editor_name)
|
||||
return
|
||||
|
||||
# 5. Extension is not installed, attempt installation.
|
||||
print(
|
||||
f'INFO: First-time setup: attempting to install the OpenHands {editor_name} extension...'
|
||||
)
|
||||
|
||||
# Attempt 1: Download from GitHub Releases (the new primary method)
|
||||
if _attempt_github_install(editor_command, editor_name):
|
||||
_mark_installation_successful(flag_file, editor_name)
|
||||
return # Success! We are done.
|
||||
|
||||
# Attempt 2: Install from bundled .vsix
|
||||
if _attempt_bundled_install(editor_command, editor_name):
|
||||
_mark_installation_successful(flag_file, editor_name)
|
||||
return # Success! We are done.
|
||||
|
||||
# TODO: Attempt 3: Install from Marketplace (when extension is published)
|
||||
# if _attempt_marketplace_install(editor_command, editor_name, extension_id):
|
||||
# _mark_installation_successful(flag_file, editor_name)
|
||||
# return # Success! We are done.
|
||||
|
||||
# If all attempts failed, inform the user (but don't create flag - allow retry).
|
||||
print(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
print(
|
||||
f'INFO: Will retry installation next time you run OpenHands in {editor_name}.'
|
||||
)
|
||||
|
||||
|
||||
def _mark_installation_successful(flag_file: pathlib.Path, editor_name: str) -> None:
|
||||
"""
|
||||
Mark the extension installation as successful by creating the flag file.
|
||||
|
||||
Args:
|
||||
flag_file: Path to the flag file to create
|
||||
editor_name: Human-readable name of the editor for logging
|
||||
"""
|
||||
try:
|
||||
flag_file.touch()
|
||||
logger.debug(f'{editor_name} extension installation marked as successful.')
|
||||
except OSError as e:
|
||||
logger.debug(f'Could not create {editor_name} extension success flag file: {e}')
|
||||
|
||||
|
||||
def _is_extension_installed(editor_command: str, extension_id: str) -> bool:
|
||||
"""
|
||||
Check if the OpenHands extension is already installed.
|
||||
|
||||
Args:
|
||||
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
|
||||
extension_id: The extension ID to check for
|
||||
|
||||
Returns:
|
||||
bool: True if extension is already installed, False otherwise
|
||||
"""
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[editor_command, '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if process.returncode == 0:
|
||||
installed_extensions = process.stdout.strip().split('\n')
|
||||
return extension_id in installed_extensions
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not check installed extensions: {e}')
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _attempt_github_install(editor_command: str, editor_name: str) -> bool:
|
||||
"""
|
||||
Attempt to install the extension from GitHub Releases.
|
||||
|
||||
Downloads the latest VSIX file from GitHub releases and attempts to install it.
|
||||
Ensures proper cleanup of temporary files.
|
||||
|
||||
Args:
|
||||
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
|
||||
editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
|
||||
|
||||
Returns:
|
||||
bool: True if installation succeeded, False otherwise
|
||||
"""
|
||||
vsix_path_from_github = download_latest_vsix_from_github()
|
||||
if not vsix_path_from_github:
|
||||
return False
|
||||
|
||||
github_success = False
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[
|
||||
editor_command,
|
||||
'--install-extension',
|
||||
vsix_path_from_github,
|
||||
'--force',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if process.returncode == 0:
|
||||
print(
|
||||
f'INFO: OpenHands {editor_name} extension installed successfully from GitHub.'
|
||||
)
|
||||
github_success = True
|
||||
else:
|
||||
logger.debug(
|
||||
f'Failed to install .vsix from GitHub: {process.stderr.strip()}'
|
||||
)
|
||||
finally:
|
||||
# Clean up the downloaded file
|
||||
if os.path.exists(vsix_path_from_github):
|
||||
try:
|
||||
os.remove(vsix_path_from_github)
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
f'Failed to delete temporary file {vsix_path_from_github}: {e}'
|
||||
)
|
||||
|
||||
return github_success
|
||||
|
||||
|
||||
def _attempt_bundled_install(editor_command: str, editor_name: str) -> bool:
|
||||
"""
|
||||
Attempt to install the extension from the bundled VSIX file.
|
||||
|
||||
Uses the VSIX file packaged with the OpenHands installation.
|
||||
|
||||
Args:
|
||||
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
|
||||
editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
|
||||
|
||||
Returns:
|
||||
bool: True if installation succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
vsix_filename = 'openhands-vscode-0.0.1.vsix'
|
||||
with importlib.resources.as_file(
|
||||
importlib.resources.files('openhands').joinpath(
|
||||
'integrations', 'vscode', vsix_filename
|
||||
)
|
||||
) as vsix_path:
|
||||
if vsix_path.exists():
|
||||
process = subprocess.run(
|
||||
[
|
||||
editor_command,
|
||||
'--install-extension',
|
||||
str(vsix_path),
|
||||
'--force',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if process.returncode == 0:
|
||||
print(
|
||||
f'INFO: Bundled {editor_name} extension installed successfully.'
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.debug(
|
||||
f'Bundled .vsix installation failed: {process.stderr.strip()}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not locate bundled .vsix: {e}.')
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _attempt_marketplace_install(
|
||||
editor_command: str, editor_name: str, extension_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Attempt to install the extension from the marketplace.
|
||||
|
||||
This method is currently unused as the OpenHands extension is not yet published
|
||||
to the VS Code/Windsurf marketplace. It's kept here for future use when the
|
||||
extension becomes available.
|
||||
|
||||
Args:
|
||||
editor_command: The command to use ('code' or 'surf')
|
||||
editor_name: Human-readable editor name ('VS Code' or 'Windsurf')
|
||||
extension_id: The extension ID to install
|
||||
|
||||
Returns:
|
||||
True if installation succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[editor_command, '--install-extension', extension_id, '--force'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if process.returncode == 0:
|
||||
print(
|
||||
f'INFO: {editor_name} extension installed successfully from the Marketplace.'
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.debug(f'Marketplace installation failed: {process.stderr.strip()}')
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f"INFO: To complete {editor_name} integration, please ensure the '{editor_command}' command-line tool is in your PATH."
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f'An unexpected error occurred trying to install from the Marketplace: {e}'
|
||||
)
|
||||
return False
|
||||
4
openhands/integrations/vscode/.eslintignore
Normal file
4
openhands/integrations/vscode/.eslintignore
Normal file
@ -0,0 +1,4 @@
|
||||
out/
|
||||
node_modules/
|
||||
.vscode-test/
|
||||
*.vsix
|
||||
68
openhands/integrations/vscode/.eslintrc.json
Normal file
68
openhands/integrations/vscode/.eslintrc.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json",
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"extends": [
|
||||
"airbnb-base",
|
||||
"airbnb-typescript/base",
|
||||
"prettier",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"rules": {
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
"": "never",
|
||||
"ts": "never"
|
||||
}
|
||||
],
|
||||
// Allow state modification in reduce and similar patterns
|
||||
"no-param-reassign": [
|
||||
"error",
|
||||
{
|
||||
"props": true,
|
||||
"ignorePropertyModificationsFor": ["acc", "state"]
|
||||
}
|
||||
],
|
||||
// For https://stackoverflow.com/questions/55844608/stuck-with-eslint-error-i-e-separately-loops-should-be-avoided-in-favor-of-arra
|
||||
"no-restricted-syntax": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
// VSCode extension specific - allow console for debugging
|
||||
"no-console": "warn",
|
||||
// Allow leading underscores for private variables in VSCode extensions
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"selector": "variable",
|
||||
"format": ["camelCase", "PascalCase", "UPPER_CASE"],
|
||||
"leadingUnderscore": "allow"
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/test/**/*.ts"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-shadow": "off",
|
||||
"consistent-return": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
18
openhands/integrations/vscode/.gitignore
vendored
Normal file
18
openhands/integrations/vscode/.gitignore
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Compiled TypeScript output
|
||||
out/
|
||||
|
||||
# VS Code Extension packaging
|
||||
*.vsix
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
|
||||
# Test run output (if any specific folders are generated)
|
||||
.vscode-test/
|
||||
|
||||
# OS-generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
3
openhands/integrations/vscode/.prettierrc.json
Normal file
3
openhands/integrations/vscode/.prettierrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"trailingComma": "all"
|
||||
}
|
||||
11
openhands/integrations/vscode/.vscodeignore
Normal file
11
openhands/integrations/vscode/.vscodeignore
Normal file
@ -0,0 +1,11 @@
|
||||
.vscodeignore
|
||||
.gitignore
|
||||
*.vsix
|
||||
node_modules/
|
||||
out/src/ # We only need out/extension.js and out/extension.js.map
|
||||
src/
|
||||
*.tsbuildinfo
|
||||
tsconfig.json
|
||||
PLAN.md
|
||||
README.md
|
||||
# Add other files/folders to ignore during packaging if needed
|
||||
97
openhands/integrations/vscode/DEVELOPMENT.md
Normal file
97
openhands/integrations/vscode/DEVELOPMENT.md
Normal file
@ -0,0 +1,97 @@
|
||||
# VSCode Extension Development
|
||||
|
||||
This document provides instructions for developing and contributing to the OpenHands VSCode extension.
|
||||
|
||||
## Setup
|
||||
|
||||
To get started with development, you need to install the dependencies.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Building the Extension
|
||||
|
||||
The VSCode extension is automatically built during the main OpenHands `pip install` process. However, you can also build it manually.
|
||||
|
||||
- **Package the extension:** This creates a `.vsix` file that can be installed in VSCode.
|
||||
```bash
|
||||
npm run package-vsix
|
||||
```
|
||||
|
||||
- **Compile TypeScript:** This compiles the source code without creating a package.
|
||||
```bash
|
||||
npm run compile
|
||||
```
|
||||
|
||||
## Code Quality and Testing
|
||||
|
||||
We use ESLint, Prettier, and TypeScript for code quality.
|
||||
|
||||
- **Run linting with auto-fixes:**
|
||||
```bash
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
- **Run type checking:**
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
- **Run tests:**
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Releasing a New Version
|
||||
|
||||
The extension has its own version number and is released independently of the main OpenHands application. The release process is automated via the `vscode-extension-build.yml` GitHub Actions workflow and is triggered by pushing a specially formatted Git tag.
|
||||
|
||||
### 1. Update the Version Number
|
||||
|
||||
Before creating a release, you must first bump the version number in the extension's `package.json` file.
|
||||
|
||||
1. Open `openhands/integrations/vscode/package.json`.
|
||||
2. Find the `"version"` field and update it according to [Semantic Versioning](https://semver.org/) (e.g., from `"0.0.1"` to `"0.0.2"`).
|
||||
|
||||
### 2. Commit the Version Bump
|
||||
|
||||
Commit the change to `package.json` with a clear commit message.
|
||||
|
||||
```bash
|
||||
git add openhands/integrations/vscode/package.json
|
||||
git commit -m "chore(vscode): bump version to 0.0.2"
|
||||
```
|
||||
|
||||
### 3. Create and Push the Tag
|
||||
|
||||
The release is triggered by a Git tag that **must** match the version in `package.json` and be prefixed with `ext-v`.
|
||||
|
||||
1. **Create an annotated tag.** The tag name must be `ext-v` followed by the version number you just set.
|
||||
```bash
|
||||
# Example for version 0.0.2
|
||||
git tag -a ext-v0.0.2 -m "Release VSCode extension v0.0.2"
|
||||
```
|
||||
|
||||
2. **Push the commit and the tag** to the `upstream` remote.
|
||||
```bash
|
||||
# Push the branch with the version bump commit
|
||||
git push upstream <your-branch-name>
|
||||
|
||||
# Push the specific tag
|
||||
git push upstream ext-v0.0.2
|
||||
```
|
||||
|
||||
### 4. Finalize the Release on GitHub
|
||||
|
||||
Pushing the tag will automatically trigger the `VSCode Extension CI` workflow. This workflow will:
|
||||
1. Build the `.vsix` file.
|
||||
2. Create a new **draft release** on GitHub with the `.vsix` file attached as an asset.
|
||||
|
||||
To finalize the release:
|
||||
1. Go to the "Releases" page of the OpenHands repository on GitHub.
|
||||
2. Find the new draft release (e.g., `ext-v0.0.2`).
|
||||
3. Click "Edit" to write the release notes, describing the new features and bug fixes.
|
||||
4. Click the **"Publish release"** button.
|
||||
|
||||
The release is now public and available for users.
|
||||
25
openhands/integrations/vscode/LICENSE
Normal file
25
openhands/integrations/vscode/LICENSE
Normal file
@ -0,0 +1,25 @@
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
Copyright © 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the “Software”), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
48
openhands/integrations/vscode/README.md
Normal file
48
openhands/integrations/vscode/README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# OpenHands VS Code Extension
|
||||
|
||||
The official OpenHands companion extension for Visual Studio Code.
|
||||
|
||||
This extension seamlessly integrates OpenHands into your VSCode workflow, allowing you to start coding sessions with your AI agent directly from your editor.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Start a New Conversation**: Launch OpenHands in a new terminal with a single command.
|
||||
- **Use Your Current File**: Automatically send the content of your active file to OpenHands to start a task.
|
||||
- **Use a Selection**: Send only the highlighted text from your editor to OpenHands for focused tasks.
|
||||
- **Safe Terminal Management**: The extension intelligently reuses idle terminals or creates new ones, ensuring it never interrupts an active process.
|
||||
- **Automatic Virtual Environment Detection**: Finds and uses your project's Python virtual environment (`.venv`, `venv`, etc.) automatically.
|
||||
|
||||
## How to Use
|
||||
|
||||
You can access the extension's commands in two ways:
|
||||
|
||||
1. **Command Palette**:
|
||||
- Open the Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P`).
|
||||
- Type `OpenHands` to see the available commands.
|
||||
- Select the command you want to run.
|
||||
|
||||
2. **Editor Context Menu**:
|
||||
- Right-click anywhere in your text editor.
|
||||
- The OpenHands commands will appear in the context menu.
|
||||
|
||||
## Installation
|
||||
|
||||
For the best experience, the OpenHands CLI will attempt to install the extension for you automatically the first time you run it inside VSCode.
|
||||
|
||||
If you need to install it manually:
|
||||
1. Download the latest `.vsix` file from the [GitHub Releases page](https://github.com/All-Hands-AI/OpenHands/releases).
|
||||
2. In VSCode, open the Command Palette (`Ctrl+Shift+P`).
|
||||
3. Run the **"Extensions: Install from VSIX..."** command.
|
||||
4. Select the `.vsix` file you downloaded.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **OpenHands CLI**: You must have `openhands` installed and available in your system's PATH.
|
||||
- **VS Code**: Version 1.98.2 or newer.
|
||||
- **Shell**: For the best terminal reuse experience, a shell with [Shell Integration](https://code.visualstudio.com/docs/terminal/shell-integration) is recommended (e.g., modern versions of bash, zsh, PowerShell, or fish).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! If you're interested in developing the extension, please see the `DEVELOPMENT.md` file in our source repository for instructions on how to get started.
|
||||
8359
openhands/integrations/vscode/package-lock.json
generated
Normal file
8359
openhands/integrations/vscode/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
110
openhands/integrations/vscode/package.json
Normal file
110
openhands/integrations/vscode/package.json
Normal file
@ -0,0 +1,110 @@
|
||||
{
|
||||
"name": "openhands-vscode",
|
||||
"displayName": "OpenHands Integration",
|
||||
"description": "Integrates OpenHands with VS Code for easy conversation starting and context passing.",
|
||||
"version": "0.0.1",
|
||||
"publisher": "openhands",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/all-hands-ai/OpenHands.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.98.2",
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:openhands.startConversation",
|
||||
"onCommand:openhands.startConversationWithFileContext",
|
||||
"onCommand:openhands.startConversationWithSelectionContext"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "openhands.startConversation",
|
||||
"title": "Start New Conversation",
|
||||
"category": "OpenHands"
|
||||
},
|
||||
{
|
||||
"command": "openhands.startConversationWithFileContext",
|
||||
"title": "Start with File Content",
|
||||
"category": "OpenHands"
|
||||
},
|
||||
{
|
||||
"command": "openhands.startConversationWithSelectionContext",
|
||||
"title": "Start with Selected Text",
|
||||
"category": "OpenHands"
|
||||
}
|
||||
],
|
||||
"submenus": [
|
||||
{
|
||||
"id": "openhands.contextMenu",
|
||||
"label": "OpenHands"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{
|
||||
"submenu": "openhands.contextMenu",
|
||||
"group": "navigation@1"
|
||||
}
|
||||
],
|
||||
"openhands.contextMenu": [
|
||||
{
|
||||
"when": "editorHasSelection",
|
||||
"command": "openhands.startConversationWithSelectionContext",
|
||||
"group": "1@1"
|
||||
},
|
||||
{
|
||||
"command": "openhands.startConversationWithFileContext",
|
||||
"group": "1@2"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
{
|
||||
"command": "openhands.startConversation",
|
||||
"when": "true"
|
||||
},
|
||||
{
|
||||
"command": "openhands.startConversationWithFileContext",
|
||||
"when": "editorIsOpen"
|
||||
},
|
||||
{
|
||||
"command": "openhands.startConversationWithSelectionContext",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run compile",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"test": "npm run compile && node ./out/test/runTest.js",
|
||||
"package-vsix": "npm run compile && npx vsce package --no-dependencies",
|
||||
"lint": "npm run typecheck && eslint src --ext .ts && prettier --check src/**/*.ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix && prettier --write src/**/*.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.98.2",
|
||||
"typescript": "^5.0.0",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"mocha": "^10.4.0",
|
||||
"@vscode/test-electron": "^2.3.9",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/glob": "^8.1.0",
|
||||
"@vscode/vsce": "^3.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-prettier": "^5.5.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"prettier": "^3.5.3"
|
||||
}
|
||||
}
|
||||
380
openhands/integrations/vscode/src/extension.ts
Normal file
380
openhands/integrations/vscode/src/extension.ts
Normal file
@ -0,0 +1,380 @@
|
||||
import * as vscode from "vscode";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Create output channel for debug logging
|
||||
const outputChannel = vscode.window.createOutputChannel("OpenHands Debug");
|
||||
|
||||
/**
|
||||
* This implementation uses VSCode's Shell Integration API.
|
||||
*
|
||||
* VSCode API References:
|
||||
* - Terminal Shell Integration: https://code.visualstudio.com/docs/terminal/shell-integration
|
||||
* - VSCode Extension API: https://code.visualstudio.com/api/references/vscode-api
|
||||
* - Terminal API Reference: https://code.visualstudio.com/api/references/vscode-api#Terminal
|
||||
* - VSCode Source Examples: https://github.com/microsoft/vscode/blob/main/src/vscode-dts/vscode.d.ts
|
||||
*
|
||||
* Shell Integration Requirements:
|
||||
* - Compatible shells: bash, zsh, PowerShell Core, or fish shell
|
||||
* - Graceful fallback needed for Command Prompt and other shells
|
||||
*/
|
||||
|
||||
// Track terminals that we know are idle (just finished our commands)
|
||||
const idleTerminals = new Set<string>();
|
||||
|
||||
/**
|
||||
* Marks a terminal as idle after our command completes
|
||||
* @param terminalName The name of the terminal
|
||||
*/
|
||||
function markTerminalAsIdle(terminalName: string): void {
|
||||
idleTerminals.add(terminalName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a terminal as busy when we start a command
|
||||
* @param terminalName The name of the terminal
|
||||
*/
|
||||
function markTerminalAsBusy(terminalName: string): void {
|
||||
idleTerminals.delete(terminalName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we know a terminal is idle (safe to reuse)
|
||||
* @param terminal The terminal to check
|
||||
* @returns boolean true if we know it's idle, false otherwise
|
||||
*/
|
||||
function isKnownIdleTerminal(terminal: vscode.Terminal): boolean {
|
||||
return idleTerminals.has(terminal.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new OpenHands terminal with timestamp
|
||||
* @returns vscode.Terminal
|
||||
*/
|
||||
function createNewOpenHandsTerminal(): vscode.Terminal {
|
||||
const timestamp = new Date().toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const terminalName = `OpenHands ${timestamp}`;
|
||||
return vscode.window.createTerminal(terminalName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an existing OpenHands terminal or creates a new one using safe detection
|
||||
* @returns vscode.Terminal
|
||||
*/
|
||||
function findOrCreateOpenHandsTerminal(): vscode.Terminal {
|
||||
const openHandsTerminals = vscode.window.terminals.filter((terminal) =>
|
||||
terminal.name.startsWith("OpenHands"),
|
||||
);
|
||||
|
||||
if (openHandsTerminals.length > 0) {
|
||||
// Use the most recent terminal, but only if we know it's idle
|
||||
const terminal = openHandsTerminals[openHandsTerminals.length - 1];
|
||||
|
||||
// Only reuse terminals that we know are idle (safe to reuse)
|
||||
if (isKnownIdleTerminal(terminal)) {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
// If we don't know the terminal is idle, create a new one to avoid interrupting running processes
|
||||
return createNewOpenHandsTerminal();
|
||||
}
|
||||
|
||||
// No existing terminals, create new one
|
||||
return createNewOpenHandsTerminal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an OpenHands command using Shell Integration when available
|
||||
* @param terminal The terminal to execute the command in
|
||||
* @param command The command to execute
|
||||
*/
|
||||
function executeOpenHandsCommand(
|
||||
terminal: vscode.Terminal,
|
||||
command: string,
|
||||
): void {
|
||||
// Mark terminal as busy when we start a command
|
||||
markTerminalAsBusy(terminal.name);
|
||||
|
||||
if (terminal.shellIntegration) {
|
||||
// Use Shell Integration for better control
|
||||
const execution = terminal.shellIntegration.executeCommand(command);
|
||||
|
||||
// Monitor execution completion
|
||||
const disposable = vscode.window.onDidEndTerminalShellExecution((event) => {
|
||||
if (event.execution === execution) {
|
||||
if (event.exitCode === 0) {
|
||||
outputChannel.appendLine(
|
||||
"DEBUG: OpenHands command completed successfully",
|
||||
);
|
||||
// Mark terminal as idle when command completes successfully
|
||||
markTerminalAsIdle(terminal.name);
|
||||
} else if (event.exitCode !== undefined) {
|
||||
outputChannel.appendLine(
|
||||
`DEBUG: OpenHands command exited with code ${event.exitCode}`,
|
||||
);
|
||||
// Mark terminal as idle even if command failed (user can reuse it)
|
||||
markTerminalAsIdle(terminal.name);
|
||||
}
|
||||
disposable.dispose(); // Clean up the event listener
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback to traditional sendText
|
||||
terminal.sendText(command, true);
|
||||
// For traditional sendText, we can't track completion, so don't mark as idle
|
||||
// This means terminals without Shell Integration won't be reused, which is safer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and builds virtual environment activation command
|
||||
* @returns string The activation command prefix (empty if no venv found)
|
||||
*/
|
||||
function detectVirtualEnvironment(): string {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
outputChannel.appendLine("DEBUG: No workspace folder found");
|
||||
return "";
|
||||
}
|
||||
|
||||
const venvPaths = [".venv", "venv", ".virtualenv"];
|
||||
for (const venvPath of venvPaths) {
|
||||
const venvFullPath = path.join(workspaceFolder.uri.fsPath, venvPath);
|
||||
if (fs.existsSync(venvFullPath)) {
|
||||
outputChannel.appendLine(`DEBUG: Found venv at ${venvFullPath}`);
|
||||
if (process.platform === "win32") {
|
||||
// For Windows, the activation command is different and typically doesn't use 'source'
|
||||
// It's often a script that needs to be executed.
|
||||
// This is a simplified version. A more robust solution might need to check for PowerShell, cmd, etc.
|
||||
return `& "${path.join(venvFullPath, "Scripts", "Activate.ps1")}" && `;
|
||||
}
|
||||
// For POSIX-like shells
|
||||
return `source "${path.join(venvFullPath, "bin", "activate")}" && `;
|
||||
}
|
||||
}
|
||||
|
||||
outputChannel.appendLine(
|
||||
`DEBUG: No venv found in workspace ${workspaceFolder.uri.fsPath}`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a contextual task message for file content
|
||||
* @param filePath The file path (or "Untitled" for unsaved files)
|
||||
* @param content The file content
|
||||
* @param languageId The programming language ID
|
||||
* @returns string A descriptive task message
|
||||
*/
|
||||
function createFileContextMessage(
|
||||
filePath: string,
|
||||
content: string,
|
||||
languageId?: string,
|
||||
): string {
|
||||
const fileName =
|
||||
filePath === "Untitled" ? "an untitled file" : `file ${filePath}`;
|
||||
const langInfo = languageId ? ` (${languageId})` : "";
|
||||
|
||||
return `User opened ${fileName}${langInfo}. Here's the content:
|
||||
|
||||
\`\`\`${languageId || ""}
|
||||
${content}
|
||||
\`\`\`
|
||||
|
||||
Please ask the user what they want to do with this file.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a contextual task message for selected text
|
||||
* @param filePath The file path (or "Untitled" for unsaved files)
|
||||
* @param content The selected content
|
||||
* @param startLine 1-based start line number
|
||||
* @param endLine 1-based end line number
|
||||
* @param languageId The programming language ID
|
||||
* @returns string A descriptive task message
|
||||
*/
|
||||
function createSelectionContextMessage(
|
||||
filePath: string,
|
||||
content: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
languageId?: string,
|
||||
): string {
|
||||
const fileName =
|
||||
filePath === "Untitled" ? "an untitled file" : `file ${filePath}`;
|
||||
const langInfo = languageId ? ` (${languageId})` : "";
|
||||
const lineInfo =
|
||||
startLine === endLine
|
||||
? `line ${startLine}`
|
||||
: `lines ${startLine}-${endLine}`;
|
||||
|
||||
return `User selected ${lineInfo} in ${fileName}${langInfo}. Here's the selected content:
|
||||
|
||||
\`\`\`${languageId || ""}
|
||||
${content}
|
||||
\`\`\`
|
||||
|
||||
Please ask the user what they want to do with this selection.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OpenHands command with proper sanitization
|
||||
* @param options Command options
|
||||
* @param activationCommand Virtual environment activation prefix
|
||||
* @returns string The complete command to execute
|
||||
*/
|
||||
function buildOpenHandsCommand(
|
||||
options: { task?: string; filePath?: string },
|
||||
activationCommand: string,
|
||||
): string {
|
||||
let commandToSend = `${activationCommand}openhands`;
|
||||
|
||||
if (options.filePath) {
|
||||
// Ensure filePath is properly quoted if it contains spaces or special characters
|
||||
const safeFilePath = options.filePath.includes(" ")
|
||||
? `"${options.filePath}"`
|
||||
: options.filePath;
|
||||
commandToSend = `${activationCommand}openhands --file ${safeFilePath}`;
|
||||
} else if (options.task) {
|
||||
// Sanitize task string for command line (basic sanitization)
|
||||
// Replace backticks and double quotes that might break the command
|
||||
const sanitizedTask = options.task
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/"/g, '\\"');
|
||||
commandToSend = `${activationCommand}openhands --task "${sanitizedTask}"`;
|
||||
}
|
||||
|
||||
return commandToSend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to start OpenHands in terminal with safe terminal reuse
|
||||
* @param options Command options
|
||||
*/
|
||||
function startOpenHandsInTerminal(options: {
|
||||
task?: string;
|
||||
filePath?: string;
|
||||
}): void {
|
||||
try {
|
||||
// Find or create terminal using safe detection
|
||||
const terminal = findOrCreateOpenHandsTerminal();
|
||||
terminal.show(true); // true to preserve focus on the editor
|
||||
|
||||
// Detect virtual environment
|
||||
const activationCommand = detectVirtualEnvironment();
|
||||
|
||||
// Build command
|
||||
const commandToSend = buildOpenHandsCommand(options, activationCommand);
|
||||
|
||||
// Debug: show the actual command being sent
|
||||
outputChannel.appendLine(`DEBUG: Sending command: ${commandToSend}`);
|
||||
|
||||
// Execute command using Shell Integration when available
|
||||
executeOpenHandsCommand(terminal, commandToSend);
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(`Error starting OpenHands: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
// Clean up terminal tracking when terminals are closed
|
||||
const terminalCloseDisposable = vscode.window.onDidCloseTerminal(
|
||||
(terminal) => {
|
||||
idleTerminals.delete(terminal.name);
|
||||
},
|
||||
);
|
||||
context.subscriptions.push(terminalCloseDisposable);
|
||||
|
||||
// Command: Start New Conversation
|
||||
const startConversationDisposable = vscode.commands.registerCommand(
|
||||
"openhands.startConversation",
|
||||
() => {
|
||||
startOpenHandsInTerminal({});
|
||||
},
|
||||
);
|
||||
context.subscriptions.push(startConversationDisposable);
|
||||
|
||||
// Command: Start Conversation with Active File Content
|
||||
const startWithFileContextDisposable = vscode.commands.registerCommand(
|
||||
"openhands.startConversationWithFileContext",
|
||||
() => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
// No active editor, start conversation without task
|
||||
startOpenHandsInTerminal({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.document.isUntitled) {
|
||||
const fileContent = editor.document.getText();
|
||||
if (!fileContent.trim()) {
|
||||
// Empty untitled file, start conversation without task
|
||||
startOpenHandsInTerminal({});
|
||||
return;
|
||||
}
|
||||
// Create contextual message for untitled file
|
||||
const contextualTask = createFileContextMessage(
|
||||
"Untitled",
|
||||
fileContent,
|
||||
editor.document.languageId,
|
||||
);
|
||||
startOpenHandsInTerminal({ task: contextualTask });
|
||||
} else {
|
||||
const filePath = editor.document.uri.fsPath;
|
||||
// For saved files, we can still use --file flag for better performance,
|
||||
// but we could also create a contextual message if preferred
|
||||
startOpenHandsInTerminal({ filePath });
|
||||
}
|
||||
},
|
||||
);
|
||||
context.subscriptions.push(startWithFileContextDisposable);
|
||||
|
||||
// Command: Start Conversation with Selected Text
|
||||
const startWithSelectionContextDisposable = vscode.commands.registerCommand(
|
||||
"openhands.startConversationWithSelectionContext",
|
||||
() => {
|
||||
outputChannel.appendLine(
|
||||
"DEBUG: startConversationWithSelectionContext command triggered!",
|
||||
);
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
// No active editor, start conversation without task
|
||||
startOpenHandsInTerminal({});
|
||||
return;
|
||||
}
|
||||
if (editor.selection.isEmpty) {
|
||||
// No text selected, start conversation without task
|
||||
startOpenHandsInTerminal({});
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = editor.document.getText(editor.selection);
|
||||
const startLine = editor.selection.start.line + 1; // Convert to 1-based
|
||||
const endLine = editor.selection.end.line + 1; // Convert to 1-based
|
||||
const filePath = editor.document.isUntitled
|
||||
? "Untitled"
|
||||
: editor.document.uri.fsPath;
|
||||
|
||||
// Create contextual message with line numbers and file info
|
||||
const contextualTask = createSelectionContextMessage(
|
||||
filePath,
|
||||
selectedText,
|
||||
startLine,
|
||||
endLine,
|
||||
editor.document.languageId,
|
||||
);
|
||||
|
||||
startOpenHandsInTerminal({ task: contextualTask });
|
||||
},
|
||||
);
|
||||
context.subscriptions.push(startWithSelectionContextDisposable);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
// Clean up resources if needed, though for this simple extension,
|
||||
// VS Code handles terminal disposal.
|
||||
}
|
||||
22
openhands/integrations/vscode/src/test/runTest.ts
Normal file
22
openhands/integrations/vscode/src/test/runTest.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import * as path from "path";
|
||||
import { runTests } from "@vscode/test-electron";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// The folder containing the Extension Manifest package.json
|
||||
// Passed to `--extensionDevelopmentPath`
|
||||
const extensionDevelopmentPath = path.resolve(__dirname, "../../../");
|
||||
|
||||
// The path to the extension test script
|
||||
// Passed to --extensionTestsPath
|
||||
const extensionTestsPath = path.resolve(__dirname, "./suite/index"); // Points to the compiled version of suite/index.ts
|
||||
|
||||
// Download VS Code, unzip it and run the integration test
|
||||
await runTests({ extensionDevelopmentPath, extensionTestsPath });
|
||||
} catch (err) {
|
||||
console.error("Failed to run tests");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
848
openhands/integrations/vscode/src/test/suite/extension.test.ts
Normal file
848
openhands/integrations/vscode/src/test/suite/extension.test.ts
Normal file
@ -0,0 +1,848 @@
|
||||
import * as assert from "assert";
|
||||
import * as vscode from "vscode";
|
||||
|
||||
suite("Extension Test Suite", () => {
|
||||
let mockTerminal: vscode.Terminal;
|
||||
let sendTextSpy: any; // Manual spy, using 'any' type
|
||||
let showSpy: any; // Manual spy
|
||||
let createTerminalStub: any; // Manual stub
|
||||
let findTerminalStub: any; // Manual spy
|
||||
let showErrorMessageSpy: any; // Manual spy
|
||||
|
||||
// It's better to use a proper mocking library like Sinon.JS for spies and stubs.
|
||||
// For now, we'll use a simplified manual approach for spies.
|
||||
const createManualSpy = () => {
|
||||
const spy: any = (...args: any[]) => {
|
||||
// eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
spy.called = true;
|
||||
spy.callCount = (spy.callCount || 0) + 1;
|
||||
spy.lastArgs = args;
|
||||
spy.argsHistory = spy.argsHistory || [];
|
||||
spy.argsHistory.push(args);
|
||||
};
|
||||
spy.called = false;
|
||||
spy.callCount = 0;
|
||||
spy.lastArgs = null;
|
||||
spy.argsHistory = [];
|
||||
spy.resetHistory = () => {
|
||||
spy.called = false;
|
||||
spy.callCount = 0;
|
||||
spy.lastArgs = null;
|
||||
spy.argsHistory = [];
|
||||
};
|
||||
return spy;
|
||||
};
|
||||
|
||||
setup(() => {
|
||||
// Reset spies and stubs before each test
|
||||
sendTextSpy = createManualSpy();
|
||||
showSpy = createManualSpy();
|
||||
showErrorMessageSpy = createManualSpy();
|
||||
|
||||
mockTerminal = {
|
||||
name: "OpenHands",
|
||||
processId: Promise.resolve(123),
|
||||
sendText: sendTextSpy as any,
|
||||
show: showSpy as any,
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
creationOptions: {},
|
||||
exitStatus: undefined, // Added to satisfy Terminal interface
|
||||
state: {
|
||||
isInteractedWith: false,
|
||||
shell: undefined as string | undefined,
|
||||
}, // Added shell property
|
||||
shellIntegration: undefined, // No Shell Integration in tests by default
|
||||
};
|
||||
|
||||
// Store original functions
|
||||
const _originalCreateTerminal = vscode.window.createTerminal;
|
||||
const _originalTerminalsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
vscode.window,
|
||||
"terminals",
|
||||
);
|
||||
const _originalShowErrorMessage = vscode.window.showErrorMessage;
|
||||
|
||||
// Stub vscode.window.createTerminal
|
||||
createTerminalStub = createManualSpy();
|
||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
||||
createTerminalStub(...args); // Call the spy with whatever arguments it received
|
||||
return mockTerminal; // Return the mock terminal
|
||||
};
|
||||
|
||||
// Stub vscode.window.terminals
|
||||
findTerminalStub = createManualSpy(); // To track if vscode.window.terminals getter is accessed
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
// Default to returning the mockTerminal, can be overridden in specific tests
|
||||
return [mockTerminal];
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vscode.window.showErrorMessage = showErrorMessageSpy as any;
|
||||
|
||||
// Restore default mock behavior before each test
|
||||
setup(() => {
|
||||
// Reset spies
|
||||
createTerminalStub.resetHistory();
|
||||
sendTextSpy.resetHistory();
|
||||
showSpy.resetHistory();
|
||||
findTerminalStub.resetHistory();
|
||||
showErrorMessageSpy.resetHistory();
|
||||
|
||||
// Restore default createTerminal mock
|
||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
||||
createTerminalStub(...args);
|
||||
return mockTerminal; // Return the default mock terminal (no Shell Integration)
|
||||
};
|
||||
|
||||
// Restore default terminals mock
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return [mockTerminal]; // Default to returning the mockTerminal
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Teardown logic to restore original functions
|
||||
teardown(() => {
|
||||
vscode.window.createTerminal = _originalCreateTerminal;
|
||||
if (_originalTerminalsDescriptor) {
|
||||
Object.defineProperty(
|
||||
vscode.window,
|
||||
"terminals",
|
||||
_originalTerminalsDescriptor,
|
||||
);
|
||||
} else {
|
||||
// If it wasn't originally defined, delete it to restore to that state
|
||||
delete (vscode.window as any).terminals;
|
||||
}
|
||||
vscode.window.showErrorMessage = _originalShowErrorMessage;
|
||||
});
|
||||
});
|
||||
|
||||
test("Extension should be present and activate", async () => {
|
||||
const extension = vscode.extensions.getExtension(
|
||||
"openhands.openhands-vscode",
|
||||
);
|
||||
assert.ok(
|
||||
extension,
|
||||
"Extension should be found (check publisher.name in package.json)",
|
||||
);
|
||||
if (!extension.isActive) {
|
||||
await extension.activate();
|
||||
}
|
||||
assert.ok(extension.isActive, "Extension should be active");
|
||||
});
|
||||
|
||||
test("Commands should be registered", async () => {
|
||||
const extension = vscode.extensions.getExtension(
|
||||
"openhands.openhands-vscode",
|
||||
);
|
||||
if (extension && !extension.isActive) {
|
||||
await extension.activate();
|
||||
}
|
||||
const commands = await vscode.commands.getCommands(true);
|
||||
const expectedCommands = [
|
||||
"openhands.startConversation",
|
||||
"openhands.startConversationWithFileContext",
|
||||
"openhands.startConversationWithSelectionContext",
|
||||
];
|
||||
for (const cmd of expectedCommands) {
|
||||
assert.ok(
|
||||
commands.includes(cmd),
|
||||
`Command '${cmd}' should be registered`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openhands.startConversation should send correct command to terminal", async () => {
|
||||
findTerminalStub.resetHistory(); // Reset for this specific test path if needed
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return [];
|
||||
},
|
||||
configurable: true,
|
||||
}); // Simulate no existing terminal
|
||||
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
assert.ok(
|
||||
createTerminalStub.called,
|
||||
"vscode.window.createTerminal should be called",
|
||||
);
|
||||
assert.ok(showSpy.called, "terminal.show should be called");
|
||||
assert.deepStrictEqual(
|
||||
sendTextSpy.lastArgs,
|
||||
["openhands", true],
|
||||
"Correct command sent to terminal",
|
||||
);
|
||||
});
|
||||
|
||||
test("openhands.startConversationWithFileContext (saved file) should send --file command", async () => {
|
||||
const testFilePath = "/test/file.py";
|
||||
// Mock activeTextEditor for a saved file
|
||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
);
|
||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
||||
get: () => ({
|
||||
document: {
|
||||
isUntitled: false,
|
||||
uri: vscode.Uri.file(testFilePath),
|
||||
fsPath: testFilePath, // fsPath is often used
|
||||
getText: () => "file content", // Not used for saved files but good to have
|
||||
},
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
"openhands.startConversationWithFileContext",
|
||||
);
|
||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
||||
`openhands --file ${testFilePath.includes(" ") ? `"${testFilePath}"` : testFilePath}`,
|
||||
true,
|
||||
]);
|
||||
|
||||
// Restore activeTextEditor
|
||||
if (originalActiveTextEditor) {
|
||||
Object.defineProperty(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
originalActiveTextEditor,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openhands.startConversationWithFileContext (untitled file) should send contextual --task command", async () => {
|
||||
const untitledFileContent = "untitled content";
|
||||
const languageId = "javascript";
|
||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
);
|
||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
||||
get: () => ({
|
||||
document: {
|
||||
isUntitled: true,
|
||||
uri: vscode.Uri.parse("untitled:Untitled-1"),
|
||||
getText: () => untitledFileContent,
|
||||
languageId,
|
||||
},
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
"openhands.startConversationWithFileContext",
|
||||
);
|
||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
||||
|
||||
// Check that the command contains the contextual message
|
||||
const expectedMessage = `User opened an untitled file (${languageId}). Here's the content:
|
||||
|
||||
\`\`\`${languageId}
|
||||
${untitledFileContent}
|
||||
\`\`\`
|
||||
|
||||
Please ask the user what they want to do with this file.`;
|
||||
|
||||
// Apply the same sanitization as the actual implementation
|
||||
const sanitizedMessage = expectedMessage
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/"/g, '\\"');
|
||||
|
||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
||||
`openhands --task "${sanitizedMessage}"`,
|
||||
true,
|
||||
]);
|
||||
|
||||
if (originalActiveTextEditor) {
|
||||
Object.defineProperty(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
originalActiveTextEditor,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openhands.startConversationWithFileContext (no editor) should start conversation without context", async () => {
|
||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
);
|
||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
||||
get: () => undefined,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
"openhands.startConversationWithFileContext",
|
||||
);
|
||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
||||
assert.deepStrictEqual(sendTextSpy.lastArgs, ["openhands", true]);
|
||||
|
||||
if (originalActiveTextEditor) {
|
||||
Object.defineProperty(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
originalActiveTextEditor,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openhands.startConversationWithSelectionContext should send contextual --task with selection", async () => {
|
||||
const selectedText = "selected text for openhands";
|
||||
const filePath = "/test/file.py";
|
||||
const languageId = "python";
|
||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
);
|
||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
||||
get: () => ({
|
||||
document: {
|
||||
isUntitled: false,
|
||||
uri: vscode.Uri.file(filePath),
|
||||
fsPath: filePath,
|
||||
languageId,
|
||||
getText: (selection?: vscode.Selection) =>
|
||||
selection ? selectedText : "full content",
|
||||
},
|
||||
selection: {
|
||||
isEmpty: false,
|
||||
active: new vscode.Position(0, 0),
|
||||
anchor: new vscode.Position(0, 0),
|
||||
start: new vscode.Position(0, 0), // Line 0 (0-based)
|
||||
end: new vscode.Position(0, 10), // Line 0 (0-based)
|
||||
} as vscode.Selection, // Mock non-empty selection on line 1
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
"openhands.startConversationWithSelectionContext",
|
||||
);
|
||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
||||
|
||||
// Check that the command contains the contextual message with line numbers
|
||||
const expectedMessage = `User selected line 1 in file ${filePath} (${languageId}). Here's the selected content:
|
||||
|
||||
\`\`\`${languageId}
|
||||
${selectedText}
|
||||
\`\`\`
|
||||
|
||||
Please ask the user what they want to do with this selection.`;
|
||||
|
||||
// Apply the same sanitization as the actual implementation
|
||||
const sanitizedMessage = expectedMessage
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/"/g, '\\"');
|
||||
|
||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
||||
`openhands --task "${sanitizedMessage}"`,
|
||||
true,
|
||||
]);
|
||||
|
||||
if (originalActiveTextEditor) {
|
||||
Object.defineProperty(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
originalActiveTextEditor,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openhands.startConversationWithSelectionContext (no selection) should start conversation without context", async () => {
|
||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
);
|
||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
||||
get: () => ({
|
||||
document: {
|
||||
isUntitled: false,
|
||||
uri: vscode.Uri.file("/test/file.py"),
|
||||
getText: () => "full content",
|
||||
},
|
||||
selection: { isEmpty: true } as vscode.Selection, // Mock empty selection
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
"openhands.startConversationWithSelectionContext",
|
||||
);
|
||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
||||
assert.deepStrictEqual(sendTextSpy.lastArgs, ["openhands", true]);
|
||||
|
||||
if (originalActiveTextEditor) {
|
||||
Object.defineProperty(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
originalActiveTextEditor,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openhands.startConversationWithSelectionContext should handle multi-line selections", async () => {
|
||||
const selectedText = "line 1\nline 2\nline 3";
|
||||
const filePath = "/test/multiline.js";
|
||||
const languageId = "javascript";
|
||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
);
|
||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
||||
get: () => ({
|
||||
document: {
|
||||
isUntitled: false,
|
||||
uri: vscode.Uri.file(filePath),
|
||||
fsPath: filePath,
|
||||
languageId,
|
||||
getText: (selection?: vscode.Selection) =>
|
||||
selection ? selectedText : "full content",
|
||||
},
|
||||
selection: {
|
||||
isEmpty: false,
|
||||
active: new vscode.Position(4, 0),
|
||||
anchor: new vscode.Position(4, 0),
|
||||
start: new vscode.Position(4, 0), // Line 4 (0-based) = Line 5 (1-based)
|
||||
end: new vscode.Position(6, 10), // Line 6 (0-based) = Line 7 (1-based)
|
||||
} as vscode.Selection, // Mock multi-line selection from line 5 to 7
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
"openhands.startConversationWithSelectionContext",
|
||||
);
|
||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
||||
|
||||
// Check that the command contains the contextual message with line range
|
||||
const expectedMessage = `User selected lines 5-7 in file ${filePath} (${languageId}). Here's the selected content:
|
||||
|
||||
\`\`\`${languageId}
|
||||
${selectedText}
|
||||
\`\`\`
|
||||
|
||||
Please ask the user what they want to do with this selection.`;
|
||||
|
||||
// Apply the same sanitization as the actual implementation
|
||||
const sanitizedMessage = expectedMessage
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/"/g, '\\"');
|
||||
|
||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
||||
`openhands --task "${sanitizedMessage}"`,
|
||||
true,
|
||||
]);
|
||||
|
||||
if (originalActiveTextEditor) {
|
||||
Object.defineProperty(
|
||||
vscode.window,
|
||||
"activeTextEditor",
|
||||
originalActiveTextEditor,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Terminal reuse should work when existing OpenHands terminal exists", async () => {
|
||||
// Create a mock existing terminal
|
||||
const existingTerminal = {
|
||||
name: "OpenHands 10:30:15",
|
||||
processId: Promise.resolve(456),
|
||||
sendText: sendTextSpy as any,
|
||||
show: showSpy as any,
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
creationOptions: {},
|
||||
exitStatus: undefined,
|
||||
state: {
|
||||
isInteractedWith: false,
|
||||
shell: undefined as string | undefined,
|
||||
},
|
||||
shellIntegration: undefined, // No Shell Integration, should create new terminal
|
||||
};
|
||||
|
||||
// Mock terminals array to return existing terminal
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return [existingTerminal];
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Should create new terminal since no Shell Integration
|
||||
assert.ok(
|
||||
createTerminalStub.called,
|
||||
"Should create new terminal when no Shell Integration available",
|
||||
);
|
||||
});
|
||||
|
||||
test("Terminal reuse with Shell Integration should reuse existing terminal", async () => {
|
||||
// Create mock Shell Integration
|
||||
const mockExecution = {
|
||||
read: () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield "OPENHANDS_PROBE_123456789";
|
||||
},
|
||||
}),
|
||||
exitCode: Promise.resolve(0),
|
||||
};
|
||||
|
||||
const mockShellIntegration = {
|
||||
executeCommand: () => mockExecution,
|
||||
};
|
||||
|
||||
// Create a mock existing terminal with Shell Integration
|
||||
const existingTerminalWithShell = {
|
||||
name: "OpenHands 10:30:15",
|
||||
processId: Promise.resolve(456),
|
||||
sendText: sendTextSpy as any,
|
||||
show: showSpy as any,
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
creationOptions: {},
|
||||
exitStatus: undefined,
|
||||
state: {
|
||||
isInteractedWith: false,
|
||||
shell: undefined as string | undefined,
|
||||
},
|
||||
shellIntegration: mockShellIntegration,
|
||||
};
|
||||
|
||||
// Mock terminals array to return existing terminal with Shell Integration
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return [existingTerminalWithShell];
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Reset create terminal stub to track if new terminal is created
|
||||
createTerminalStub.resetHistory();
|
||||
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Should reuse existing terminal since Shell Integration is available
|
||||
// Note: The probe might timeout in test environment, but it should still reuse the terminal
|
||||
assert.ok(showSpy.called, "terminal.show should be called");
|
||||
});
|
||||
|
||||
test("Shell Integration should use executeCommand for OpenHands commands", async () => {
|
||||
const executeCommandSpy = createManualSpy();
|
||||
|
||||
// Mock execution for OpenHands command
|
||||
const mockExecution = {
|
||||
read: () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield "OpenHands started successfully";
|
||||
},
|
||||
}),
|
||||
exitCode: Promise.resolve(0),
|
||||
commandLine: {
|
||||
value: "openhands",
|
||||
isTrusted: true,
|
||||
confidence: 2,
|
||||
},
|
||||
cwd: vscode.Uri.file("/test/directory"),
|
||||
};
|
||||
|
||||
const mockShellIntegration = {
|
||||
executeCommand: (command: string) => {
|
||||
executeCommandSpy(command);
|
||||
return mockExecution;
|
||||
},
|
||||
cwd: vscode.Uri.file("/test/directory"),
|
||||
};
|
||||
|
||||
// Create a terminal with Shell Integration that will be created by createTerminal
|
||||
const terminalWithShell = {
|
||||
name: "OpenHands 10:30:15",
|
||||
processId: Promise.resolve(456),
|
||||
sendText: sendTextSpy as any,
|
||||
show: showSpy as any,
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
creationOptions: {},
|
||||
exitStatus: undefined,
|
||||
state: {
|
||||
isInteractedWith: false,
|
||||
shell: undefined as string | undefined,
|
||||
},
|
||||
shellIntegration: mockShellIntegration,
|
||||
};
|
||||
|
||||
// Mock createTerminal to return a terminal with Shell Integration
|
||||
createTerminalStub.resetHistory();
|
||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
||||
createTerminalStub(...args);
|
||||
return terminalWithShell; // Return terminal with Shell Integration
|
||||
};
|
||||
|
||||
// Mock empty terminals array so we create a new one
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return []; // No existing terminals
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Should have called executeCommand for OpenHands command
|
||||
assert.ok(
|
||||
executeCommandSpy.called,
|
||||
"Shell Integration executeCommand should be called for OpenHands command",
|
||||
);
|
||||
|
||||
// Check that the command was an OpenHands command
|
||||
const openhandsCall = executeCommandSpy.argsHistory.find(
|
||||
(args: any[]) => args[0] && args[0].includes("openhands"),
|
||||
);
|
||||
assert.ok(
|
||||
openhandsCall,
|
||||
`Should execute OpenHands command. Actual calls: ${JSON.stringify(executeCommandSpy.argsHistory)}`,
|
||||
);
|
||||
|
||||
// Should create new terminal since none exist
|
||||
assert.ok(
|
||||
createTerminalStub.called,
|
||||
"Should create new terminal when none exist",
|
||||
);
|
||||
});
|
||||
|
||||
test("Idle terminal tracking should reuse known idle terminals", async () => {
|
||||
const executeCommandSpy = createManualSpy();
|
||||
|
||||
// Mock execution for OpenHands command
|
||||
const mockExecution = {
|
||||
read: () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield "OpenHands started successfully";
|
||||
},
|
||||
}),
|
||||
exitCode: Promise.resolve(0),
|
||||
commandLine: {
|
||||
value: "openhands",
|
||||
isTrusted: true,
|
||||
confidence: 2,
|
||||
},
|
||||
cwd: vscode.Uri.file("/test/directory"),
|
||||
};
|
||||
|
||||
const mockShellIntegration = {
|
||||
executeCommand: (command: string) => {
|
||||
executeCommandSpy(command);
|
||||
return mockExecution;
|
||||
},
|
||||
cwd: vscode.Uri.file("/test/directory"),
|
||||
};
|
||||
|
||||
const terminalWithShell = {
|
||||
name: "OpenHands 10:30:15",
|
||||
processId: Promise.resolve(456),
|
||||
sendText: sendTextSpy as any,
|
||||
show: showSpy as any,
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
creationOptions: {},
|
||||
exitStatus: undefined,
|
||||
state: {
|
||||
isInteractedWith: false,
|
||||
shell: undefined as string | undefined,
|
||||
},
|
||||
shellIntegration: mockShellIntegration,
|
||||
};
|
||||
|
||||
// First, manually mark the terminal as idle (simulating a previous successful command)
|
||||
// We need to access the extension's internal idle tracking
|
||||
// For testing, we'll simulate this by running a command first, then another
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return [terminalWithShell];
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
createTerminalStub.resetHistory();
|
||||
|
||||
// First command to establish the terminal as idle
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Simulate command completion to mark terminal as idle
|
||||
// This would normally happen via the onDidEndTerminalShellExecution event
|
||||
|
||||
createTerminalStub.resetHistory();
|
||||
executeCommandSpy.resetHistory();
|
||||
|
||||
// Second command should reuse the terminal if it's marked as idle
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Should show terminal
|
||||
assert.ok(showSpy.called, "Should show terminal");
|
||||
});
|
||||
|
||||
test("Shell Integration should use executeCommand when available", async () => {
|
||||
const executeCommandSpy = createManualSpy();
|
||||
|
||||
const mockExecution = {
|
||||
read: () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield "OpenHands started successfully";
|
||||
},
|
||||
}),
|
||||
exitCode: Promise.resolve(0),
|
||||
commandLine: {
|
||||
value: "openhands",
|
||||
isTrusted: true,
|
||||
confidence: 2,
|
||||
},
|
||||
cwd: vscode.Uri.file("/test/directory"),
|
||||
};
|
||||
|
||||
const mockShellIntegration = {
|
||||
executeCommand: (command: string) => {
|
||||
executeCommandSpy(command);
|
||||
return mockExecution;
|
||||
},
|
||||
cwd: vscode.Uri.file("/test/directory"),
|
||||
};
|
||||
|
||||
const terminalWithShell = {
|
||||
name: "OpenHands 10:30:15",
|
||||
processId: Promise.resolve(456),
|
||||
sendText: sendTextSpy as any,
|
||||
show: showSpy as any,
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
creationOptions: {},
|
||||
exitStatus: undefined,
|
||||
state: {
|
||||
isInteractedWith: false,
|
||||
shell: undefined as string | undefined,
|
||||
},
|
||||
shellIntegration: mockShellIntegration,
|
||||
};
|
||||
|
||||
// Mock createTerminal to return a terminal with Shell Integration
|
||||
createTerminalStub.resetHistory();
|
||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
||||
createTerminalStub(...args);
|
||||
return terminalWithShell; // Return terminal with Shell Integration
|
||||
};
|
||||
|
||||
// Mock empty terminals array so we create a new one
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return []; // No existing terminals
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendTextSpy.resetHistory();
|
||||
executeCommandSpy.resetHistory();
|
||||
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Should use Shell Integration executeCommand, not sendText
|
||||
assert.ok(
|
||||
executeCommandSpy.called,
|
||||
"Should use Shell Integration executeCommand",
|
||||
);
|
||||
|
||||
// The OpenHands command should be executed via Shell Integration
|
||||
const openhandsCommand = executeCommandSpy.argsHistory.find(
|
||||
(args: any[]) => args[0] && args[0].includes("openhands"),
|
||||
);
|
||||
assert.ok(
|
||||
openhandsCommand,
|
||||
"Should execute OpenHands command via Shell Integration",
|
||||
);
|
||||
});
|
||||
|
||||
test("Terminal creation should work when no existing terminals", async () => {
|
||||
// Mock empty terminals array
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return []; // No existing terminals
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
createTerminalStub.resetHistory();
|
||||
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Should create new terminal when none exist
|
||||
assert.ok(
|
||||
createTerminalStub.called,
|
||||
"Should create new terminal when none exist",
|
||||
);
|
||||
|
||||
// Should show the new terminal
|
||||
assert.ok(showSpy.called, "Should show the new terminal");
|
||||
});
|
||||
|
||||
test("Shell Integration fallback should work when Shell Integration unavailable", async () => {
|
||||
// Create terminal without Shell Integration
|
||||
const terminalWithoutShell = {
|
||||
name: "OpenHands 10:30:15",
|
||||
processId: Promise.resolve(456),
|
||||
sendText: sendTextSpy as any,
|
||||
show: showSpy as any,
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
creationOptions: {},
|
||||
exitStatus: undefined,
|
||||
state: {
|
||||
isInteractedWith: false,
|
||||
shell: undefined as string | undefined,
|
||||
},
|
||||
shellIntegration: undefined, // No Shell Integration
|
||||
};
|
||||
|
||||
Object.defineProperty(vscode.window, "terminals", {
|
||||
get: () => {
|
||||
findTerminalStub();
|
||||
return [terminalWithoutShell];
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
createTerminalStub.resetHistory();
|
||||
sendTextSpy.resetHistory();
|
||||
|
||||
await vscode.commands.executeCommand("openhands.startConversation");
|
||||
|
||||
// Should create new terminal when no Shell Integration available
|
||||
assert.ok(
|
||||
createTerminalStub.called,
|
||||
"Should create new terminal when Shell Integration unavailable",
|
||||
);
|
||||
|
||||
// Should use sendText fallback for the new terminal
|
||||
assert.ok(sendTextSpy.called, "Should use sendText fallback");
|
||||
assert.ok(
|
||||
sendTextSpy.lastArgs[0].includes("openhands"),
|
||||
"Should send OpenHands command",
|
||||
);
|
||||
});
|
||||
});
|
||||
45
openhands/integrations/vscode/src/test/suite/index.ts
Normal file
45
openhands/integrations/vscode/src/test/suite/index.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import * as path from "path";
|
||||
import Mocha = require("mocha"); // Changed import style
|
||||
import glob = require("glob"); // Changed import style
|
||||
|
||||
export function run(): Promise<void> {
|
||||
// Create the mocha test
|
||||
const mocha = new Mocha({
|
||||
// This should now work with the changed import
|
||||
ui: "tdd", // Use TDD interface
|
||||
color: true, // Colored output
|
||||
timeout: 15000, // Increased timeout for extension tests
|
||||
});
|
||||
|
||||
const testsRoot = path.resolve(__dirname, ".."); // Root of the /src/test folder (compiled to /out/test)
|
||||
|
||||
return new Promise((c, e) => {
|
||||
// Use glob to find all test files (ending with .test.js in the compiled output)
|
||||
glob(
|
||||
"**/**.test.js",
|
||||
{ cwd: testsRoot },
|
||||
(err: NodeJS.ErrnoException | null, files: string[]) => {
|
||||
if (err) {
|
||||
return e(err);
|
||||
}
|
||||
|
||||
// Add files to the test suite
|
||||
files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)));
|
||||
|
||||
try {
|
||||
// Run the mocha test
|
||||
mocha.run((failures: number) => {
|
||||
if (failures > 0) {
|
||||
e(new Error(`${failures} tests failed.`));
|
||||
} else {
|
||||
c();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
e(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
23
openhands/integrations/vscode/tsconfig.json
Normal file
23
openhands/integrations/vscode/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2020",
|
||||
"outDir": "out",
|
||||
"lib": [
|
||||
"es2020"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".vscode-test"
|
||||
],
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@ -86,8 +86,7 @@ def check_dependencies(code_repo_path: str, poetry_venvs_path: str) -> None:
|
||||
# Check jupyter is installed
|
||||
logger.debug('Checking dependencies: Jupyter')
|
||||
output = subprocess.check_output(
|
||||
'poetry run jupyter --version',
|
||||
shell=True,
|
||||
['poetry', 'run', 'jupyter', '--version'],
|
||||
text=True,
|
||||
cwd=code_repo_path,
|
||||
)
|
||||
|
||||
@ -18,6 +18,10 @@ packages = [
|
||||
{ include = "pyproject.toml", to = "openhands" },
|
||||
{ include = "poetry.lock", to = "openhands" },
|
||||
]
|
||||
include = [
|
||||
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
|
||||
]
|
||||
build = "build_vscode.py" # Build VSCode extension during Poetry build
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12,<3.14"
|
||||
|
||||
818
tests/unit/cli/test_vscode_extension.py
Normal file
818
tests/unit/cli/test_vscode_extension.py
Normal file
@ -0,0 +1,818 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.cli import vscode_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_and_dependencies():
|
||||
"""A fixture to mock all external dependencies and manage the environment."""
|
||||
with (
|
||||
mock.patch.dict(os.environ, {}, clear=True),
|
||||
mock.patch('pathlib.Path.home') as mock_home,
|
||||
mock.patch('pathlib.Path.exists') as mock_exists,
|
||||
mock.patch('pathlib.Path.touch') as mock_touch,
|
||||
mock.patch('pathlib.Path.mkdir') as mock_mkdir,
|
||||
mock.patch('subprocess.run') as mock_subprocess,
|
||||
mock.patch('importlib.resources.as_file') as mock_as_file,
|
||||
mock.patch(
|
||||
'openhands.cli.vscode_extension.download_latest_vsix_from_github'
|
||||
) as mock_download,
|
||||
mock.patch('builtins.print') as mock_print,
|
||||
mock.patch('openhands.cli.vscode_extension.logger.debug') as mock_logger,
|
||||
):
|
||||
# Setup a temporary directory for home
|
||||
temp_dir = pathlib.Path.cwd() / 'temp_test_home'
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
mock_home.return_value = temp_dir
|
||||
|
||||
try:
|
||||
yield {
|
||||
'home': mock_home,
|
||||
'exists': mock_exists,
|
||||
'touch': mock_touch,
|
||||
'mkdir': mock_mkdir,
|
||||
'subprocess': mock_subprocess,
|
||||
'as_file': mock_as_file,
|
||||
'download': mock_download,
|
||||
'print': mock_print,
|
||||
'logger': mock_logger,
|
||||
}
|
||||
finally:
|
||||
# Teardown the temporary directory, ignoring errors if files don't exist
|
||||
openhands_dir = temp_dir / '.openhands'
|
||||
if openhands_dir.exists():
|
||||
for f in openhands_dir.glob('*'):
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
try:
|
||||
openhands_dir.rmdir()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
temp_dir.rmdir()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def test_not_in_vscode_environment(mock_env_and_dependencies):
|
||||
"""Should not attempt any installation if not in a VSCode-like environment."""
|
||||
os.environ['TERM_PROGRAM'] = 'not_vscode'
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
mock_env_and_dependencies['download'].assert_not_called()
|
||||
mock_env_and_dependencies['subprocess'].assert_not_called()
|
||||
|
||||
|
||||
def test_already_attempted_flag_prevents_execution(mock_env_and_dependencies):
|
||||
"""Should do nothing if the installation flag file already exists."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = True # Simulate flag file exists
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
mock_env_and_dependencies['download'].assert_not_called()
|
||||
mock_env_and_dependencies['subprocess'].assert_not_called()
|
||||
|
||||
|
||||
def test_extension_already_installed_detected(mock_env_and_dependencies):
|
||||
"""Should detect already installed extension and create flag."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns extension as installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0,
|
||||
args=[],
|
||||
stdout='openhands.openhands-vscode\nother.extension',
|
||||
stderr='',
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should only call --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['subprocess'].assert_called_with(
|
||||
['code', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: OpenHands VS Code extension is already installed.'
|
||||
)
|
||||
mock_env_and_dependencies['touch'].assert_called_once()
|
||||
mock_env_and_dependencies['download'].assert_not_called()
|
||||
|
||||
|
||||
def test_extension_detection_in_middle_of_list(mock_env_and_dependencies):
|
||||
"""Should detect extension even when it's not the first in the list."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
|
||||
# Extension is in the middle of the list
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0,
|
||||
args=[],
|
||||
stdout='first.extension\nopenhands.openhands-vscode\nlast.extension',
|
||||
stderr='',
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: OpenHands VS Code extension is already installed.'
|
||||
)
|
||||
mock_env_and_dependencies['touch'].assert_called_once()
|
||||
|
||||
|
||||
def test_extension_detection_partial_match_ignored(mock_env_and_dependencies):
|
||||
"""Should not match partial extension IDs."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
|
||||
# Partial match should not trigger detection
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0,
|
||||
args=[],
|
||||
stdout='other.openhands-vscode-fork\nsome.extension',
|
||||
stderr='',
|
||||
),
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # GitHub install succeeds
|
||||
]
|
||||
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
|
||||
|
||||
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should proceed with installation since exact match not found
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 2
|
||||
mock_env_and_dependencies['download'].assert_called_once()
|
||||
|
||||
|
||||
def test_list_extensions_fails_continues_installation(mock_env_and_dependencies):
|
||||
"""Should continue with installation if --list-extensions fails."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
|
||||
# --list-extensions fails, but GitHub install succeeds
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
returncode=1, args=[], stdout='', stderr='Command failed'
|
||||
),
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # GitHub install succeeds
|
||||
]
|
||||
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
|
||||
|
||||
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should proceed with installation
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 2
|
||||
mock_env_and_dependencies['download'].assert_called_once()
|
||||
|
||||
|
||||
def test_list_extensions_exception_continues_installation(mock_env_and_dependencies):
|
||||
"""Should continue with installation if --list-extensions throws exception."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
|
||||
# --list-extensions throws exception, but GitHub install succeeds
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
FileNotFoundError('code command not found'),
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # GitHub install succeeds
|
||||
]
|
||||
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
|
||||
|
||||
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should proceed with installation
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 2
|
||||
mock_env_and_dependencies['download'].assert_called_once()
|
||||
|
||||
|
||||
def test_mark_installation_successful_os_error(mock_env_and_dependencies):
|
||||
"""Should log error but continue if flag file creation fails."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # --list-extensions (empty)
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # GitHub install succeeds
|
||||
]
|
||||
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
|
||||
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
|
||||
|
||||
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should still complete installation
|
||||
mock_env_and_dependencies['download'].assert_called_once()
|
||||
mock_env_and_dependencies['touch'].assert_called_once()
|
||||
# Should log the error
|
||||
mock_env_and_dependencies['logger'].assert_any_call(
|
||||
'Could not create VS Code extension success flag file: Permission denied'
|
||||
)
|
||||
|
||||
|
||||
def test_installation_failure_no_flag_created(mock_env_and_dependencies):
|
||||
"""Should NOT create flag when all installation methods fail (allow retry)."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0,
|
||||
args=[],
|
||||
stdout='',
|
||||
stderr='', # --list-extensions (empty)
|
||||
)
|
||||
mock_env_and_dependencies['download'].return_value = None # GitHub fails
|
||||
mock_env_and_dependencies[
|
||||
'as_file'
|
||||
].side_effect = FileNotFoundError # Bundled fails
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should NOT create flag file - this is the key behavior change
|
||||
mock_env_and_dependencies['touch'].assert_not_called()
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Will retry installation next time you run OpenHands in VS Code.'
|
||||
)
|
||||
|
||||
|
||||
def test_install_succeeds_from_github(mock_env_and_dependencies):
|
||||
"""Should successfully install from GitHub on the first try."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
|
||||
|
||||
# Mock subprocess calls: first --list-extensions (returns empty), then install
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # --list-extensions
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # --install-extension
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch('os.remove') as mock_os_remove,
|
||||
mock.patch('os.path.exists', return_value=True),
|
||||
):
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
mock_env_and_dependencies['download'].assert_called_once()
|
||||
# Should have two subprocess calls: list-extensions and install-extension
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 2
|
||||
mock_env_and_dependencies['subprocess'].assert_any_call(
|
||||
['code', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['subprocess'].assert_any_call(
|
||||
['code', '--install-extension', '/fake/path/to/github.vsix', '--force'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: OpenHands VS Code extension installed successfully from GitHub.'
|
||||
)
|
||||
mock_os_remove.assert_called_once_with('/fake/path/to/github.vsix')
|
||||
mock_env_and_dependencies['touch'].assert_called_once()
|
||||
|
||||
|
||||
def test_github_fails_falls_back_to_bundled(mock_env_and_dependencies):
|
||||
"""Should fall back to bundled VSIX if GitHub download fails."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
|
||||
mock_vsix_path = mock.MagicMock()
|
||||
mock_vsix_path.exists.return_value = True
|
||||
mock_vsix_path.__str__.return_value = '/fake/path/to/bundled.vsix'
|
||||
mock_env_and_dependencies[
|
||||
'as_file'
|
||||
].return_value.__enter__.return_value = mock_vsix_path
|
||||
|
||||
# Mock subprocess calls: first --list-extensions (returns empty), then install
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # --list-extensions
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # --install-extension
|
||||
]
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
mock_env_and_dependencies['download'].assert_called_once()
|
||||
mock_env_and_dependencies['as_file'].assert_called_once()
|
||||
# Should have two subprocess calls: list-extensions and install-extension
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 2
|
||||
mock_env_and_dependencies['subprocess'].assert_any_call(
|
||||
['code', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['subprocess'].assert_any_call(
|
||||
['code', '--install-extension', '/fake/path/to/bundled.vsix', '--force'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['touch'].assert_called_once()
|
||||
|
||||
|
||||
def test_all_methods_fail(mock_env_and_dependencies):
|
||||
"""Should show a final failure message if all installation methods fail."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
mock_env_and_dependencies['download'].assert_called_once()
|
||||
mock_env_and_dependencies['as_file'].assert_called_once()
|
||||
# Only one subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['subprocess'].assert_called_with(
|
||||
['code', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Will retry installation next time you run OpenHands in VS Code.'
|
||||
)
|
||||
# Should NOT create flag file on failure - that's the point of our new approach
|
||||
mock_env_and_dependencies['touch'].assert_not_called()
|
||||
|
||||
|
||||
def test_windsurf_detection_and_install(mock_env_and_dependencies):
|
||||
"""Should correctly detect Windsurf but not attempt marketplace installation."""
|
||||
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Only one subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['subprocess'].assert_called_with(
|
||||
['surf', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Will retry installation next time you run OpenHands in Windsurf.'
|
||||
)
|
||||
# Should NOT create flag file on failure
|
||||
mock_env_and_dependencies['touch'].assert_not_called()
|
||||
|
||||
|
||||
def test_os_error_on_mkdir(mock_env_and_dependencies):
|
||||
"""Should log a debug message if creating the flag directory fails."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['mkdir'].side_effect = OSError('Permission denied')
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
mock_env_and_dependencies['logger'].assert_called_once_with(
|
||||
'Could not create or check VS Code extension flag directory: Permission denied'
|
||||
)
|
||||
mock_env_and_dependencies['download'].assert_not_called()
|
||||
|
||||
|
||||
def test_os_error_on_touch(mock_env_and_dependencies):
|
||||
"""Should log a debug message if creating the flag file fails."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should NOT create flag file on failure - this is the new behavior
|
||||
mock_env_and_dependencies['touch'].assert_not_called()
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Will retry installation next time you run OpenHands in VS Code.'
|
||||
)
|
||||
|
||||
|
||||
def test_flag_file_exists_windsurf(mock_env_and_dependencies):
|
||||
"""Should not attempt install if flag file already exists (Windsurf)."""
|
||||
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
|
||||
mock_env_and_dependencies['exists'].return_value = True
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
mock_env_and_dependencies['download'].assert_not_called()
|
||||
mock_env_and_dependencies['subprocess'].assert_not_called()
|
||||
|
||||
|
||||
def test_successful_install_attempt_vscode(mock_env_and_dependencies):
|
||||
"""Test that VS Code is detected but marketplace installation is not attempted."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['subprocess'].assert_called_with(
|
||||
['code', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_successful_install_attempt_windsurf(mock_env_and_dependencies):
|
||||
"""Test that Windsurf is detected but marketplace installation is not attempted."""
|
||||
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['subprocess'].assert_called_with(
|
||||
['surf', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_install_attempt_code_command_fails(mock_env_and_dependencies):
|
||||
"""Test that VS Code is detected but marketplace installation is not attempted."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_install_attempt_code_not_found(mock_env_and_dependencies):
|
||||
"""Test that VS Code is detected but marketplace installation is not attempted."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_flag_dir_creation_os_error_windsurf(mock_env_and_dependencies):
|
||||
"""Test OSError during flag directory creation (Windsurf)."""
|
||||
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
|
||||
mock_env_and_dependencies['mkdir'].side_effect = OSError('Permission denied')
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
mock_env_and_dependencies['logger'].assert_called_once_with(
|
||||
'Could not create or check Windsurf extension flag directory: Permission denied'
|
||||
)
|
||||
mock_env_and_dependencies['download'].assert_not_called()
|
||||
|
||||
|
||||
def test_flag_file_touch_os_error_vscode(mock_env_and_dependencies):
|
||||
"""Test OSError during flag file touch (VS Code)."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should NOT create flag file on failure - this is the new behavior
|
||||
mock_env_and_dependencies['touch'].assert_not_called()
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Will retry installation next time you run OpenHands in VS Code.'
|
||||
)
|
||||
|
||||
|
||||
def test_flag_file_touch_os_error_windsurf(mock_env_and_dependencies):
|
||||
"""Test OSError during flag file touch (Windsurf)."""
|
||||
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Should NOT create flag file on failure - this is the new behavior
|
||||
mock_env_and_dependencies['touch'].assert_not_called()
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Will retry installation next time you run OpenHands in Windsurf.'
|
||||
)
|
||||
|
||||
|
||||
def test_bundled_vsix_installation_failure_fallback_to_marketplace(
|
||||
mock_env_and_dependencies,
|
||||
):
|
||||
"""Test bundled VSIX failure shows appropriate message."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_vsix_path = mock.MagicMock()
|
||||
mock_vsix_path.exists.return_value = True
|
||||
mock_vsix_path.__str__.return_value = '/mock/path/openhands-vscode-0.0.1.vsix'
|
||||
mock_env_and_dependencies[
|
||||
'as_file'
|
||||
].return_value.__enter__.return_value = mock_vsix_path
|
||||
|
||||
# Mock subprocess calls: first --list-extensions (empty), then bundled install (fails)
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # --list-extensions
|
||||
subprocess.CompletedProcess(
|
||||
args=[
|
||||
'code',
|
||||
'--install-extension',
|
||||
'/mock/path/openhands-vscode-0.0.1.vsix',
|
||||
'--force',
|
||||
],
|
||||
returncode=1,
|
||||
stdout='Installation failed',
|
||||
stderr='Error installing extension',
|
||||
),
|
||||
]
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Two subprocess calls: --list-extensions and bundled VSIX install
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 2
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_bundled_vsix_not_found_fallback_to_marketplace(mock_env_and_dependencies):
|
||||
"""Test bundled VSIX not found shows appropriate message."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_vsix_path = mock.MagicMock()
|
||||
mock_vsix_path.exists.return_value = False
|
||||
mock_env_and_dependencies[
|
||||
'as_file'
|
||||
].return_value.__enter__.return_value = mock_vsix_path
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_importlib_resources_exception_fallback_to_marketplace(
|
||||
mock_env_and_dependencies,
|
||||
):
|
||||
"""Test importlib.resources exception shows appropriate message."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError(
|
||||
'Resource not found'
|
||||
)
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_comprehensive_windsurf_detection_path_based(mock_env_and_dependencies):
|
||||
"""Test Windsurf detection via PATH environment variable but no marketplace installation."""
|
||||
os.environ['PATH'] = (
|
||||
'/usr/local/bin:/Applications/Windsurf.app/Contents/Resources/app/bin:/usr/bin'
|
||||
)
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['subprocess'].assert_called_with(
|
||||
['surf', '--list-extensions'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_comprehensive_windsurf_detection_env_value_based(mock_env_and_dependencies):
|
||||
"""Test Windsurf detection via environment variable values but no marketplace installation."""
|
||||
os.environ['SOME_APP_PATH'] = '/Applications/Windsurf.app/Contents/MacOS/Windsurf'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_comprehensive_windsurf_detection_multiple_indicators(
|
||||
mock_env_and_dependencies,
|
||||
):
|
||||
"""Test Windsurf detection with multiple environment indicators."""
|
||||
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
|
||||
os.environ['PATH'] = (
|
||||
'/usr/local/bin:/Applications/Windsurf.app/Contents/Resources/app/bin:/usr/bin'
|
||||
)
|
||||
os.environ['WINDSURF_CONFIG'] = '/Users/test/.windsurf/config'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
|
||||
|
||||
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
|
||||
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
)
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# One subprocess call for --list-extensions, no installation attempts
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 1
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
|
||||
|
||||
def test_no_editor_detection_skips_installation(mock_env_and_dependencies):
|
||||
"""Test that no installation is attempted when no supported editor is detected."""
|
||||
os.environ['TERM_PROGRAM'] = 'iTerm.app'
|
||||
os.environ['PATH'] = '/usr/local/bin:/usr/bin:/bin'
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
mock_env_and_dependencies['exists'].assert_not_called()
|
||||
mock_env_and_dependencies['touch'].assert_not_called()
|
||||
mock_env_and_dependencies['subprocess'].assert_not_called()
|
||||
mock_env_and_dependencies['print'].assert_not_called()
|
||||
|
||||
|
||||
def test_both_bundled_and_marketplace_fail(mock_env_and_dependencies):
|
||||
"""Test when bundled VSIX installation fails."""
|
||||
os.environ['TERM_PROGRAM'] = 'vscode'
|
||||
mock_env_and_dependencies['exists'].return_value = False
|
||||
mock_env_and_dependencies['download'].return_value = None
|
||||
mock_vsix_path = mock.MagicMock()
|
||||
mock_vsix_path.exists.return_value = True
|
||||
mock_vsix_path.__str__.return_value = '/mock/path/openhands-vscode-0.0.1.vsix'
|
||||
mock_env_and_dependencies[
|
||||
'as_file'
|
||||
].return_value.__enter__.return_value = mock_vsix_path
|
||||
|
||||
# Mock subprocess calls: first --list-extensions (empty), then bundled install (fails)
|
||||
mock_env_and_dependencies['subprocess'].side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
returncode=0, args=[], stdout='', stderr=''
|
||||
), # --list-extensions
|
||||
subprocess.CompletedProcess(
|
||||
args=[
|
||||
'code',
|
||||
'--install-extension',
|
||||
'/mock/path/openhands-vscode-0.0.1.vsix',
|
||||
'--force',
|
||||
],
|
||||
returncode=1,
|
||||
stdout='Bundled installation failed',
|
||||
stderr='Error installing bundled extension',
|
||||
),
|
||||
]
|
||||
|
||||
vscode_extension.attempt_vscode_extension_install()
|
||||
|
||||
# Two subprocess calls: --list-extensions and bundled VSIX install
|
||||
assert mock_env_and_dependencies['subprocess'].call_count == 2
|
||||
mock_env_and_dependencies['print'].assert_any_call(
|
||||
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user