VSCode shim + basic cli (#10452)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Chris Estreich 2026-01-05 10:24:05 -08:00 committed by GitHub
parent f2276bebc4
commit b11d53adf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 12266 additions and 21 deletions

178
apps/cli/README.md Normal file
View file

@ -0,0 +1,178 @@
# @roo-code/cli
Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode.
## Overview
This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment.
## Installation
```bash
# From the monorepo root.
pnpm install
# Build the main extension first.
pnpm --filter roo-cline bundle
# Build the cli.
pnpm --filter @roo-code/cli build
```
## Usage
### Interactive Mode (Default)
By default, the CLI prompts for approval before executing actions:
```bash
export OPENROUTER_API_KEY=sk-or-v1-...
pnpm --filter @roo-code/cli start \
-x \
-p openrouter \
-k $OPENROUTER_API_KEY \
-m anthropic/claude-sonnet-4.5 \
--workspace ~/Documents/my-project \
"What is this project?"
```
In interactive mode:
- Tool executions prompt for yes/no approval
- Commands prompt for yes/no approval
- Followup questions show suggestions and wait for user input
- Browser and MCP actions prompt for approval
### Non-Interactive Mode (`-y`)
For automation and scripts, use `-y` to auto-approve all actions:
```bash
pnpm --filter @roo-code/cli start \
-y \
-x \
-p openrouter \
-k $OPENROUTER_API_KEY \
-m anthropic/claude-sonnet-4.5 \
--workspace ~/Documents/my-project \
"Refactor the utils.ts file"
```
In non-interactive mode:
- Tool, command, browser, and MCP actions are auto-approved
- Followup questions show a 10-second timeout, then auto-select the first suggestion
- Typing any key cancels the timeout and allows manual input
## Options
| Option | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | Provider default |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` |
By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information.
## Environment Variables
The CLI will look for API keys in environment variables if not provided via `--api-key`:
| Provider | Environment Variable |
| ------------- | -------------------- |
| anthropic | `ANTHROPIC_API_KEY` |
| openai | `OPENAI_API_KEY` |
| openrouter | `OPENROUTER_API_KEY` |
| google/gemini | `GOOGLE_API_KEY` |
| mistral | `MISTRAL_API_KEY` |
| deepseek | `DEEPSEEK_API_KEY` |
| bedrock | `AWS_ACCESS_KEY_ID` |
## Architecture
```
┌─────────────────┐
│ CLI Entry │
│ (index.ts) │
└────────┬────────┘
┌─────────────────┐
│ ExtensionHost │
│ (extension- │
│ host.ts) │
└────────┬────────┘
┌────┴────┐
│ │
▼ ▼
┌───────┐ ┌──────────┐
│vscode │ │Extension │
│-shim │ │ Bundle │
└───────┘ └──────────┘
```
## How It Works
1. **CLI Entry Point** (`index.ts`): Parses command line arguments and initializes the ExtensionHost
2. **ExtensionHost** (`extension-host.ts`):
- Creates a VSCode API mock using `@roo-code/vscode-shim`
- Intercepts `require('vscode')` to return the mock
- Loads and activates the extension bundle
- Manages bidirectional message flow
3. **Message Flow**:
- CLI → Extension: `emit("webviewMessage", {...})`
- Extension → CLI: `emit("extensionWebviewMessage", {...})`
## Current Limitations
- **No TUI**: Output is plain text (no React/Ink UI yet)
- **No configuration file**: Settings are passed via command line flags
- **No persistence**: Each run is a fresh session
## Development
```bash
# Watch mode for development
pnpm dev
# Run tests
pnpm test
# Type checking
pnpm check-types
# Linting
pnpm lint
```
## Troubleshooting
### Extension bundle not found
Make sure you've built the main extension first:
```bash
cd src
pnpm bundle
```
### Module resolution errors
The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS.
### "vscode" module not found
The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed.

View file

@ -0,0 +1,4 @@
import { config } from "@roo-code/config-eslint/base"
/** @type {import("eslint").Linter.Config} */
export default [...config]

35
apps/cli/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "@roo-code/cli",
"version": "0.1.0",
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true,
"type": "module",
"main": "dist/index.js",
"bin": {
"roo": "dist/index.js"
},
"scripts": {
"format": "prettier --write 'src/**/*.ts'",
"lint": "eslint src --ext .ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"build": "tsup",
"start": "node dist/index.js",
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@roo-code/types": "workspace:^",
"@roo-code/vscode-shim": "workspace:^",
"@vscode/ripgrep": "^1.15.9",
"commander": "^12.1.0"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"rimraf": "^6.0.1",
"tsup": "^8.4.0",
"typescript": "5.8.3",
"vitest": "^3.2.3"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,144 @@
/**
* Integration tests for CLI
*
* These tests require a valid OPENROUTER_API_KEY environment variable.
* They will be skipped if the API key is not available.
*
* Run with: OPENROUTER_API_KEY=sk-or-v1-... pnpm test
*/
import { ExtensionHost } from "../extension-host.js"
import path from "path"
import fs from "fs"
import os from "os"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
const hasApiKey = !!OPENROUTER_API_KEY
// Find the extension path - we need a built extension for integration tests
function findExtensionPath(): string | null {
// From apps/cli/src/__tests__, go up to monorepo root then to src/dist
const monorepoPath = path.resolve(__dirname, "../../../../src/dist")
if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
return monorepoPath
}
// Also try from the apps/cli level
const altPath = path.resolve(__dirname, "../../../src/dist")
if (fs.existsSync(path.join(altPath, "extension.js"))) {
return altPath
}
return null
}
const extensionPath = findExtensionPath()
const hasExtension = !!extensionPath
// Create a temporary workspace directory for tests
function createTempWorkspace(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-"))
return tempDir
}
// Clean up temporary workspace
function cleanupWorkspace(workspacePath: string): void {
try {
fs.rmSync(workspacePath, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}
describe.skipIf(!hasApiKey || !hasExtension)(
"CLI Integration Tests (requires OPENROUTER_API_KEY and built extension)",
() => {
let workspacePath: string
let host: ExtensionHost
beforeAll(() => {
console.log("Integration tests running with:")
console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`)
console.log(` - Extension Path: ${extensionPath}`)
})
beforeEach(() => {
workspacePath = createTempWorkspace()
})
afterEach(async () => {
if (host) {
await host.dispose()
}
cleanupWorkspace(workspacePath)
})
/**
* Main integration test - tests the complete end-to-end flow
*
* NOTE: Due to the extension using singletons (TelemetryService, etc.),
* only one integration test can run per process. This single test covers
* the main functionality: activation, task execution, completion, and disposal.
*/
it("should complete end-to-end task execution with proper lifecycle", async () => {
host = new ExtensionHost({
mode: "code",
apiProvider: "openrouter",
apiKey: OPENROUTER_API_KEY!,
model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests.
workspacePath,
extensionPath: extensionPath!,
quiet: true,
})
// Test activation
await host.activate()
// Track state messages
const stateMessages: unknown[] = []
host.on("extensionWebviewMessage", (msg: Record<string, unknown>) => {
if (msg.type === "state") {
stateMessages.push(msg)
}
})
// Test task execution with completion
// Note: runTask internally waits for webview to be ready before sending messages
await expect(host.runTask("Say hello in exactly 5 words")).resolves.toBeUndefined()
// After task completes, webview should have been ready
expect(host.isInInitialSetup()).toBe(false)
// Verify we received state updates
expect(stateMessages.length).toBeGreaterThan(0)
// Test disposal
await host.dispose()
expect((global as Record<string, unknown>).vscode).toBeUndefined()
expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
}, 120000) // 2 minute timeout
},
)
// Additional test to verify skip behavior
describe("Integration test skip behavior", () => {
it("should have OPENROUTER_API_KEY check", () => {
if (hasApiKey) {
console.log("OPENROUTER_API_KEY is set, integration tests will run")
} else {
console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
it("should have extension check", () => {
if (hasExtension) {
console.log(`Extension found at: ${extensionPath}`)
} else {
console.log("Extension not found, integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
})

View file

@ -0,0 +1,119 @@
/**
* Unit tests for CLI utility functions
*/
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
import fs from "fs"
import path from "path"
// Mock fs module
vi.mock("fs")
describe("getEnvVarName", () => {
it.each([
["anthropic", "ANTHROPIC_API_KEY"],
["openai", "OPENAI_API_KEY"],
["openrouter", "OPENROUTER_API_KEY"],
["google", "GOOGLE_API_KEY"],
["gemini", "GOOGLE_API_KEY"],
["bedrock", "AWS_ACCESS_KEY_ID"],
["ollama", "OLLAMA_API_KEY"],
["mistral", "MISTRAL_API_KEY"],
["deepseek", "DEEPSEEK_API_KEY"],
])("should return %s for %s provider", (provider, expectedEnvVar) => {
expect(getEnvVarName(provider)).toBe(expectedEnvVar)
})
it("should handle case-insensitive provider names", () => {
expect(getEnvVarName("ANTHROPIC")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("OpenRouter")).toBe("OPENROUTER_API_KEY")
})
it("should return uppercase provider name with _API_KEY suffix for unknown providers", () => {
expect(getEnvVarName("custom")).toBe("CUSTOM_API_KEY")
expect(getEnvVarName("myProvider")).toBe("MYPROVIDER_API_KEY")
})
})
describe("getApiKeyFromEnv", () => {
const originalEnv = process.env
beforeEach(() => {
// Reset process.env before each test
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it("should return API key from environment variable for anthropic", () => {
process.env.ANTHROPIC_API_KEY = "test-anthropic-key"
expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key")
})
it("should return API key from environment variable for openrouter", () => {
process.env.OPENROUTER_API_KEY = "test-openrouter-key"
expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
})
it("should return API key from environment variable for openai", () => {
process.env.OPENAI_API_KEY = "test-openai-key"
expect(getApiKeyFromEnv("openai")).toBe("test-openai-key")
})
it("should return undefined when API key is not set", () => {
delete process.env.ANTHROPIC_API_KEY
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
})
it("should handle custom provider names", () => {
process.env.CUSTOM_API_KEY = "test-custom-key"
expect(getApiKeyFromEnv("custom")).toBe("test-custom-key")
})
it("should handle case-insensitive provider lookup", () => {
process.env.ANTHROPIC_API_KEY = "test-key"
expect(getApiKeyFromEnv("ANTHROPIC")).toBe("test-key")
})
})
describe("getDefaultExtensionPath", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("should return monorepo path when extension.js exists there", () => {
const mockDirname = "/test/apps/cli/dist"
const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
vi.mocked(fs.existsSync).mockReturnValue(true)
const result = getDefaultExtensionPath(mockDirname)
expect(result).toBe(expectedMonorepoPath)
expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
})
it("should return package path when extension.js does not exist in monorepo path", () => {
const mockDirname = "/test/apps/cli/dist"
const expectedPackagePath = path.resolve(mockDirname, "../extension")
vi.mocked(fs.existsSync).mockReturnValue(false)
const result = getDefaultExtensionPath(mockDirname)
expect(result).toBe(expectedPackagePath)
})
it("should check monorepo path first", () => {
const mockDirname = "/some/path"
vi.mocked(fs.existsSync).mockReturnValue(false)
getDefaultExtensionPath(mockDirname)
const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
})
})

File diff suppressed because it is too large Load diff

163
apps/cli/src/index.ts Normal file
View file

@ -0,0 +1,163 @@
/**
* @roo-code/cli - Command Line Interface for Roo Code
*/
import { Command } from "commander"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import {
type ProviderName,
type ReasoningEffortExtended,
isProviderName,
reasoningEffortsExtended,
} from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"
import { ExtensionHost } from "./extension-host.js"
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js"
const DEFAULTS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-sonnet-4.5",
}
const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const program = new Command()
program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0")
program
.argument("<prompt>", "The prompt/task to execute")
.option("-w, --workspace <path>", "Workspace path to operate in", process.cwd())
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
.option("-x, --exit-on-complete", "Exit the process when the task completes (useful for testing)", false)
.option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false)
.option("-k, --api-key <key>", "API key for the LLM provider (defaults to ANTHROPIC_API_KEY env var)")
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
.option("-m, --model <model>", "Model to use", DEFAULTS.model)
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode)
.option(
"-r, --reasoning-effort <effort>",
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
DEFAULTS.reasoningEffort,
)
.action(
async (
prompt: string,
options: {
workspace: string
extension?: string
verbose: boolean
debug: boolean
exitOnComplete: boolean
yes: boolean
apiKey?: string
provider: ProviderName
model?: string
mode?: string
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
},
) => {
// Default is quiet mode - suppress VSCode shim logs unless verbose
// or debug is specified.
if (!options.verbose && !options.debug) {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})
}
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
const workspacePath = path.resolve(options.workspace)
if (!apiKey) {
console.error(
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
)
console.error(`[CLI] For ${options.provider}, set ${getEnvVarName(options.provider)}`)
process.exit(1)
}
if (!fs.existsSync(workspacePath)) {
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
process.exit(1)
}
if (!isProviderName(options.provider)) {
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
process.exit(1)
}
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
console.error(
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
)
process.exit(1)
}
console.log(`[CLI] Mode: ${options.mode || "default"}`)
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
console.log(`[CLI] Provider: ${options.provider}`)
console.log(`[CLI] Model: ${options.model || "default"}`)
console.log(`[CLI] Workspace: ${workspacePath}`)
const host = new ExtensionHost({
mode: options.mode || DEFAULTS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
apiProvider: options.provider,
apiKey,
model: options.model || DEFAULTS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
verbose: options.debug,
quiet: !options.verbose && !options.debug,
nonInteractive: options.yes,
})
// Handle SIGINT (Ctrl+C)
process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
await host.dispose()
process.exit(130)
})
// Handle SIGTERM
process.on("SIGTERM", async () => {
console.log("\n[CLI] Received SIGTERM, shutting down...")
await host.dispose()
process.exit(143)
})
try {
await host.activate()
await host.runTask(prompt)
await host.dispose()
if (options.exitOnComplete) {
process.exit(0)
}
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
if (options.debug && error instanceof Error) {
console.error(error.stack)
}
await host.dispose()
process.exit(1)
}
},
)
program.parse()

54
apps/cli/src/utils.ts Normal file
View file

@ -0,0 +1,54 @@
/**
* Utility functions for the Roo Code CLI
*/
import path from "path"
import fs from "fs"
/**
* Get the environment variable name for a provider's API key
*/
export function getEnvVarName(provider: string): string {
const envVarMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
google: "GOOGLE_API_KEY",
gemini: "GOOGLE_API_KEY",
bedrock: "AWS_ACCESS_KEY_ID",
ollama: "OLLAMA_API_KEY",
mistral: "MISTRAL_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
}
return envVarMap[provider.toLowerCase()] || `${provider.toUpperCase()}_API_KEY`
}
/**
* Get API key from environment variable based on provider
*/
export function getApiKeyFromEnv(provider: string): string | undefined {
const envVar = getEnvVarName(provider)
return process.env[envVar]
}
/**
* Get the default path to the extension bundle.
* This assumes the CLI is installed alongside the built extension.
*
* @param dirname - The __dirname equivalent for the calling module
*/
export function getDefaultExtensionPath(dirname: string): string {
// __dirname is apps/cli/dist when bundled
// The extension is at src/dist (relative to monorepo root)
// So from apps/cli/dist, we need to go ../../../src/dist
const monorepoPath = path.resolve(dirname, "../../../src/dist")
// Try monorepo path first (for development)
if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
return monorepoPath
}
// Fallback: when installed as npm package, extension might be at ../extension
const packagePath = path.resolve(dirname, "../extension")
return packagePath
}

9
apps/cli/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist"
},
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]
}

22
apps/cli/tsup.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
target: "node20",
platform: "node",
banner: {
js: "#!/usr/bin/env node",
},
// Bundle these workspace packages that export TypeScript.
noExternal: ["@roo-code/types", "@roo-code/vscode-shim"],
external: [
// Keep native modules external.
"@anthropic-ai/sdk",
"@anthropic-ai/bedrock-sdk",
"@anthropic-ai/vertex-sdk",
],
})

11
apps/cli/vitest.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
watch: false,
testTimeout: 120_000, // 2m for integration tests.
include: ["src/**/*.test.ts"],
},
})

View file

@ -53,6 +53,9 @@
]
},
"pnpm": {
"onlyBuiltDependencies": [
"@vscode/ripgrep"
],
"overrides": {
"tar-fs": ">=3.1.1",
"esbuild": ">=0.25.0",

View file

@ -0,0 +1,4 @@
import { config } from "@roo-code/config-eslint/base"
/** @type {import("eslint").Linter.Config} */
export default [...config]

View file

@ -0,0 +1,20 @@
{
"name": "@roo-code/vscode-shim",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"format": "prettier --write 'src/**/*.ts'",
"lint": "eslint src --ext .ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"clean": "rimraf .turbo"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"vitest": "^3.2.3"
},
"dependencies": {}
}

View file

@ -0,0 +1,378 @@
import {
Location,
DiagnosticRelatedInformation,
Diagnostic,
ThemeColor,
ThemeIcon,
CodeActionKind,
CodeLens,
LanguageModelTextPart,
LanguageModelToolCallPart,
LanguageModelToolResultPart,
FileSystemError,
} from "../classes/Additional.js"
import { Uri } from "../classes/Uri.js"
import { Range } from "../classes/Range.js"
import { Position } from "../classes/Position.js"
describe("Location", () => {
it("should create location with URI and Range", () => {
const uri = Uri.file("/path/to/file.txt")
const range = new Range(0, 0, 5, 10)
const location = new Location(uri, range)
expect(location.uri).toBe(uri)
expect(location.range).toBe(range)
})
it("should create location with URI and Position", () => {
const uri = Uri.file("/path/to/file.txt")
const position = new Position(5, 10)
const location = new Location(uri, position)
expect(location.uri).toBe(uri)
expect(location.range).toBe(position)
})
})
describe("DiagnosticRelatedInformation", () => {
it("should create diagnostic related information", () => {
const uri = Uri.file("/path/to/file.txt")
const range = new Range(0, 0, 1, 0)
const location = new Location(uri, range)
const message = "Related issue here"
const info = new DiagnosticRelatedInformation(location, message)
expect(info.location).toBe(location)
expect(info.message).toBe(message)
})
})
describe("Diagnostic", () => {
it("should create diagnostic with default severity (Error)", () => {
const range = new Range(0, 0, 0, 10)
const message = "Error message"
const diagnostic = new Diagnostic(range, message)
expect(diagnostic.range.isEqual(range)).toBe(true)
expect(diagnostic.message).toBe(message)
expect(diagnostic.severity).toBe(0) // Error
})
it("should create diagnostic with custom severity", () => {
const range = new Range(0, 0, 0, 10)
const message = "Warning message"
const diagnostic = new Diagnostic(range, message, 1) // Warning
expect(diagnostic.severity).toBe(1)
})
it("should allow setting optional properties", () => {
const range = new Range(0, 0, 0, 10)
const diagnostic = new Diagnostic(range, "Test")
diagnostic.source = "eslint"
diagnostic.code = "no-unused-vars"
diagnostic.tags = [1] // Unnecessary
expect(diagnostic.source).toBe("eslint")
expect(diagnostic.code).toBe("no-unused-vars")
expect(diagnostic.tags).toEqual([1])
})
it("should allow setting related information", () => {
const range = new Range(0, 0, 0, 10)
const diagnostic = new Diagnostic(range, "Test")
const relatedUri = Uri.file("/related.txt")
const relatedLocation = new Location(relatedUri, new Range(1, 0, 1, 5))
const relatedInfo = new DiagnosticRelatedInformation(relatedLocation, "Related issue")
diagnostic.relatedInformation = [relatedInfo]
expect(diagnostic.relatedInformation).toHaveLength(1)
expect(diagnostic.relatedInformation[0]?.message).toBe("Related issue")
})
})
describe("ThemeColor", () => {
it("should create theme color with ID", () => {
const color = new ThemeColor("editor.foreground")
expect(color.id).toBe("editor.foreground")
})
it("should handle custom color IDs", () => {
const color = new ThemeColor("myExtension.customColor")
expect(color.id).toBe("myExtension.customColor")
})
})
describe("ThemeIcon", () => {
it("should create theme icon with ID", () => {
const icon = new ThemeIcon("file")
expect(icon.id).toBe("file")
expect(icon.color).toBeUndefined()
})
it("should create theme icon with ID and color", () => {
const color = new ThemeColor("errorForeground")
const icon = new ThemeIcon("error", color)
expect(icon.id).toBe("error")
expect(icon.color).toBe(color)
expect(icon.color?.id).toBe("errorForeground")
})
})
describe("CodeActionKind", () => {
describe("static properties", () => {
it("should have Empty kind", () => {
expect(CodeActionKind.Empty.value).toBe("")
})
it("should have QuickFix kind", () => {
expect(CodeActionKind.QuickFix.value).toBe("quickfix")
})
it("should have Refactor kind", () => {
expect(CodeActionKind.Refactor.value).toBe("refactor")
})
it("should have RefactorExtract kind", () => {
expect(CodeActionKind.RefactorExtract.value).toBe("refactor.extract")
})
it("should have RefactorInline kind", () => {
expect(CodeActionKind.RefactorInline.value).toBe("refactor.inline")
})
it("should have RefactorRewrite kind", () => {
expect(CodeActionKind.RefactorRewrite.value).toBe("refactor.rewrite")
})
it("should have Source kind", () => {
expect(CodeActionKind.Source.value).toBe("source")
})
it("should have SourceOrganizeImports kind", () => {
expect(CodeActionKind.SourceOrganizeImports.value).toBe("source.organizeImports")
})
})
describe("constructor", () => {
it("should create custom kind", () => {
const kind = new CodeActionKind("custom.action")
expect(kind.value).toBe("custom.action")
})
})
describe("append()", () => {
it("should append to existing kind", () => {
const kind = new CodeActionKind("refactor")
const appended = kind.append("extract")
expect(appended.value).toBe("refactor.extract")
})
it("should handle empty kind", () => {
const kind = new CodeActionKind("")
const appended = kind.append("quickfix")
expect(appended.value).toBe("quickfix")
})
})
describe("contains()", () => {
it("should return true when kind contains another", () => {
const parent = CodeActionKind.Refactor
const child = CodeActionKind.RefactorExtract
expect(parent.contains(child)).toBe(true)
})
it("should return false when kinds are different hierarchies", () => {
const quickfix = CodeActionKind.QuickFix
const refactor = CodeActionKind.Refactor
expect(quickfix.contains(refactor)).toBe(false)
})
it("should return true for equal kinds", () => {
const kind = new CodeActionKind("quickfix")
expect(kind.contains(CodeActionKind.QuickFix)).toBe(true)
})
})
describe("intersects()", () => {
it("should return true when one contains the other", () => {
const parent = CodeActionKind.Refactor
const child = CodeActionKind.RefactorExtract
expect(parent.intersects(child)).toBe(true)
expect(child.intersects(parent)).toBe(true)
})
it("should return false for non-intersecting kinds", () => {
const quickfix = CodeActionKind.QuickFix
const source = CodeActionKind.Source
expect(quickfix.intersects(source)).toBe(false)
})
})
})
describe("CodeLens", () => {
it("should create CodeLens with range only", () => {
const range = new Range(0, 0, 0, 10)
const lens = new CodeLens(range)
expect(lens.range.isEqual(range)).toBe(true)
expect(lens.command).toBeUndefined()
expect(lens.isResolved).toBe(false)
})
it("should create CodeLens with range and command", () => {
const range = new Range(5, 0, 5, 20)
const command = {
command: "myExtension.doSomething",
title: "Click me",
arguments: [1, 2, 3],
}
const lens = new CodeLens(range, command)
expect(lens.range).toBeDefined()
expect(lens.command?.command).toBe("myExtension.doSomething")
expect(lens.command?.title).toBe("Click me")
expect(lens.command?.arguments).toEqual([1, 2, 3])
})
})
describe("LanguageModelTextPart", () => {
it("should create text part with value", () => {
const part = new LanguageModelTextPart("Hello, world!")
expect(part.value).toBe("Hello, world!")
})
})
describe("LanguageModelToolCallPart", () => {
it("should create tool call part", () => {
const part = new LanguageModelToolCallPart("call-123", "searchFiles", { query: "test" })
expect(part.callId).toBe("call-123")
expect(part.name).toBe("searchFiles")
expect(part.input).toEqual({ query: "test" })
})
})
describe("LanguageModelToolResultPart", () => {
it("should create tool result part", () => {
const part = new LanguageModelToolResultPart("call-123", [{ type: "text", text: "result" }])
expect(part.callId).toBe("call-123")
expect(part.content).toHaveLength(1)
expect(part.content[0]).toEqual({ type: "text", text: "result" })
})
})
describe("FileSystemError", () => {
describe("constructor", () => {
it("should create error with message", () => {
const error = new FileSystemError("Something went wrong")
expect(error.message).toBe("Something went wrong")
expect(error.code).toBe("Unknown")
expect(error.name).toBe("FileSystemError")
})
it("should create error with message and code", () => {
const error = new FileSystemError("Custom error", "CustomCode")
expect(error.message).toBe("Custom error")
expect(error.code).toBe("CustomCode")
})
})
describe("FileNotFound()", () => {
it("should create FileNotFound error from string", () => {
const error = FileSystemError.FileNotFound("File not found: /path/to/file")
expect(error.message).toBe("File not found: /path/to/file")
expect(error.code).toBe("FileNotFound")
})
it("should create FileNotFound error from URI", () => {
const uri = Uri.file("/path/to/file.txt")
const error = FileSystemError.FileNotFound(uri)
expect(error.message).toContain("/path/to/file.txt")
expect(error.code).toBe("FileNotFound")
})
it("should handle undefined input", () => {
const error = FileSystemError.FileNotFound()
expect(error.message).toContain("unknown")
expect(error.code).toBe("FileNotFound")
})
})
describe("FileExists()", () => {
it("should create FileExists error", () => {
const error = FileSystemError.FileExists("File already exists")
expect(error.message).toBe("File already exists")
expect(error.code).toBe("FileExists")
})
it("should create FileExists error from URI", () => {
const uri = Uri.file("/existing/file.txt")
const error = FileSystemError.FileExists(uri)
expect(error.message).toContain("/existing/file.txt")
expect(error.code).toBe("FileExists")
})
})
describe("FileNotADirectory()", () => {
it("should create FileNotADirectory error", () => {
const error = FileSystemError.FileNotADirectory("Not a directory")
expect(error.message).toBe("Not a directory")
expect(error.code).toBe("FileNotADirectory")
})
})
describe("FileIsADirectory()", () => {
it("should create FileIsADirectory error", () => {
const error = FileSystemError.FileIsADirectory("Is a directory")
expect(error.message).toBe("Is a directory")
expect(error.code).toBe("FileIsADirectory")
})
})
describe("NoPermissions()", () => {
it("should create NoPermissions error", () => {
const error = FileSystemError.NoPermissions("Access denied")
expect(error.message).toBe("Access denied")
expect(error.code).toBe("NoPermissions")
})
})
describe("Unavailable()", () => {
it("should create Unavailable error", () => {
const error = FileSystemError.Unavailable("Resource unavailable")
expect(error.message).toBe("Resource unavailable")
expect(error.code).toBe("Unavailable")
})
})
})

View file

@ -0,0 +1,156 @@
import { CancellationTokenSource } from "../classes/CancellationToken.js"
describe("CancellationToken", () => {
describe("initial state", () => {
it("should not be cancelled initially", () => {
const source = new CancellationTokenSource()
const token = source.token
expect(token.isCancellationRequested).toBe(false)
})
it("should have onCancellationRequested function", () => {
const source = new CancellationTokenSource()
const token = source.token
expect(typeof token.onCancellationRequested).toBe("function")
})
})
})
describe("CancellationTokenSource", () => {
describe("token property", () => {
it("should return a CancellationToken", () => {
const source = new CancellationTokenSource()
const token = source.token
expect(token).toBeDefined()
expect(typeof token.isCancellationRequested).toBe("boolean")
expect(typeof token.onCancellationRequested).toBe("function")
})
it("should return the same token instance on multiple accesses", () => {
const source = new CancellationTokenSource()
expect(source.token).toBe(source.token)
})
})
describe("cancel()", () => {
it("should set isCancellationRequested to true", () => {
const source = new CancellationTokenSource()
source.cancel()
expect(source.token.isCancellationRequested).toBe(true)
})
it("should fire onCancellationRequested event", () => {
const source = new CancellationTokenSource()
const listener = vi.fn()
source.token.onCancellationRequested(listener)
source.cancel()
expect(listener).toHaveBeenCalledTimes(1)
})
it("should only fire event once on multiple cancel calls", () => {
const source = new CancellationTokenSource()
const listener = vi.fn()
source.token.onCancellationRequested(listener)
source.cancel()
source.cancel()
source.cancel()
expect(listener).toHaveBeenCalledTimes(1)
})
it("should be idempotent", () => {
const source = new CancellationTokenSource()
source.cancel()
source.cancel()
expect(source.token.isCancellationRequested).toBe(true)
})
})
describe("dispose()", () => {
it("should cancel the token", () => {
const source = new CancellationTokenSource()
source.dispose()
expect(source.token.isCancellationRequested).toBe(true)
})
it("should fire onCancellationRequested event", () => {
const source = new CancellationTokenSource()
const listener = vi.fn()
source.token.onCancellationRequested(listener)
source.dispose()
expect(listener).toHaveBeenCalledTimes(1)
})
it("should be safe to call multiple times", () => {
const source = new CancellationTokenSource()
expect(() => {
source.dispose()
source.dispose()
}).not.toThrow()
})
})
describe("onCancellationRequested", () => {
it("should return a disposable", () => {
const source = new CancellationTokenSource()
const listener = vi.fn()
const disposable = source.token.onCancellationRequested(listener)
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
it("should stop listening after disposing", () => {
const source = new CancellationTokenSource()
const listener = vi.fn()
const disposable = source.token.onCancellationRequested(listener)
disposable.dispose()
source.cancel()
expect(listener).not.toHaveBeenCalled()
})
it("should call listener immediately if already cancelled", () => {
const source = new CancellationTokenSource()
source.cancel()
const listener = vi.fn()
source.token.onCancellationRequested(listener)
// Event was already fired, listener added after won't be called
// This matches VSCode behavior
expect(listener).not.toHaveBeenCalled()
})
it("should support multiple listeners", () => {
const source = new CancellationTokenSource()
const listener1 = vi.fn()
const listener2 = vi.fn()
source.token.onCancellationRequested(listener1)
source.token.onCancellationRequested(listener2)
source.cancel()
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).toHaveBeenCalledTimes(1)
})
})
})

View file

@ -0,0 +1,157 @@
import { CommandsAPI } from "../api/CommandsAPI.js"
describe("CommandsAPI", () => {
let commands: CommandsAPI
beforeEach(() => {
commands = new CommandsAPI()
})
describe("registerCommand()", () => {
it("should register a command", () => {
const callback = vi.fn()
commands.registerCommand("test.command", callback)
commands.executeCommand("test.command")
expect(callback).toHaveBeenCalled()
})
it("should return a disposable", () => {
const callback = vi.fn()
const disposable = commands.registerCommand("test.command", callback)
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
it("should unregister command on dispose", async () => {
const callback = vi.fn()
const disposable = commands.registerCommand("test.command", callback)
disposable.dispose()
await commands.executeCommand("test.command")
expect(callback).not.toHaveBeenCalled()
})
it("should allow registering multiple commands", () => {
const callback1 = vi.fn()
const callback2 = vi.fn()
commands.registerCommand("test.command1", callback1)
commands.registerCommand("test.command2", callback2)
commands.executeCommand("test.command1")
commands.executeCommand("test.command2")
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
})
})
describe("executeCommand()", () => {
it("should execute registered command", async () => {
const callback = vi.fn().mockReturnValue("result")
commands.registerCommand("test.command", callback)
const result = await commands.executeCommand("test.command")
expect(result).toBe("result")
})
it("should pass arguments to command handler", async () => {
const callback = vi.fn()
commands.registerCommand("test.command", callback)
await commands.executeCommand("test.command", "arg1", "arg2", 123)
expect(callback).toHaveBeenCalledWith("arg1", "arg2", 123)
})
it("should return promise for unknown command", () => {
const result = commands.executeCommand("unknown.command")
expect(result).toBeInstanceOf(Promise)
})
it("should resolve to undefined for unknown command", async () => {
const result = await commands.executeCommand("unknown.command")
expect(result).toBeUndefined()
})
it("should reject if handler throws", async () => {
commands.registerCommand("test.error", () => {
throw new Error("Test error")
})
await expect(commands.executeCommand("test.error")).rejects.toThrow("Test error")
})
it("should handle async command handlers", async () => {
commands.registerCommand("test.async", async () => {
return "async result"
})
const result = await commands.executeCommand("test.async")
expect(result).toBe("async result")
})
})
describe("built-in commands", () => {
it("should handle workbench.action.files.saveFiles", async () => {
const result = await commands.executeCommand("workbench.action.files.saveFiles")
expect(result).toBeUndefined()
})
it("should handle workbench.action.closeWindow", async () => {
const result = await commands.executeCommand("workbench.action.closeWindow")
expect(result).toBeUndefined()
})
it("should handle workbench.action.reloadWindow", async () => {
const result = await commands.executeCommand("workbench.action.reloadWindow")
expect(result).toBeUndefined()
})
})
describe("generic type support", () => {
it("should support typed return values", async () => {
commands.registerCommand("test.typed", () => 42)
const result = await commands.executeCommand<number>("test.typed")
expect(result).toBe(42)
})
it("should support complex return types", async () => {
const expected = { name: "test", value: 123 }
commands.registerCommand("test.object", () => expected)
const result = await commands.executeCommand<{ name: string; value: number }>("test.object")
expect(result).toEqual(expected)
})
})
describe("command overwriting", () => {
it("should allow registering same command multiple times", () => {
const callback1 = vi.fn().mockReturnValue(1)
const callback2 = vi.fn().mockReturnValue(2)
commands.registerCommand("test.command", callback1)
commands.registerCommand("test.command", callback2)
// Last registration wins
const result = commands.executeCommand("test.command")
expect(result).resolves.toBe(2)
})
})
})

View file

@ -0,0 +1,133 @@
import { EventEmitter } from "../classes/EventEmitter.js"
describe("EventEmitter", () => {
describe("event subscription", () => {
it("should subscribe and receive events", () => {
const emitter = new EventEmitter<string>()
const listener = vi.fn()
emitter.event(listener)
emitter.fire("test")
expect(listener).toHaveBeenCalledWith("test")
expect(listener).toHaveBeenCalledTimes(1)
})
it("should support multiple listeners", () => {
const emitter = new EventEmitter<number>()
const listener1 = vi.fn()
const listener2 = vi.fn()
emitter.event(listener1)
emitter.event(listener2)
emitter.fire(42)
expect(listener1).toHaveBeenCalledWith(42)
expect(listener2).toHaveBeenCalledWith(42)
})
it("should bind thisArgs when provided", () => {
const emitter = new EventEmitter<string>()
const context = { name: "test", capturedThis: null as unknown }
emitter.event(function (this: typeof context) {
this.capturedThis = this
}, context)
emitter.fire("event")
expect(context.capturedThis).toBe(context)
})
it("should add disposable to array when provided", () => {
const emitter = new EventEmitter<string>()
const disposables: { dispose: () => void }[] = []
emitter.event(() => {}, undefined, disposables)
expect(disposables).toHaveLength(1)
expect(typeof disposables[0]?.dispose).toBe("function")
})
})
describe("dispose subscription", () => {
it("should stop receiving events after dispose", () => {
const emitter = new EventEmitter<string>()
const listener = vi.fn()
const disposable = emitter.event(listener)
emitter.fire("before")
disposable.dispose()
emitter.fire("after")
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith("before")
})
})
describe("dispose emitter", () => {
it("should remove all listeners on dispose", () => {
const emitter = new EventEmitter<string>()
const listener1 = vi.fn()
const listener2 = vi.fn()
emitter.event(listener1)
emitter.event(listener2)
emitter.dispose()
emitter.fire("test")
expect(listener1).not.toHaveBeenCalled()
expect(listener2).not.toHaveBeenCalled()
})
it("should have zero listeners after dispose", () => {
const emitter = new EventEmitter<string>()
emitter.event(() => {})
emitter.event(() => {})
expect(emitter.listenerCount).toBe(2)
emitter.dispose()
expect(emitter.listenerCount).toBe(0)
})
})
describe("error handling", () => {
it("should not fail if a listener throws", () => {
const emitter = new EventEmitter<string>()
const goodListener = vi.fn()
emitter.event(() => {
throw new Error("Listener error")
})
emitter.event(goodListener)
// Should not throw
expect(() => emitter.fire("test")).not.toThrow()
// Good listener should still be called
expect(goodListener).toHaveBeenCalledWith("test")
})
})
describe("listenerCount", () => {
it("should track number of listeners", () => {
const emitter = new EventEmitter<string>()
expect(emitter.listenerCount).toBe(0)
const d1 = emitter.event(() => {})
expect(emitter.listenerCount).toBe(1)
const d2 = emitter.event(() => {})
expect(emitter.listenerCount).toBe(2)
d1.dispose()
expect(emitter.listenerCount).toBe(1)
d2.dispose()
expect(emitter.listenerCount).toBe(0)
})
})
})

View file

@ -0,0 +1,343 @@
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
import * as fs from "fs"
import * as path from "path"
import { tmpdir } from "os"
describe("ExtensionContextImpl", () => {
let tempDir: string
let extensionPath: string
let workspacePath: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), "ext-context-test-"))
extensionPath = path.join(tempDir, "extension")
workspacePath = path.join(tempDir, "workspace")
fs.mkdirSync(extensionPath, { recursive: true })
fs.mkdirSync(workspacePath, { recursive: true })
})
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
describe("constructor", () => {
it("should create context with extension path", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
expect(context.extensionPath).toBe(extensionPath)
expect(context.extensionUri.fsPath).toBe(extensionPath)
})
it("should use default extension mode (Production)", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
expect(context.extensionMode).toBe(1) // Production
})
it("should allow custom extension mode", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
extensionMode: 2, // Development
})
expect(context.extensionMode).toBe(2)
})
it("should initialize empty subscriptions array", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
expect(context.subscriptions).toEqual([])
})
it("should initialize environmentVariableCollection", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
expect(context.environmentVariableCollection).toEqual({})
})
})
describe("storage paths", () => {
it("should set up global storage path", () => {
const customStorageDir = path.join(tempDir, "custom-storage")
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: customStorageDir,
})
expect(context.globalStoragePath).toContain("global-storage")
expect(context.globalStorageUri.fsPath).toBe(context.globalStoragePath)
})
it("should set up workspace storage path with hash", () => {
const customStorageDir = path.join(tempDir, "custom-storage")
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: customStorageDir,
})
expect(context.storagePath).toContain("workspace-storage")
expect(context.storageUri?.fsPath).toBe(context.storagePath)
})
it("should set up log path", () => {
const customStorageDir = path.join(tempDir, "custom-storage")
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: customStorageDir,
})
expect(context.logPath).toContain("logs")
expect(context.logUri.fsPath).toBe(context.logPath)
})
it("should create storage directories", () => {
const customStorageDir = path.join(tempDir, "custom-storage")
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: customStorageDir,
})
expect(fs.existsSync(context.globalStoragePath)).toBe(true)
expect(fs.existsSync(context.storagePath!)).toBe(true)
expect(fs.existsSync(context.logPath)).toBe(true)
})
it("should generate different workspace hashes for different paths", () => {
const workspace1 = path.join(tempDir, "workspace1")
const workspace2 = path.join(tempDir, "workspace2")
fs.mkdirSync(workspace1, { recursive: true })
fs.mkdirSync(workspace2, { recursive: true })
const context1 = new ExtensionContextImpl({
extensionPath,
workspacePath: workspace1,
storageDir: path.join(tempDir, "storage1"),
})
const context2 = new ExtensionContextImpl({
extensionPath,
workspacePath: workspace2,
storageDir: path.join(tempDir, "storage2"),
})
// The hashes should be different
const hash1 = path.basename(context1.storagePath!)
const hash2 = path.basename(context2.storagePath!)
expect(hash1).not.toBe(hash2)
})
})
describe("workspaceState", () => {
it("should provide workspaceState memento", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: path.join(tempDir, "storage"),
})
expect(context.workspaceState).toBeDefined()
expect(typeof context.workspaceState.get).toBe("function")
expect(typeof context.workspaceState.update).toBe("function")
expect(typeof context.workspaceState.keys).toBe("function")
})
it("should persist workspace state", async () => {
const storageDir = path.join(tempDir, "storage")
const context1 = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir,
})
await context1.workspaceState.update("testKey", "testValue")
// Create new context with same storage
const context2 = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir,
})
expect(context2.workspaceState.get("testKey")).toBe("testValue")
})
})
describe("globalState", () => {
it("should provide globalState memento", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: path.join(tempDir, "storage"),
})
expect(context.globalState).toBeDefined()
expect(typeof context.globalState.get).toBe("function")
expect(typeof context.globalState.update).toBe("function")
expect(typeof context.globalState.keys).toBe("function")
})
it("should have setKeysForSync method", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: path.join(tempDir, "storage"),
})
expect(typeof context.globalState.setKeysForSync).toBe("function")
// Should not throw
expect(() => context.globalState.setKeysForSync(["key1", "key2"])).not.toThrow()
})
it("should persist global state", async () => {
const storageDir = path.join(tempDir, "storage")
const context1 = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir,
})
await context1.globalState.update("globalKey", "globalValue")
// Create new context with same storage
const context2 = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir,
})
expect(context2.globalState.get("globalKey")).toBe("globalValue")
})
})
describe("secrets", () => {
it("should provide secrets storage", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: path.join(tempDir, "storage"),
})
expect(context.secrets).toBeDefined()
expect(typeof context.secrets.get).toBe("function")
expect(typeof context.secrets.store).toBe("function")
expect(typeof context.secrets.delete).toBe("function")
})
it("should persist secrets", async () => {
const storageDir = path.join(tempDir, "storage")
const context1 = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir,
})
await context1.secrets.store("apiKey", "secret123")
// Create new context with same storage
const context2 = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir,
})
const secret = await context2.secrets.get("apiKey")
expect(secret).toBe("secret123")
})
})
describe("dispose()", () => {
it("should dispose all subscriptions", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
const disposable1 = { dispose: vi.fn() }
const disposable2 = { dispose: vi.fn() }
context.subscriptions.push(disposable1)
context.subscriptions.push(disposable2)
context.dispose()
expect(disposable1.dispose).toHaveBeenCalledTimes(1)
expect(disposable2.dispose).toHaveBeenCalledTimes(1)
})
it("should clear subscriptions array after dispose", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
context.subscriptions.push({ dispose: () => {} })
context.subscriptions.push({ dispose: () => {} })
context.dispose()
expect(context.subscriptions).toEqual([])
})
it("should handle disposal errors gracefully", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
// Add a disposable that throws
context.subscriptions.push({
dispose: () => {
throw new Error("Disposal error")
},
})
// Add a normal disposable
const normalDisposable = { dispose: vi.fn() }
context.subscriptions.push(normalDisposable)
// Should not throw
expect(() => context.dispose()).not.toThrow()
// Normal disposable should still be called
expect(normalDisposable.dispose).toHaveBeenCalled()
})
})
describe("default storage directory", () => {
it("should use home directory based default when no storageDir provided", () => {
const context = new ExtensionContextImpl({
extensionPath,
workspacePath,
})
// Should contain .vscode-mock in the path
expect(context.globalStoragePath).toContain(".vscode-mock")
})
})
})

View file

@ -0,0 +1,129 @@
import * as fs from "fs"
import * as path from "path"
import { tmpdir } from "os"
import { FileSystemAPI } from "../api/FileSystemAPI.js"
import { Uri } from "../classes/Uri.js"
describe("FileSystemAPI", () => {
let tempDir: string
let fsAPI: FileSystemAPI
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), "fs-api-test-"))
fsAPI = new FileSystemAPI()
})
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
describe("stat()", () => {
it("should stat a file", async () => {
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "test content")
const uri = Uri.file(filePath)
const stat = await fsAPI.stat(uri)
expect(stat.type).toBe(1) // File
expect(stat.size).toBeGreaterThan(0)
expect(stat.mtime).toBeGreaterThan(0)
expect(stat.ctime).toBeGreaterThan(0)
})
it("should stat a directory", async () => {
const uri = Uri.file(tempDir)
const stat = await fsAPI.stat(uri)
expect(stat.type).toBe(2) // Directory
})
it("should return default stat for non-existent file", async () => {
const uri = Uri.file(path.join(tempDir, "nonexistent.txt"))
const stat = await fsAPI.stat(uri)
expect(stat.type).toBe(1) // File (default)
expect(stat.size).toBe(0)
})
})
describe("readFile()", () => {
it("should read file content", async () => {
const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "Hello, world!")
const uri = Uri.file(filePath)
const content = await fsAPI.readFile(uri)
expect(Buffer.from(content).toString()).toBe("Hello, world!")
})
it("should throw FileSystemError for non-existent file", async () => {
const uri = Uri.file(path.join(tempDir, "nonexistent.txt"))
await expect(fsAPI.readFile(uri)).rejects.toThrow()
})
})
describe("writeFile()", () => {
it("should write file content", async () => {
const filePath = path.join(tempDir, "output.txt")
const uri = Uri.file(filePath)
await fsAPI.writeFile(uri, new TextEncoder().encode("Written content"))
expect(fs.readFileSync(filePath, "utf-8")).toBe("Written content")
})
it("should create parent directories if they don't exist", async () => {
const filePath = path.join(tempDir, "subdir", "nested", "file.txt")
const uri = Uri.file(filePath)
await fsAPI.writeFile(uri, new TextEncoder().encode("Nested content"))
expect(fs.readFileSync(filePath, "utf-8")).toBe("Nested content")
})
})
describe("delete()", () => {
it("should delete a file", async () => {
const filePath = path.join(tempDir, "to-delete.txt")
fs.writeFileSync(filePath, "delete me")
const uri = Uri.file(filePath)
await fsAPI.delete(uri)
expect(fs.existsSync(filePath)).toBe(false)
})
it("should throw error for non-existent file", async () => {
const uri = Uri.file(path.join(tempDir, "nonexistent.txt"))
await expect(fsAPI.delete(uri)).rejects.toThrow()
})
})
describe("createDirectory()", () => {
it("should create a directory", async () => {
const dirPath = path.join(tempDir, "new-dir")
const uri = Uri.file(dirPath)
await fsAPI.createDirectory(uri)
expect(fs.existsSync(dirPath)).toBe(true)
expect(fs.statSync(dirPath).isDirectory()).toBe(true)
})
it("should create nested directories", async () => {
const dirPath = path.join(tempDir, "a", "b", "c")
const uri = Uri.file(dirPath)
await fsAPI.createDirectory(uri)
expect(fs.existsSync(dirPath)).toBe(true)
})
})
})

View file

@ -0,0 +1,117 @@
import { OutputChannel } from "../classes/OutputChannel.js"
import { setLogger } from "../utils/logger.js"
describe("OutputChannel", () => {
let mockLogger: {
debug: ReturnType<typeof vi.fn>
info: ReturnType<typeof vi.fn>
warn: ReturnType<typeof vi.fn>
error: ReturnType<typeof vi.fn>
}
beforeEach(() => {
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}
setLogger(mockLogger)
})
describe("constructor", () => {
it("should create an output channel with the given name", () => {
const channel = new OutputChannel("TestChannel")
expect(channel.name).toBe("TestChannel")
})
})
describe("name property", () => {
it("should return the channel name", () => {
const channel = new OutputChannel("MyChannel")
expect(channel.name).toBe("MyChannel")
})
})
describe("append()", () => {
it("should log the value with channel name prefix", () => {
const channel = new OutputChannel("TestChannel")
channel.append("test message")
expect(mockLogger.info).toHaveBeenCalledWith(
"[TestChannel] test message",
"VSCode.OutputChannel",
undefined,
)
})
it("should handle empty strings", () => {
const channel = new OutputChannel("TestChannel")
channel.append("")
expect(mockLogger.info).toHaveBeenCalledWith("[TestChannel] ", "VSCode.OutputChannel", undefined)
})
})
describe("appendLine()", () => {
it("should log the value with channel name prefix", () => {
const channel = new OutputChannel("TestChannel")
channel.appendLine("line message")
expect(mockLogger.info).toHaveBeenCalledWith(
"[TestChannel] line message",
"VSCode.OutputChannel",
undefined,
)
})
it("should handle multi-line strings", () => {
const channel = new OutputChannel("TestChannel")
channel.appendLine("line1\nline2")
expect(mockLogger.info).toHaveBeenCalledWith(
"[TestChannel] line1\nline2",
"VSCode.OutputChannel",
undefined,
)
})
})
describe("clear()", () => {
it("should not throw when called", () => {
const channel = new OutputChannel("TestChannel")
expect(() => channel.clear()).not.toThrow()
})
})
describe("show()", () => {
it("should not throw when called without arguments", () => {
const channel = new OutputChannel("TestChannel")
expect(() => channel.show()).not.toThrow()
})
})
describe("hide()", () => {
it("should not throw when called", () => {
const channel = new OutputChannel("TestChannel")
expect(() => channel.hide()).not.toThrow()
})
})
describe("dispose()", () => {
it("should not throw when called", () => {
const channel = new OutputChannel("TestChannel")
expect(() => channel.dispose()).not.toThrow()
})
})
})

View file

@ -0,0 +1,139 @@
import { Position } from "../classes/Position.js"
describe("Position", () => {
describe("constructor", () => {
it("should create a position with line and character", () => {
const pos = new Position(5, 10)
expect(pos.line).toBe(5)
expect(pos.character).toBe(10)
})
it("should reject negative line numbers", () => {
expect(() => new Position(-1, 0)).toThrow("Line number must be non-negative")
})
it("should reject negative character offsets", () => {
expect(() => new Position(0, -1)).toThrow("Character offset must be non-negative")
})
})
describe("isEqual()", () => {
it("should return true for equal positions", () => {
const pos1 = new Position(5, 10)
const pos2 = new Position(5, 10)
expect(pos1.isEqual(pos2)).toBe(true)
})
it("should return false for different positions", () => {
const pos1 = new Position(5, 10)
const pos2 = new Position(5, 11)
expect(pos1.isEqual(pos2)).toBe(false)
})
})
describe("isBefore()", () => {
it("should return true when line is before", () => {
const pos1 = new Position(3, 10)
const pos2 = new Position(5, 5)
expect(pos1.isBefore(pos2)).toBe(true)
})
it("should return true when same line but character before", () => {
const pos1 = new Position(5, 8)
const pos2 = new Position(5, 10)
expect(pos1.isBefore(pos2)).toBe(true)
})
it("should return false when equal", () => {
const pos1 = new Position(5, 10)
const pos2 = new Position(5, 10)
expect(pos1.isBefore(pos2)).toBe(false)
})
it("should return false when after", () => {
const pos1 = new Position(6, 0)
const pos2 = new Position(5, 10)
expect(pos1.isBefore(pos2)).toBe(false)
})
})
describe("isAfter()", () => {
it("should return true when line is after", () => {
const pos1 = new Position(5, 10)
const pos2 = new Position(3, 10)
expect(pos1.isAfter(pos2)).toBe(true)
})
it("should return false when equal", () => {
const pos1 = new Position(5, 10)
const pos2 = new Position(5, 10)
expect(pos1.isAfter(pos2)).toBe(false)
})
})
describe("compareTo()", () => {
it("should return -1 when before", () => {
const pos1 = new Position(3, 10)
const pos2 = new Position(5, 10)
expect(pos1.compareTo(pos2)).toBe(-1)
})
it("should return 0 when equal", () => {
const pos1 = new Position(5, 10)
const pos2 = new Position(5, 10)
expect(pos1.compareTo(pos2)).toBe(0)
})
it("should return 1 when after", () => {
const pos1 = new Position(7, 10)
const pos2 = new Position(5, 10)
expect(pos1.compareTo(pos2)).toBe(1)
})
})
describe("translate()", () => {
it("should translate by delta values", () => {
const pos = new Position(5, 10)
const translated = pos.translate(2, 3)
expect(translated.line).toBe(7)
expect(translated.character).toBe(13)
})
it("should translate by change object", () => {
const pos = new Position(5, 10)
const translated = pos.translate({ lineDelta: 1, characterDelta: -2 })
expect(translated.line).toBe(6)
expect(translated.character).toBe(8)
})
it("should handle omitted deltas as zero", () => {
const pos = new Position(5, 10)
const translated = pos.translate()
expect(translated.line).toBe(5)
expect(translated.character).toBe(10)
})
})
describe("with()", () => {
it("should create new position with changed line", () => {
const pos = new Position(5, 10)
const modified = pos.with(8)
expect(modified.line).toBe(8)
expect(modified.character).toBe(10)
})
it("should create new position with change object", () => {
const pos = new Position(5, 10)
const modified = pos.with({ line: 8, character: 15 })
expect(modified.line).toBe(8)
expect(modified.character).toBe(15)
})
it("should preserve unchanged properties", () => {
const pos = new Position(5, 10)
const modified = pos.with({ line: 8 })
expect(modified.line).toBe(8)
expect(modified.character).toBe(10)
})
})
})

View file

@ -0,0 +1,153 @@
import { Range } from "../classes/Range.js"
import { Position } from "../classes/Position.js"
describe("Range", () => {
describe("constructor", () => {
it("should create range from Position objects", () => {
const start = new Position(0, 0)
const end = new Position(5, 10)
const range = new Range(start, end)
expect(range.start.line).toBe(0)
expect(range.start.character).toBe(0)
expect(range.end.line).toBe(5)
expect(range.end.character).toBe(10)
})
it("should create range from numbers", () => {
const range = new Range(0, 0, 5, 10)
expect(range.start.line).toBe(0)
expect(range.start.character).toBe(0)
expect(range.end.line).toBe(5)
expect(range.end.character).toBe(10)
})
})
describe("isEmpty", () => {
it("should return true for empty range", () => {
const range = new Range(5, 10, 5, 10)
expect(range.isEmpty).toBe(true)
})
it("should return false for non-empty range", () => {
const range = new Range(5, 10, 5, 15)
expect(range.isEmpty).toBe(false)
})
})
describe("isSingleLine", () => {
it("should return true for single line range", () => {
const range = new Range(5, 0, 5, 10)
expect(range.isSingleLine).toBe(true)
})
it("should return false for multi-line range", () => {
const range = new Range(5, 0, 6, 10)
expect(range.isSingleLine).toBe(false)
})
})
describe("contains()", () => {
it("should return true when range contains position", () => {
const range = new Range(0, 0, 10, 10)
const pos = new Position(5, 5)
expect(range.contains(pos)).toBe(true)
})
it("should return false when position is outside range", () => {
const range = new Range(0, 0, 10, 10)
const pos = new Position(15, 5)
expect(range.contains(pos)).toBe(false)
})
it("should return true when range contains another range", () => {
const outer = new Range(0, 0, 10, 10)
const inner = new Range(2, 2, 8, 8)
expect(outer.contains(inner)).toBe(true)
})
it("should return false when range does not contain another range", () => {
const range1 = new Range(0, 0, 5, 10)
const range2 = new Range(6, 0, 10, 10)
expect(range1.contains(range2)).toBe(false)
})
})
describe("isEqual()", () => {
it("should return true for equal ranges", () => {
const range1 = new Range(0, 0, 5, 10)
const range2 = new Range(0, 0, 5, 10)
expect(range1.isEqual(range2)).toBe(true)
})
it("should return false for different ranges", () => {
const range1 = new Range(0, 0, 5, 10)
const range2 = new Range(0, 0, 5, 11)
expect(range1.isEqual(range2)).toBe(false)
})
})
describe("intersection()", () => {
it("should return intersection of overlapping ranges", () => {
const range1 = new Range(0, 0, 10, 10)
const range2 = new Range(5, 5, 15, 15)
const intersection = range1.intersection(range2)
expect(intersection).toBeDefined()
expect(intersection!.start.line).toBe(5)
expect(intersection!.start.character).toBe(5)
expect(intersection!.end.line).toBe(10)
expect(intersection!.end.character).toBe(10)
})
it("should return undefined for non-overlapping ranges", () => {
const range1 = new Range(0, 0, 5, 10)
const range2 = new Range(10, 0, 15, 10)
const intersection = range1.intersection(range2)
expect(intersection).toBeUndefined()
})
})
describe("union()", () => {
it("should return union of two ranges", () => {
const range1 = new Range(0, 0, 5, 10)
const range2 = new Range(3, 5, 8, 15)
const union = range1.union(range2)
expect(union.start.line).toBe(0)
expect(union.start.character).toBe(0)
expect(union.end.line).toBe(8)
expect(union.end.character).toBe(15)
})
it("should handle non-overlapping ranges", () => {
const range1 = new Range(0, 0, 2, 10)
const range2 = new Range(5, 0, 8, 10)
const union = range1.union(range2)
expect(union.start.line).toBe(0)
expect(union.end.line).toBe(8)
})
})
describe("with()", () => {
it("should create new range with modified start", () => {
const range = new Range(0, 0, 5, 10)
const modified = range.with(new Position(1, 0))
expect(modified.start.line).toBe(1)
expect(modified.end.line).toBe(5)
})
it("should create new range with change object", () => {
const range = new Range(0, 0, 5, 10)
const modified = range.with({ end: new Position(8, 15) })
expect(modified.start.line).toBe(0)
expect(modified.end.line).toBe(8)
expect(modified.end.character).toBe(15)
})
})
})

View file

@ -0,0 +1,123 @@
import { Selection } from "../classes/Selection.js"
import { Position } from "../classes/Position.js"
describe("Selection", () => {
describe("constructor with Position objects", () => {
it("should create selection from Position objects", () => {
const anchor = new Position(0, 0)
const active = new Position(5, 10)
const selection = new Selection(anchor, active)
expect(selection.anchor.line).toBe(0)
expect(selection.anchor.character).toBe(0)
expect(selection.active.line).toBe(5)
expect(selection.active.character).toBe(10)
})
it("should set start and end correctly for non-reversed selection", () => {
const anchor = new Position(0, 0)
const active = new Position(5, 10)
const selection = new Selection(anchor, active)
expect(selection.start.line).toBe(0)
expect(selection.start.character).toBe(0)
expect(selection.end.line).toBe(5)
expect(selection.end.character).toBe(10)
})
it("should set start and end correctly for reversed selection", () => {
const anchor = new Position(5, 10)
const active = new Position(0, 0)
const selection = new Selection(anchor, active)
// Start/end are inherited from Range, which normalizes
expect(selection.anchor.line).toBe(5)
expect(selection.anchor.character).toBe(10)
expect(selection.active.line).toBe(0)
expect(selection.active.character).toBe(0)
})
})
describe("constructor with line/character numbers", () => {
it("should create selection from line and character numbers", () => {
const selection = new Selection(0, 0, 5, 10)
expect(selection.anchor.line).toBe(0)
expect(selection.anchor.character).toBe(0)
expect(selection.active.line).toBe(5)
expect(selection.active.character).toBe(10)
})
it("should handle reversed selection with numbers", () => {
const selection = new Selection(5, 10, 0, 0)
expect(selection.anchor.line).toBe(5)
expect(selection.anchor.character).toBe(10)
expect(selection.active.line).toBe(0)
expect(selection.active.character).toBe(0)
})
})
describe("isReversed", () => {
it("should return false when active is after anchor", () => {
const selection = new Selection(0, 0, 5, 10)
expect(selection.isReversed).toBe(false)
})
it("should return true when active is before anchor", () => {
const selection = new Selection(5, 10, 0, 0)
expect(selection.isReversed).toBe(true)
})
it("should return false when anchor equals active", () => {
const selection = new Selection(5, 10, 5, 10)
expect(selection.isReversed).toBe(false)
})
it("should return true when same line but active character is before anchor", () => {
const selection = new Selection(5, 10, 5, 5)
expect(selection.isReversed).toBe(true)
})
it("should return false when same line and active character is after anchor", () => {
const selection = new Selection(5, 5, 5, 10)
expect(selection.isReversed).toBe(false)
})
})
describe("inherited Range properties", () => {
it("should have isEmpty property", () => {
const emptySelection = new Selection(5, 10, 5, 10)
expect(emptySelection.isEmpty).toBe(true)
const nonEmptySelection = new Selection(0, 0, 5, 10)
expect(nonEmptySelection.isEmpty).toBe(false)
})
it("should have isSingleLine property", () => {
const singleLineSelection = new Selection(5, 0, 5, 10)
expect(singleLineSelection.isSingleLine).toBe(true)
const multiLineSelection = new Selection(0, 0, 5, 10)
expect(multiLineSelection.isSingleLine).toBe(false)
})
it("should support contains method", () => {
const selection = new Selection(0, 0, 10, 10)
const pos = new Position(5, 5)
expect(selection.contains(pos)).toBe(true)
const outsidePos = new Position(15, 5)
expect(selection.contains(outsidePos)).toBe(false)
})
it("should support isEqual method", () => {
const selection1 = new Selection(0, 0, 5, 10)
const selection2 = new Selection(0, 0, 5, 10)
const selection3 = new Selection(0, 0, 5, 11)
expect(selection1.isEqual(selection2)).toBe(true)
expect(selection1.isEqual(selection3)).toBe(false)
})
})
})

View file

@ -0,0 +1,214 @@
import { StatusBarItem } from "../classes/StatusBarItem.js"
import { StatusBarAlignment } from "../types.js"
describe("StatusBarItem", () => {
describe("constructor", () => {
it("should create with alignment", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.alignment).toBe(StatusBarAlignment.Left)
})
it("should create with alignment and priority", () => {
const item = new StatusBarItem(StatusBarAlignment.Right, 100)
expect(item.alignment).toBe(StatusBarAlignment.Right)
expect(item.priority).toBe(100)
})
it("should have undefined priority when not provided", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.priority).toBeUndefined()
})
})
describe("text property", () => {
it("should have empty text initially", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.text).toBe("")
})
it("should allow setting text", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.text = "Hello"
expect(item.text).toBe("Hello")
})
})
describe("tooltip property", () => {
it("should be undefined initially", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.tooltip).toBeUndefined()
})
it("should allow setting tooltip", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.tooltip = "My tooltip"
expect(item.tooltip).toBe("My tooltip")
})
it("should allow setting to undefined", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.tooltip = "tooltip"
item.tooltip = undefined
expect(item.tooltip).toBeUndefined()
})
})
describe("command property", () => {
it("should be undefined initially", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.command).toBeUndefined()
})
it("should allow setting command", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.command = "myExtension.doSomething"
expect(item.command).toBe("myExtension.doSomething")
})
})
describe("color property", () => {
it("should be undefined initially", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.color).toBeUndefined()
})
it("should allow setting color", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.color = "#ff0000"
expect(item.color).toBe("#ff0000")
})
})
describe("backgroundColor property", () => {
it("should be undefined initially", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.backgroundColor).toBeUndefined()
})
it("should allow setting backgroundColor", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.backgroundColor = "#00ff00"
expect(item.backgroundColor).toBe("#00ff00")
})
})
describe("isVisible property", () => {
it("should be false initially", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(item.isVisible).toBe(false)
})
it("should be true after show()", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.show()
expect(item.isVisible).toBe(true)
})
it("should be false after hide()", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.show()
item.hide()
expect(item.isVisible).toBe(false)
})
})
describe("show()", () => {
it("should make item visible", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.show()
expect(item.isVisible).toBe(true)
})
it("should be idempotent", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.show()
item.show()
expect(item.isVisible).toBe(true)
})
})
describe("hide()", () => {
it("should make item invisible", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.show()
item.hide()
expect(item.isVisible).toBe(false)
})
it("should be safe to call when already hidden", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(() => item.hide()).not.toThrow()
expect(item.isVisible).toBe(false)
})
})
describe("dispose()", () => {
it("should make item invisible", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
item.show()
item.dispose()
expect(item.isVisible).toBe(false)
})
it("should be safe to call multiple times", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
expect(() => {
item.dispose()
item.dispose()
}).not.toThrow()
})
})
describe("alignment property", () => {
it("should be readonly", () => {
const item = new StatusBarItem(StatusBarAlignment.Left)
// TypeScript prevents reassignment at compile time
// Just verify the value is what we expect
expect(item.alignment).toBe(StatusBarAlignment.Left)
})
})
describe("priority property", () => {
it("should be readonly", () => {
const item = new StatusBarItem(StatusBarAlignment.Left, 50)
expect(item.priority).toBe(50)
})
})
})

View file

@ -0,0 +1,163 @@
import { TabGroupsAPI, type Tab, type TabGroup } from "../api/TabGroupsAPI.js"
import { Uri } from "../classes/Uri.js"
describe("TabGroupsAPI", () => {
let tabGroups: TabGroupsAPI
beforeEach(() => {
tabGroups = new TabGroupsAPI()
})
describe("all property", () => {
it("should return empty array initially", () => {
expect(tabGroups.all).toEqual([])
})
it("should return array of TabGroup", () => {
expect(Array.isArray(tabGroups.all)).toBe(true)
})
})
describe("onDidChangeTabs()", () => {
it("should return a disposable", () => {
const disposable = tabGroups.onDidChangeTabs(() => {})
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
it("should call listener when _simulateTabChange is called", () => {
const listener = vi.fn()
tabGroups.onDidChangeTabs(listener)
tabGroups._simulateTabChange()
expect(listener).toHaveBeenCalledTimes(1)
})
it("should not call listener after dispose", () => {
const listener = vi.fn()
const disposable = tabGroups.onDidChangeTabs(listener)
disposable.dispose()
tabGroups._simulateTabChange()
expect(listener).not.toHaveBeenCalled()
})
it("should support multiple listeners", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
tabGroups.onDidChangeTabs(listener1)
tabGroups.onDidChangeTabs(listener2)
tabGroups._simulateTabChange()
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).toHaveBeenCalledTimes(1)
})
})
describe("close()", () => {
it("should return false when tab is not found", async () => {
const mockTab: Tab = {
input: { uri: Uri.file("/test/file.txt") },
label: "file.txt",
isActive: true,
isDirty: false,
}
const result = await tabGroups.close(mockTab)
expect(result).toBe(false)
})
it("should return a promise", () => {
const mockTab: Tab = {
input: { uri: Uri.file("/test/file.txt") },
label: "file.txt",
isActive: true,
isDirty: false,
}
const result = tabGroups.close(mockTab)
expect(result).toBeInstanceOf(Promise)
})
})
describe("_simulateTabChange()", () => {
it("should fire the onDidChangeTabs event", () => {
const listener = vi.fn()
tabGroups.onDidChangeTabs(listener)
tabGroups._simulateTabChange()
expect(listener).toHaveBeenCalled()
})
})
describe("dispose()", () => {
it("should not throw when called", () => {
expect(() => tabGroups.dispose()).not.toThrow()
})
it("should stop firing events after dispose", () => {
const listener = vi.fn()
tabGroups.onDidChangeTabs(listener)
tabGroups.dispose()
// After dispose, internal emitter is disposed so new events shouldn't fire
// But existing listeners may still be registered
})
it("should be safe to call multiple times", () => {
expect(() => {
tabGroups.dispose()
tabGroups.dispose()
}).not.toThrow()
})
})
})
describe("Tab interface", () => {
it("should have required properties", () => {
const tab: Tab = {
input: { uri: Uri.file("/test/file.txt") },
label: "file.txt",
isActive: true,
isDirty: false,
}
expect(tab.input).toBeDefined()
expect(tab.label).toBe("file.txt")
expect(tab.isActive).toBe(true)
expect(tab.isDirty).toBe(false)
})
})
describe("TabGroup interface", () => {
it("should have tabs array", () => {
const tabGroup: TabGroup = {
tabs: [],
}
expect(Array.isArray(tabGroup.tabs)).toBe(true)
})
it("should contain Tab objects", () => {
const tab: Tab = {
input: { uri: Uri.file("/test/file.txt") },
label: "file.txt",
isActive: true,
isDirty: false,
}
const tabGroup: TabGroup = {
tabs: [tab],
}
expect(tabGroup.tabs).toHaveLength(1)
expect(tabGroup.tabs[0]).toBe(tab)
})
})

View file

@ -0,0 +1,263 @@
import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js"
import { Position } from "../classes/Position.js"
import { Range } from "../classes/Range.js"
import { Uri } from "../classes/Uri.js"
describe("TextEdit", () => {
describe("constructor", () => {
it("should create a TextEdit with range and newText", () => {
const range = new Range(0, 0, 0, 5)
const edit = new TextEdit(range, "hello")
expect(edit.range.start.line).toBe(0)
expect(edit.range.start.character).toBe(0)
expect(edit.range.end.line).toBe(0)
expect(edit.range.end.character).toBe(5)
expect(edit.newText).toBe("hello")
})
})
describe("replace()", () => {
it("should create a replace edit", () => {
const range = new Range(1, 0, 1, 10)
const edit = TextEdit.replace(range, "replacement")
expect(edit.range.isEqual(range)).toBe(true)
expect(edit.newText).toBe("replacement")
})
it("should handle multi-line ranges", () => {
const range = new Range(0, 0, 5, 10)
const edit = TextEdit.replace(range, "new content")
expect(edit.range.start.line).toBe(0)
expect(edit.range.end.line).toBe(5)
expect(edit.newText).toBe("new content")
})
})
describe("insert()", () => {
it("should create an insert edit at position", () => {
const position = new Position(5, 10)
const edit = TextEdit.insert(position, "inserted text")
expect(edit.range.start.line).toBe(5)
expect(edit.range.start.character).toBe(10)
expect(edit.range.end.line).toBe(5)
expect(edit.range.end.character).toBe(10)
expect(edit.range.isEmpty).toBe(true)
expect(edit.newText).toBe("inserted text")
})
it("should handle insert at beginning of file", () => {
const position = new Position(0, 0)
const edit = TextEdit.insert(position, "prefix")
expect(edit.range.start.isEqual(position)).toBe(true)
expect(edit.newText).toBe("prefix")
})
})
describe("delete()", () => {
it("should create a delete edit", () => {
const range = new Range(0, 5, 0, 10)
const edit = TextEdit.delete(range)
expect(edit.range.isEqual(range)).toBe(true)
expect(edit.newText).toBe("")
})
it("should handle multi-line deletion", () => {
const range = new Range(0, 0, 5, 0)
const edit = TextEdit.delete(range)
expect(edit.range.start.line).toBe(0)
expect(edit.range.end.line).toBe(5)
expect(edit.newText).toBe("")
})
})
describe("setEndOfLine()", () => {
it("should create a setEndOfLine edit", () => {
const edit = TextEdit.setEndOfLine()
expect(edit.range.start.line).toBe(0)
expect(edit.range.start.character).toBe(0)
expect(edit.newText).toBe("")
})
})
})
describe("WorkspaceEdit", () => {
describe("set() and get()", () => {
it("should set and get edits for a URI", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
const edits = [
TextEdit.replace(new Range(0, 0, 0, 5), "hello"),
TextEdit.insert(new Position(1, 0), "world"),
]
workspaceEdit.set(uri, edits)
const retrieved = workspaceEdit.get(uri)
expect(retrieved).toHaveLength(2)
expect(retrieved[0]?.newText).toBe("hello")
expect(retrieved[1]?.newText).toBe("world")
})
it("should return empty array for unknown URI", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/nonexistent.txt")
expect(workspaceEdit.get(uri)).toEqual([])
})
it("should overwrite edits when setting same URI", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "first")])
workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "second")])
const edits = workspaceEdit.get(uri)
expect(edits).toHaveLength(1)
expect(edits[0]?.newText).toBe("second")
})
})
describe("has()", () => {
it("should return true when URI has edits", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "text")])
expect(workspaceEdit.has(uri)).toBe(true)
})
it("should return false when URI has no edits", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
expect(workspaceEdit.has(uri)).toBe(false)
})
})
describe("delete()", () => {
it("should add a delete edit for URI", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
const range = new Range(0, 5, 0, 10)
workspaceEdit.delete(uri, range)
const edits = workspaceEdit.get(uri)
expect(edits).toHaveLength(1)
expect(edits[0]?.newText).toBe("")
expect(edits[0]?.range.start.character).toBe(5)
expect(edits[0]?.range.end.character).toBe(10)
})
it("should append to existing edits", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
workspaceEdit.insert(uri, new Position(0, 0), "text")
workspaceEdit.delete(uri, new Range(1, 0, 1, 5))
const edits = workspaceEdit.get(uri)
expect(edits).toHaveLength(2)
})
})
describe("insert()", () => {
it("should add an insert edit for URI", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
const position = new Position(5, 10)
workspaceEdit.insert(uri, position, "inserted")
const edits = workspaceEdit.get(uri)
expect(edits).toHaveLength(1)
expect(edits[0]?.newText).toBe("inserted")
expect(edits[0]?.range.start.line).toBe(5)
expect(edits[0]?.range.start.character).toBe(10)
})
})
describe("replace()", () => {
it("should add a replace edit for URI", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
const range = new Range(0, 0, 0, 10)
workspaceEdit.replace(uri, range, "replacement")
const edits = workspaceEdit.get(uri)
expect(edits).toHaveLength(1)
expect(edits[0]?.newText).toBe("replacement")
expect(edits[0]?.range.start.line).toBe(0)
expect(edits[0]?.range.end.character).toBe(10)
})
})
describe("size", () => {
it("should return 0 for empty WorkspaceEdit", () => {
const workspaceEdit = new WorkspaceEdit()
expect(workspaceEdit.size).toBe(0)
})
it("should return number of documents with edits", () => {
const workspaceEdit = new WorkspaceEdit()
const uri1 = Uri.file("/path/to/file1.txt")
const uri2 = Uri.file("/path/to/file2.txt")
const uri3 = Uri.file("/path/to/file3.txt")
workspaceEdit.insert(uri1, new Position(0, 0), "text1")
workspaceEdit.insert(uri2, new Position(0, 0), "text2")
workspaceEdit.insert(uri3, new Position(0, 0), "text3")
expect(workspaceEdit.size).toBe(3)
})
it("should count same URI only once", () => {
const workspaceEdit = new WorkspaceEdit()
const uri = Uri.file("/path/to/file.txt")
workspaceEdit.insert(uri, new Position(0, 0), "text1")
workspaceEdit.insert(uri, new Position(1, 0), "text2")
workspaceEdit.insert(uri, new Position(2, 0), "text3")
expect(workspaceEdit.size).toBe(1)
})
})
describe("entries()", () => {
it("should return empty array for empty WorkspaceEdit", () => {
const workspaceEdit = new WorkspaceEdit()
expect(workspaceEdit.entries()).toEqual([])
})
it("should return all URI/edits pairs", () => {
const workspaceEdit = new WorkspaceEdit()
const uri1 = Uri.file("/path/to/file1.txt")
const uri2 = Uri.file("/path/to/file2.txt")
workspaceEdit.insert(uri1, new Position(0, 0), "text1")
workspaceEdit.replace(uri2, new Range(0, 0, 0, 5), "text2")
const entries = workspaceEdit.entries()
expect(entries).toHaveLength(2)
// Entries should have URI-like objects with toString and fsPath
expect(typeof entries[0]?.[0]?.toString).toBe("function")
expect(typeof entries[0]?.[0]?.fsPath).toBe("string")
// Should contain the edits
expect(entries.some((e) => e[1][0]?.newText === "text1")).toBe(true)
expect(entries.some((e) => e[1][0]?.newText === "text2")).toBe(true)
})
})
})

View file

@ -0,0 +1,59 @@
import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js"
describe("TextEditorDecorationType", () => {
describe("constructor", () => {
it("should create with a key", () => {
const decoration = new TextEditorDecorationType("my-decoration")
expect(decoration.key).toBe("my-decoration")
})
it("should allow any string key", () => {
const decoration = new TextEditorDecorationType("decoration-12345")
expect(decoration.key).toBe("decoration-12345")
})
})
describe("key property", () => {
it("should be accessible", () => {
const decoration = new TextEditorDecorationType("test-key")
expect(decoration.key).toBe("test-key")
})
it("should be mutable", () => {
const decoration = new TextEditorDecorationType("original")
decoration.key = "modified"
expect(decoration.key).toBe("modified")
})
})
describe("dispose()", () => {
it("should not throw when called", () => {
const decoration = new TextEditorDecorationType("test")
expect(() => decoration.dispose()).not.toThrow()
})
it("should be safe to call multiple times", () => {
const decoration = new TextEditorDecorationType("test")
expect(() => {
decoration.dispose()
decoration.dispose()
decoration.dispose()
}).not.toThrow()
})
})
describe("Disposable interface", () => {
it("should implement Disposable interface", () => {
const decoration = new TextEditorDecorationType("test")
expect(typeof decoration.dispose).toBe("function")
})
})
})

View file

@ -0,0 +1,102 @@
import { Uri } from "../classes/Uri.js"
describe("Uri", () => {
describe("file()", () => {
it("should create a file URI", () => {
const uri = Uri.file("/path/to/file.txt")
expect(uri.scheme).toBe("file")
expect(uri.path).toBe("/path/to/file.txt")
expect(uri.fsPath).toBe("/path/to/file.txt")
})
it("should handle Windows paths", () => {
const uri = Uri.file("C:\\Users\\test\\file.txt")
expect(uri.scheme).toBe("file")
expect(uri.fsPath).toBe("C:\\Users\\test\\file.txt")
})
})
describe("parse()", () => {
it("should parse HTTP URLs", () => {
const uri = Uri.parse("https://example.com/path?query=1#fragment")
expect(uri.scheme).toBe("https")
expect(uri.authority).toBe("example.com")
expect(uri.path).toBe("/path")
expect(uri.query).toBe("query=1")
expect(uri.fragment).toBe("fragment")
})
it("should parse file URLs", () => {
const uri = Uri.parse("file:///path/to/file.txt")
expect(uri.scheme).toBe("file")
expect(uri.path).toBe("/path/to/file.txt")
})
it("should handle invalid URLs by treating as file paths", () => {
const uri = Uri.parse("/just/a/path")
expect(uri.scheme).toBe("file")
expect(uri.fsPath).toBe("/just/a/path")
})
})
describe("joinPath()", () => {
it("should join path segments", () => {
const base = Uri.file("/base/path")
const joined = Uri.joinPath(base, "sub", "file.txt")
expect(joined.fsPath).toContain("sub")
expect(joined.fsPath).toContain("file.txt")
})
})
describe("with()", () => {
it("should create new URI with modified scheme", () => {
const uri = Uri.file("/path/to/file.txt")
const modified = uri.with({ scheme: "vscode" })
expect(modified.scheme).toBe("vscode")
expect(modified.path).toBe("/path/to/file.txt")
})
it("should create new URI with modified path", () => {
const uri = Uri.parse("https://example.com/old/path")
const modified = uri.with({ path: "/new/path" })
expect(modified.path).toBe("/new/path")
expect(modified.scheme).toBe("https")
})
it("should preserve unchanged properties", () => {
const uri = Uri.parse("https://example.com/path?query=1#fragment")
const modified = uri.with({ path: "/newpath" })
expect(modified.scheme).toBe("https")
expect(modified.query).toBe("query=1")
expect(modified.fragment).toBe("fragment")
})
})
describe("toString()", () => {
it("should convert to URI string", () => {
const uri = Uri.parse("https://example.com/path?query=1#fragment")
const str = uri.toString()
expect(str).toBe("https://example.com/path?query=1#fragment")
})
it("should handle file URIs", () => {
const uri = Uri.file("/path/to/file.txt")
const str = uri.toString()
expect(str).toBe("file:///path/to/file.txt")
})
})
describe("toJSON()", () => {
it("should convert to JSON object", () => {
const uri = Uri.parse("https://example.com/path?query=1#fragment")
const json = uri.toJSON()
expect(json).toEqual({
scheme: "https",
authority: "example.com",
path: "/path",
query: "query=1",
fragment: "fragment",
})
})
})
})

View file

@ -0,0 +1,305 @@
import { WindowAPI } from "../api/WindowAPI.js"
import { Uri } from "../classes/Uri.js"
import { StatusBarAlignment } from "../types.js"
describe("WindowAPI", () => {
let windowAPI: WindowAPI
beforeEach(() => {
windowAPI = new WindowAPI()
})
describe("tabGroups property", () => {
it("should have tabGroups", () => {
expect(windowAPI.tabGroups).toBeDefined()
})
it("should return TabGroupsAPI instance", () => {
expect(typeof windowAPI.tabGroups.onDidChangeTabs).toBe("function")
expect(Array.isArray(windowAPI.tabGroups.all)).toBe(true)
})
})
describe("visibleTextEditors property", () => {
it("should be an empty array initially", () => {
expect(windowAPI.visibleTextEditors).toEqual([])
})
})
describe("createOutputChannel()", () => {
it("should create an output channel with the given name", () => {
const channel = windowAPI.createOutputChannel("TestChannel")
expect(channel.name).toBe("TestChannel")
})
it("should return an OutputChannel instance", () => {
const channel = windowAPI.createOutputChannel("Test")
expect(typeof channel.append).toBe("function")
expect(typeof channel.appendLine).toBe("function")
expect(typeof channel.dispose).toBe("function")
})
})
describe("createStatusBarItem()", () => {
it("should create with default alignment", () => {
const item = windowAPI.createStatusBarItem()
expect(item.alignment).toBe(StatusBarAlignment.Left)
})
it("should create with specified alignment", () => {
const item = windowAPI.createStatusBarItem(StatusBarAlignment.Right)
expect(item.alignment).toBe(StatusBarAlignment.Right)
})
it("should create with alignment and priority", () => {
const item = windowAPI.createStatusBarItem(StatusBarAlignment.Left, 100)
expect(item.alignment).toBe(StatusBarAlignment.Left)
expect(item.priority).toBe(100)
})
it("should handle overloaded signature with id", () => {
const item = windowAPI.createStatusBarItem("myId", StatusBarAlignment.Right, 50)
expect(item.alignment).toBe(StatusBarAlignment.Right)
expect(item.priority).toBe(50)
})
})
describe("createTextEditorDecorationType()", () => {
it("should create a decoration type", () => {
const decoration = windowAPI.createTextEditorDecorationType({})
expect(decoration).toBeDefined()
expect(decoration.key).toContain("decoration-")
})
it("should return unique keys", () => {
const decoration1 = windowAPI.createTextEditorDecorationType({})
const decoration2 = windowAPI.createTextEditorDecorationType({})
expect(decoration1.key).not.toBe(decoration2.key)
})
})
describe("createTerminal()", () => {
it("should create a terminal with default name", () => {
const terminal = windowAPI.createTerminal()
expect(terminal.name).toBe("Terminal")
})
it("should create a terminal with specified name", () => {
const terminal = windowAPI.createTerminal({ name: "MyTerminal" })
expect(terminal.name).toBe("MyTerminal")
})
it("should return terminal with expected methods", () => {
const terminal = windowAPI.createTerminal()
expect(typeof terminal.sendText).toBe("function")
expect(typeof terminal.show).toBe("function")
expect(typeof terminal.hide).toBe("function")
expect(typeof terminal.dispose).toBe("function")
})
it("should have processId promise", async () => {
const terminal = windowAPI.createTerminal()
const processId = await terminal.processId
expect(processId).toBeUndefined()
})
})
describe("showInformationMessage()", () => {
it("should return a promise", () => {
const result = windowAPI.showInformationMessage("Test message")
expect(result).toBeInstanceOf(Promise)
})
it("should resolve to undefined", async () => {
const result = await windowAPI.showInformationMessage("Test message")
expect(result).toBeUndefined()
})
})
describe("showWarningMessage()", () => {
it("should return a promise", () => {
const result = windowAPI.showWarningMessage("Warning message")
expect(result).toBeInstanceOf(Promise)
})
it("should resolve to undefined", async () => {
const result = await windowAPI.showWarningMessage("Warning message")
expect(result).toBeUndefined()
})
})
describe("showErrorMessage()", () => {
it("should return a promise", () => {
const result = windowAPI.showErrorMessage("Error message")
expect(result).toBeInstanceOf(Promise)
})
it("should resolve to undefined", async () => {
const result = await windowAPI.showErrorMessage("Error message")
expect(result).toBeUndefined()
})
})
describe("showQuickPick()", () => {
it("should return first item", async () => {
const result = await windowAPI.showQuickPick(["item1", "item2", "item3"])
expect(result).toBe("item1")
})
it("should return undefined for empty array", async () => {
const result = await windowAPI.showQuickPick([])
expect(result).toBeUndefined()
})
})
describe("showInputBox()", () => {
it("should return empty string", async () => {
const result = await windowAPI.showInputBox()
expect(result).toBe("")
})
})
describe("showOpenDialog()", () => {
it("should return empty array", async () => {
const result = await windowAPI.showOpenDialog()
expect(result).toEqual([])
})
})
describe("showTextDocument()", () => {
it("should return an editor", async () => {
const uri = Uri.file("/test/file.txt")
const editor = await windowAPI.showTextDocument(uri)
expect(editor).toBeDefined()
expect(editor.document).toBeDefined()
})
it("should add editor to visibleTextEditors", async () => {
const uri = Uri.file("/test/file.txt")
await windowAPI.showTextDocument(uri)
expect(windowAPI.visibleTextEditors.length).toBeGreaterThan(0)
})
})
describe("registerWebviewViewProvider()", () => {
it("should return a disposable", () => {
const mockProvider = {
resolveWebviewView: vi.fn(),
}
const disposable = windowAPI.registerWebviewViewProvider("myView", mockProvider)
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
})
describe("registerUriHandler()", () => {
it("should return a disposable", () => {
const mockHandler = {
handleUri: vi.fn(),
}
const disposable = windowAPI.registerUriHandler(mockHandler)
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
})
describe("onDidChangeTextEditorSelection()", () => {
it("should return a disposable", () => {
const disposable = windowAPI.onDidChangeTextEditorSelection(() => {})
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
})
describe("onDidChangeActiveTextEditor()", () => {
it("should return a disposable", () => {
const disposable = windowAPI.onDidChangeActiveTextEditor(() => {})
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
})
describe("onDidChangeVisibleTextEditors()", () => {
it("should return a disposable", () => {
const disposable = windowAPI.onDidChangeVisibleTextEditors(() => {})
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
})
describe("terminal events", () => {
it("onDidCloseTerminal should return disposable", () => {
const disposable = windowAPI.onDidCloseTerminal(() => {})
expect(typeof disposable.dispose).toBe("function")
})
it("onDidOpenTerminal should return disposable", () => {
const disposable = windowAPI.onDidOpenTerminal(() => {})
expect(typeof disposable.dispose).toBe("function")
})
it("onDidChangeActiveTerminal should return disposable", () => {
const disposable = windowAPI.onDidChangeActiveTerminal(() => {})
expect(typeof disposable.dispose).toBe("function")
})
it("onDidChangeTerminalDimensions should return disposable", () => {
const disposable = windowAPI.onDidChangeTerminalDimensions(() => {})
expect(typeof disposable.dispose).toBe("function")
})
it("onDidWriteTerminalData should return disposable", () => {
const disposable = windowAPI.onDidWriteTerminalData(() => {})
expect(typeof disposable.dispose).toBe("function")
})
})
describe("activeTerminal property", () => {
it("should return undefined", () => {
expect(windowAPI.activeTerminal).toBeUndefined()
})
})
describe("terminals property", () => {
it("should return empty array", () => {
expect(windowAPI.terminals).toEqual([])
})
})
})

View file

@ -0,0 +1,290 @@
import * as fs from "fs"
import * as path from "path"
import { tmpdir } from "os"
import { WorkspaceAPI } from "../api/WorkspaceAPI.js"
import { Uri } from "../classes/Uri.js"
import { Range } from "../classes/Range.js"
import { Position } from "../classes/Position.js"
import { WorkspaceEdit } from "../classes/TextEdit.js"
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
describe("WorkspaceAPI", () => {
let tempDir: string
let extensionPath: string
let workspacePath: string
let context: ExtensionContextImpl
let workspaceAPI: WorkspaceAPI
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), "workspace-api-test-"))
extensionPath = path.join(tempDir, "extension")
workspacePath = path.join(tempDir, "workspace")
fs.mkdirSync(extensionPath, { recursive: true })
fs.mkdirSync(workspacePath, { recursive: true })
context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: path.join(tempDir, "storage"),
})
workspaceAPI = new WorkspaceAPI(workspacePath, context)
})
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
describe("workspaceFolders", () => {
it("should have workspace folder set", () => {
expect(workspaceAPI.workspaceFolders).toHaveLength(1)
expect(workspaceAPI.workspaceFolders?.[0]?.uri.fsPath).toBe(workspacePath)
expect(workspaceAPI.workspaceFolders?.[0]?.index).toBe(0)
})
it("should have workspace name set", () => {
expect(workspaceAPI.name).toBe(path.basename(workspacePath))
})
})
describe("asRelativePath()", () => {
it("should convert absolute path to relative", () => {
const absolutePath = path.join(workspacePath, "subdir", "file.txt")
const relativePath = workspaceAPI.asRelativePath(absolutePath)
expect(relativePath).toBe(path.join("subdir", "file.txt"))
})
it("should handle URI input", () => {
const uri = Uri.file(path.join(workspacePath, "file.txt"))
const relativePath = workspaceAPI.asRelativePath(uri)
expect(relativePath).toBe("file.txt")
})
it("should return original path if outside workspace", () => {
const outsidePath = "/outside/workspace/file.txt"
const result = workspaceAPI.asRelativePath(outsidePath)
expect(result).toBe(outsidePath)
})
it("should handle empty workspace folders", () => {
workspaceAPI.workspaceFolders = undefined
const absolutePath = "/some/path/file.txt"
const result = workspaceAPI.asRelativePath(absolutePath)
expect(result).toBe(absolutePath)
})
})
describe("getConfiguration()", () => {
it("should return configuration object", () => {
const config = workspaceAPI.getConfiguration("myExtension")
expect(config).toBeDefined()
expect(typeof config.get).toBe("function")
expect(typeof config.has).toBe("function")
expect(typeof config.update).toBe("function")
})
})
describe("findFiles()", () => {
it("should return empty array (minimal implementation)", async () => {
const result = await workspaceAPI.findFiles("**/*.txt")
expect(result).toEqual([])
})
})
describe("openTextDocument()", () => {
it("should open and return a text document", async () => {
const filePath = path.join(workspacePath, "test.txt")
fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3")
const uri = Uri.file(filePath)
const document = await workspaceAPI.openTextDocument(uri)
expect(document.uri.fsPath).toBe(filePath)
expect(document.fileName).toBe(filePath)
expect(document.lineCount).toBe(3)
expect(document.getText()).toBe("Line 1\nLine 2\nLine 3")
})
it("should handle getText with range", async () => {
const filePath = path.join(workspacePath, "test.txt")
fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3")
const uri = Uri.file(filePath)
const document = await workspaceAPI.openTextDocument(uri)
const range = new Range(0, 0, 1, 6)
const text = document.getText(range)
expect(text).toContain("Line 1")
expect(text).toContain("Line 2")
})
it("should provide lineAt method", async () => {
const filePath = path.join(workspacePath, "test.txt")
fs.writeFileSync(filePath, "Hello\nWorld")
const uri = Uri.file(filePath)
const document = await workspaceAPI.openTextDocument(uri)
const line = document.lineAt(0)
expect(line.text).toBe("Hello")
expect(line.isEmptyOrWhitespace).toBe(false)
})
it("should add document to textDocuments", async () => {
const filePath = path.join(workspacePath, "test.txt")
fs.writeFileSync(filePath, "content")
const uri = Uri.file(filePath)
await workspaceAPI.openTextDocument(uri)
expect(workspaceAPI.textDocuments).toHaveLength(1)
})
it("should handle non-existent file gracefully", async () => {
const uri = Uri.file(path.join(workspacePath, "nonexistent.txt"))
const document = await workspaceAPI.openTextDocument(uri)
expect(document.getText()).toBe("")
expect(document.lineCount).toBe(1)
})
})
describe("applyEdit()", () => {
it("should apply single edit to file", async () => {
const filePath = path.join(workspacePath, "edit-test.txt")
fs.writeFileSync(filePath, "Hello World")
const edit = new WorkspaceEdit()
const uri = Uri.file(filePath)
edit.replace(uri, new Range(0, 0, 0, 5), "Hi")
const result = await workspaceAPI.applyEdit(edit)
expect(result).toBe(true)
expect(fs.readFileSync(filePath, "utf-8")).toBe("Hi World")
})
it("should apply insert edit", async () => {
const filePath = path.join(workspacePath, "insert-test.txt")
fs.writeFileSync(filePath, "World")
const edit = new WorkspaceEdit()
const uri = Uri.file(filePath)
edit.insert(uri, new Position(0, 0), "Hello ")
const result = await workspaceAPI.applyEdit(edit)
expect(result).toBe(true)
expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello World")
})
it("should apply delete edit", async () => {
const filePath = path.join(workspacePath, "delete-test.txt")
fs.writeFileSync(filePath, "Hello World")
const edit = new WorkspaceEdit()
const uri = Uri.file(filePath)
edit.delete(uri, new Range(0, 5, 0, 11))
const result = await workspaceAPI.applyEdit(edit)
expect(result).toBe(true)
expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello")
})
it("should create file if it doesn't exist", async () => {
const filePath = path.join(workspacePath, "new-file.txt")
const edit = new WorkspaceEdit()
const uri = Uri.file(filePath)
edit.insert(uri, new Position(0, 0), "New content")
const result = await workspaceAPI.applyEdit(edit)
expect(result).toBe(true)
expect(fs.readFileSync(filePath, "utf-8")).toBe("New content")
})
it("should update in-memory document", async () => {
const filePath = path.join(workspacePath, "memory-test.txt")
fs.writeFileSync(filePath, "Original")
// First open the document
const uri = Uri.file(filePath)
const document = await workspaceAPI.openTextDocument(uri)
expect(document.getText()).toBe("Original")
// Apply edit
const edit = new WorkspaceEdit()
edit.replace(uri, new Range(0, 0, 0, 8), "Modified")
await workspaceAPI.applyEdit(edit)
// Check in-memory document is updated
expect(document.getText()).toBe("Modified")
})
})
describe("createFileSystemWatcher()", () => {
it("should return a file system watcher object", () => {
const watcher = workspaceAPI.createFileSystemWatcher()
expect(typeof watcher.onDidChange).toBe("function")
expect(typeof watcher.onDidCreate).toBe("function")
expect(typeof watcher.onDidDelete).toBe("function")
expect(typeof watcher.dispose).toBe("function")
})
})
describe("events", () => {
it("should have onDidChangeWorkspaceFolders event", () => {
expect(typeof workspaceAPI.onDidChangeWorkspaceFolders).toBe("function")
})
it("should have onDidOpenTextDocument event", () => {
expect(typeof workspaceAPI.onDidOpenTextDocument).toBe("function")
})
it("should have onDidChangeTextDocument event", () => {
expect(typeof workspaceAPI.onDidChangeTextDocument).toBe("function")
})
it("should have onDidCloseTextDocument event", () => {
expect(typeof workspaceAPI.onDidCloseTextDocument).toBe("function")
})
it("should have onDidChangeConfiguration event", () => {
expect(typeof workspaceAPI.onDidChangeConfiguration).toBe("function")
})
})
describe("fs property", () => {
it("should have FileSystemAPI instance", () => {
expect(workspaceAPI.fs).toBeDefined()
expect(typeof workspaceAPI.fs.stat).toBe("function")
expect(typeof workspaceAPI.fs.readFile).toBe("function")
expect(typeof workspaceAPI.fs.writeFile).toBe("function")
})
})
describe("registerTextDocumentContentProvider()", () => {
it("should return a disposable", () => {
const disposable = workspaceAPI.registerTextDocumentContentProvider("test", {
provideTextDocumentContent: () => Promise.resolve("content"),
})
expect(disposable).toBeDefined()
expect(typeof disposable.dispose).toBe("function")
})
})
})

View file

@ -0,0 +1,272 @@
import * as fs from "fs"
import * as path from "path"
import { tmpdir } from "os"
import {
MockWorkspaceConfiguration,
setRuntimeConfig,
setRuntimeConfigValues,
getRuntimeConfig,
clearRuntimeConfig,
} from "../api/WorkspaceConfiguration.js"
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
describe("MockWorkspaceConfiguration", () => {
let tempDir: string
let extensionPath: string
let workspacePath: string
let context: ExtensionContextImpl
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), "config-test-"))
extensionPath = path.join(tempDir, "extension")
workspacePath = path.join(tempDir, "workspace")
fs.mkdirSync(extensionPath, { recursive: true })
fs.mkdirSync(workspacePath, { recursive: true })
context = new ExtensionContextImpl({
extensionPath,
workspacePath,
storageDir: path.join(tempDir, "storage"),
})
})
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
describe("get()", () => {
it("should return default value when key doesn't exist", () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
expect(config.get("nonexistent", "default")).toBe("default")
})
it("should return undefined when key doesn't exist and no default provided", () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
expect(config.get("nonexistent")).toBeUndefined()
})
it("should return stored value", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("setting", "value")
expect(config.get("setting")).toBe("value")
})
it("should use section prefix", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("nested.setting", "nested value")
expect(config.get("nested.setting")).toBe("nested value")
})
it("should handle complex values", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
const complexValue = { nested: { array: [1, 2, 3] } }
await config.update("complex", complexValue)
expect(config.get("complex")).toEqual(complexValue)
})
})
describe("has()", () => {
it("should return false for non-existent key", () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
expect(config.has("nonexistent")).toBe(false)
})
it("should return true for existing key", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("exists", "value")
expect(config.has("exists")).toBe(true)
})
})
describe("inspect()", () => {
it("should return undefined for non-existent key", () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
expect(config.inspect("nonexistent")).toBeUndefined()
})
it("should return inspection result for existing key", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("setting", "global value", 1) // Global
const inspection = config.inspect<string>("setting")
expect(inspection).toBeDefined()
expect(inspection?.key).toBe("myExtension.setting")
expect(inspection?.globalValue).toBe("global value")
})
it("should return workspace value when set", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("workspaceSetting", "workspace value", 2) // Workspace
const inspection = config.inspect<string>("workspaceSetting")
expect(inspection).toBeDefined()
expect(inspection?.workspaceValue).toBe("workspace value")
})
})
describe("update()", () => {
it("should update global configuration", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("globalSetting", "global value", 1) // Global
expect(config.get("globalSetting")).toBe("global value")
})
it("should update workspace configuration", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("workspaceSetting", "workspace value", 2) // Workspace
expect(config.get("workspaceSetting")).toBe("workspace value")
})
it("should persist configuration across instances", async () => {
const config1 = new MockWorkspaceConfiguration("myExtension", context)
await config1.update("persistent", "value")
// Create new config instance
const config2 = new MockWorkspaceConfiguration("myExtension", context)
expect(config2.get("persistent")).toBe("value")
})
it("should allow updating with null/undefined to clear value", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("toDelete", "value")
expect(config.get("toDelete")).toBe("value")
await config.update("toDelete", undefined)
expect(config.get("toDelete")).toBeUndefined()
})
})
describe("reload()", () => {
it("should not throw when called", () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
expect(() => config.reload()).not.toThrow()
})
})
describe("getAllConfig()", () => {
it("should return all configuration values", async () => {
const config = new MockWorkspaceConfiguration("myExtension", context)
await config.update("key1", "value1")
await config.update("key2", "value2")
const allConfig = config.getAllConfig()
expect(allConfig["myExtension.key1"]).toBe("value1")
expect(allConfig["myExtension.key2"]).toBe("value2")
})
})
describe("Runtime Configuration", () => {
beforeEach(() => {
// Clear runtime config before each test
clearRuntimeConfig()
})
afterEach(() => {
// Clean up after each test
clearRuntimeConfig()
})
it("should return runtime config value over disk-based values", async () => {
const config = new MockWorkspaceConfiguration("roo-cline", context)
// Set a value in disk-based storage
await config.update("commandExecutionTimeout", 10)
// Verify disk value is returned
expect(config.get("commandExecutionTimeout")).toBe(10)
// Set runtime config (should take precedence)
setRuntimeConfig("roo-cline", "commandExecutionTimeout", 20)
// Now runtime value should be returned
expect(config.get("commandExecutionTimeout")).toBe(20)
})
it("should set and get runtime config values", () => {
setRuntimeConfig("roo-cline", "testSetting", "testValue")
expect(getRuntimeConfig("roo-cline.testSetting")).toBe("testValue")
})
it("should set multiple runtime config values at once", () => {
setRuntimeConfigValues("roo-cline", {
setting1: "value1",
setting2: 42,
setting3: true,
})
expect(getRuntimeConfig("roo-cline.setting1")).toBe("value1")
expect(getRuntimeConfig("roo-cline.setting2")).toBe(42)
expect(getRuntimeConfig("roo-cline.setting3")).toBe(true)
})
it("should ignore undefined values in setRuntimeConfigValues", () => {
setRuntimeConfigValues("roo-cline", {
defined: "value",
notDefined: undefined,
})
expect(getRuntimeConfig("roo-cline.defined")).toBe("value")
expect(getRuntimeConfig("roo-cline.notDefined")).toBeUndefined()
})
it("should clear all runtime config values", () => {
setRuntimeConfig("roo-cline", "setting1", "value1")
setRuntimeConfig("roo-cline", "setting2", "value2")
clearRuntimeConfig()
expect(getRuntimeConfig("roo-cline.setting1")).toBeUndefined()
expect(getRuntimeConfig("roo-cline.setting2")).toBeUndefined()
})
it("should return default value when no runtime config is set", () => {
const config = new MockWorkspaceConfiguration("roo-cline", context)
expect(config.get("nonexistent", 0)).toBe(0)
expect(config.get("nonexistent", "default")).toBe("default")
})
it("should work with MockWorkspaceConfiguration.get() for CLI settings", () => {
// Simulate CLI setting commandExecutionTimeout
setRuntimeConfigValues("roo-cline", {
commandExecutionTimeout: 20,
commandTimeoutAllowlist: ["npm", "yarn"],
})
const config = new MockWorkspaceConfiguration("roo-cline", context)
// These should return the runtime config values
expect(config.get<number>("commandExecutionTimeout", 0)).toBe(20)
expect(config.get<string[]>("commandTimeoutAllowlist", [])).toEqual(["npm", "yarn"])
})
})
})

View file

@ -0,0 +1,198 @@
import { logs, setLogger, type Logger } from "../utils/logger.js"
describe("Logger", () => {
let originalEnv: string | undefined
let consoleSpy: {
log: ReturnType<typeof vi.spyOn>
warn: ReturnType<typeof vi.spyOn>
error: ReturnType<typeof vi.spyOn>
debug: ReturnType<typeof vi.spyOn>
}
beforeEach(() => {
originalEnv = process.env.DEBUG
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => {}),
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
error: vi.spyOn(console, "error").mockImplementation(() => {}),
debug: vi.spyOn(console, "debug").mockImplementation(() => {}),
}
})
afterEach(() => {
process.env.DEBUG = originalEnv
vi.restoreAllMocks()
})
describe("logs object (default ConsoleLogger)", () => {
describe("info()", () => {
it("should log info message", () => {
logs.info("Info message")
expect(consoleSpy.log).toHaveBeenCalled()
expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("Info message")
})
it("should include context in log", () => {
logs.info("Info message", "MyContext")
expect(consoleSpy.log).toHaveBeenCalled()
expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("MyContext")
})
it("should use INFO as default context", () => {
logs.info("Info message")
expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("INFO")
})
})
describe("warn()", () => {
it("should log warning message", () => {
logs.warn("Warning message")
expect(consoleSpy.warn).toHaveBeenCalled()
expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("Warning message")
})
it("should include context in warning", () => {
logs.warn("Warning message", "MyContext")
expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("MyContext")
})
it("should use WARN as default context", () => {
logs.warn("Warning message")
expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("WARN")
})
})
describe("error()", () => {
it("should log error message", () => {
logs.error("Error message")
expect(consoleSpy.error).toHaveBeenCalled()
expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("Error message")
})
it("should include context in error", () => {
logs.error("Error message", "MyContext")
expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("MyContext")
})
it("should use ERROR as default context", () => {
logs.error("Error message")
expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("ERROR")
})
})
describe("debug()", () => {
it("should not log debug message when DEBUG env is not set", () => {
delete process.env.DEBUG
logs.debug("Debug message")
expect(consoleSpy.debug).not.toHaveBeenCalled()
})
it("should log debug message when DEBUG env is set", () => {
process.env.DEBUG = "true"
logs.debug("Debug message")
expect(consoleSpy.debug).toHaveBeenCalled()
expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("Debug message")
})
it("should include context in debug when DEBUG is set", () => {
process.env.DEBUG = "true"
logs.debug("Debug message", "MyContext")
expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("MyContext")
})
})
})
describe("setLogger()", () => {
it("should replace default logger with custom logger", () => {
const customLogger: Logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}
setLogger(customLogger)
logs.info("Test message", "TestContext")
expect(customLogger.info).toHaveBeenCalledWith("Test message", "TestContext", undefined)
})
it("should use custom logger for all log levels", () => {
const customLogger: Logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}
setLogger(customLogger)
logs.info("Info")
logs.warn("Warn")
logs.error("Error")
logs.debug("Debug")
expect(customLogger.info).toHaveBeenCalledTimes(1)
expect(customLogger.warn).toHaveBeenCalledTimes(1)
expect(customLogger.error).toHaveBeenCalledTimes(1)
expect(customLogger.debug).toHaveBeenCalledTimes(1)
})
it("should pass meta parameter to custom logger", () => {
const customLogger: Logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}
setLogger(customLogger)
const meta = { requestId: "123", userId: "456" }
logs.info("Info with meta", "Context", meta)
expect(customLogger.info).toHaveBeenCalledWith("Info with meta", "Context", meta)
})
})
describe("Logger interface", () => {
it("should accept custom logger implementing Logger interface", () => {
// Create a custom logger that collects messages
const messages: string[] = []
const customLogger: Logger = {
info: (message) => messages.push(`INFO: ${message}`),
warn: (message) => messages.push(`WARN: ${message}`),
error: (message) => messages.push(`ERROR: ${message}`),
debug: (message) => messages.push(`DEBUG: ${message}`),
}
setLogger(customLogger)
logs.info("Test info")
logs.warn("Test warn")
logs.error("Test error")
logs.debug("Test debug")
expect(messages).toContain("INFO: Test info")
expect(messages).toContain("WARN: Test warn")
expect(messages).toContain("ERROR: Test error")
expect(messages).toContain("DEBUG: Test debug")
})
})
})

View file

@ -0,0 +1,143 @@
import * as fs from "fs"
import * as path from "path"
import { tmpdir } from "os"
import { machineIdSync } from "../utils/machine-id.js"
describe("machineIdSync", () => {
let originalHome: string | undefined
let tempDir: string
beforeEach(() => {
originalHome = process.env.HOME
tempDir = fs.mkdtempSync(path.join(tmpdir(), "machine-id-test-"))
process.env.HOME = tempDir
})
afterEach(() => {
process.env.HOME = originalHome
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
it("should generate a machine ID", () => {
const machineId = machineIdSync()
expect(machineId).toBeDefined()
expect(typeof machineId).toBe("string")
expect(machineId.length).toBeGreaterThan(0)
})
it("should return a hexadecimal string", () => {
const machineId = machineIdSync()
// SHA256 hash produces 64 hex characters
expect(machineId).toMatch(/^[a-f0-9]+$/)
expect(machineId.length).toBe(64)
})
it("should persist machine ID to file", () => {
const machineId = machineIdSync()
const idPath = path.join(tempDir, ".vscode-mock", ".machine-id")
expect(fs.existsSync(idPath)).toBe(true)
const storedId = fs.readFileSync(idPath, "utf-8").trim()
expect(storedId).toBe(machineId)
})
it("should return same ID on subsequent calls", () => {
const machineId1 = machineIdSync()
const machineId2 = machineIdSync()
expect(machineId1).toBe(machineId2)
})
it("should read existing ID from file", () => {
// Create the directory and file first
const idDir = path.join(tempDir, ".vscode-mock")
const idPath = path.join(idDir, ".machine-id")
fs.mkdirSync(idDir, { recursive: true })
fs.writeFileSync(idPath, "existing-machine-id-12345")
const machineId = machineIdSync()
expect(machineId).toBe("existing-machine-id-12345")
})
it("should create directory if it doesn't exist", () => {
const idDir = path.join(tempDir, ".vscode-mock")
expect(fs.existsSync(idDir)).toBe(false)
machineIdSync()
expect(fs.existsSync(idDir)).toBe(true)
})
it("should handle missing HOME environment variable", () => {
// Use USERPROFILE instead (Windows fallback)
delete process.env.HOME
process.env.USERPROFILE = tempDir
const machineId = machineIdSync()
expect(machineId).toBeDefined()
expect(machineId.length).toBeGreaterThan(0)
// Restore
process.env.HOME = tempDir
})
it("should generate unique IDs for different hosts", () => {
// This test verifies that the ID generation includes random data
// Since we can't easily change the hostname, we verify multiple generations
// in fresh environments produce unique results (due to random component)
// First call generates and saves
const machineId1 = machineIdSync()
// Delete the saved file to force regeneration
const idPath = path.join(tempDir, ".vscode-mock", ".machine-id")
fs.unlinkSync(idPath)
// Second call should generate a new ID (random component)
const machineId2 = machineIdSync()
// The IDs should be different due to the random component
expect(machineId1).not.toBe(machineId2)
})
it("should handle read errors gracefully", () => {
// Create an unreadable file (directory instead of file)
const idDir = path.join(tempDir, ".vscode-mock")
const idPath = path.join(idDir, ".machine-id")
fs.mkdirSync(idPath, { recursive: true }) // Create directory instead of file
// Should not throw, should generate new ID
expect(() => machineIdSync()).not.toThrow()
const machineId = machineIdSync()
expect(machineId).toBeDefined()
expect(machineId.length).toBeGreaterThan(0)
})
it("should handle write errors gracefully", () => {
// Make the directory read-only (Unix only)
if (process.platform !== "win32") {
const idDir = path.join(tempDir, ".vscode-mock")
fs.mkdirSync(idDir, { recursive: true })
fs.chmodSync(idDir, 0o444) // Read-only
// Should not throw, should still generate ID
expect(() => machineIdSync()).not.toThrow()
const machineId = machineIdSync()
expect(machineId).toBeDefined()
// Restore permissions for cleanup
fs.chmodSync(idDir, 0o755)
}
})
})

View file

@ -0,0 +1,208 @@
import * as fs from "fs"
import * as path from "path"
import { tmpdir } from "os"
import { VSCodeMockPaths } from "../utils/paths.js"
describe("VSCodeMockPaths", () => {
let originalHome: string | undefined
let tempDir: string
beforeEach(() => {
originalHome = process.env.HOME
tempDir = fs.mkdtempSync(path.join(tmpdir(), "paths-test-"))
process.env.HOME = tempDir
})
afterEach(() => {
process.env.HOME = originalHome
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
describe("getGlobalStorageDir()", () => {
it("should return path containing .vscode-mock", () => {
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
expect(globalDir).toContain(".vscode-mock")
})
it("should return path containing global-storage", () => {
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
expect(globalDir).toContain("global-storage")
})
it("should use HOME environment variable", () => {
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
expect(globalDir).toContain(tempDir)
})
it("should return consistent path on multiple calls", () => {
const dir1 = VSCodeMockPaths.getGlobalStorageDir()
const dir2 = VSCodeMockPaths.getGlobalStorageDir()
expect(dir1).toBe(dir2)
})
})
describe("getWorkspaceStorageDir()", () => {
it("should return path containing .vscode-mock", () => {
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace")
expect(workspaceDir).toContain(".vscode-mock")
})
it("should return path containing workspace-storage", () => {
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace")
expect(workspaceDir).toContain("workspace-storage")
})
it("should include hashed workspace path", () => {
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace")
// Should end with a hash (hex string)
const hash = path.basename(workspaceDir)
expect(hash).toMatch(/^[a-f0-9]+$/)
})
it("should return different paths for different workspaces", () => {
const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/one")
const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/two")
expect(dir1).not.toBe(dir2)
})
it("should return same path for same workspace", () => {
const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace")
const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace")
expect(dir1).toBe(dir2)
})
it("should handle Windows-style paths", () => {
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("C:\\Users\\test\\workspace")
expect(workspaceDir).toContain("workspace-storage")
// Should still produce a valid hash
const hash = path.basename(workspaceDir)
expect(hash).toMatch(/^[a-f0-9]+$/)
})
it("should handle empty workspace path", () => {
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("")
expect(workspaceDir).toContain("workspace-storage")
})
})
describe("getLogsDir()", () => {
it("should return path containing .vscode-mock", () => {
const logsDir = VSCodeMockPaths.getLogsDir()
expect(logsDir).toContain(".vscode-mock")
})
it("should return path containing logs", () => {
const logsDir = VSCodeMockPaths.getLogsDir()
expect(logsDir).toContain("logs")
})
it("should return consistent path on multiple calls", () => {
const dir1 = VSCodeMockPaths.getLogsDir()
const dir2 = VSCodeMockPaths.getLogsDir()
expect(dir1).toBe(dir2)
})
})
describe("initializeWorkspace()", () => {
it("should create global storage directory", () => {
VSCodeMockPaths.initializeWorkspace("/test/workspace")
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
expect(fs.existsSync(globalDir)).toBe(true)
})
it("should create workspace storage directory", () => {
const workspacePath = "/test/workspace"
VSCodeMockPaths.initializeWorkspace(workspacePath)
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir(workspacePath)
expect(fs.existsSync(workspaceDir)).toBe(true)
})
it("should create logs directory", () => {
VSCodeMockPaths.initializeWorkspace("/test/workspace")
const logsDir = VSCodeMockPaths.getLogsDir()
expect(fs.existsSync(logsDir)).toBe(true)
})
it("should not fail if directories already exist", () => {
// Initialize twice
VSCodeMockPaths.initializeWorkspace("/test/workspace")
expect(() => {
VSCodeMockPaths.initializeWorkspace("/test/workspace")
}).not.toThrow()
})
it("should create directories with correct structure", () => {
VSCodeMockPaths.initializeWorkspace("/test/workspace")
const baseDir = path.join(tempDir, ".vscode-mock")
expect(fs.existsSync(baseDir)).toBe(true)
expect(fs.existsSync(path.join(baseDir, "global-storage"))).toBe(true)
expect(fs.existsSync(path.join(baseDir, "workspace-storage"))).toBe(true)
expect(fs.existsSync(path.join(baseDir, "logs"))).toBe(true)
})
})
describe("hash consistency", () => {
it("should produce deterministic hashes", () => {
// The same workspace path should always produce the same hash
const workspace = "/project/my-project"
const hash1 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace))
const hash2 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace))
const hash3 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace))
expect(hash1).toBe(hash2)
expect(hash2).toBe(hash3)
})
it("should handle special characters in workspace path", () => {
const workspaces = [
"/path/with spaces/project",
"/path/with-dashes/project",
"/path/with_underscores/project",
"/path/with.dots/project",
]
for (const workspace of workspaces) {
const dir = VSCodeMockPaths.getWorkspaceStorageDir(workspace)
// Should produce valid directory name
expect(path.basename(dir)).toMatch(/^[a-f0-9]+$/)
}
})
})
describe("USERPROFILE fallback (Windows)", () => {
it("should use USERPROFILE when HOME is not set", () => {
delete process.env.HOME
process.env.USERPROFILE = tempDir
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
expect(globalDir).toContain(tempDir)
// Restore for cleanup
process.env.HOME = tempDir
})
})
})

View file

@ -0,0 +1,178 @@
import * as fs from "fs"
import * as path from "path"
import { tmpdir } from "os"
import { FileMemento } from "../storage/Memento.js"
import { FileSecretStorage } from "../storage/SecretStorage.js"
describe("FileMemento", () => {
let tempDir: string
let mementoPath: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), "memento-test-"))
mementoPath = path.join(tempDir, "state.json")
})
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
it("should store and retrieve values", async () => {
const memento = new FileMemento(mementoPath)
await memento.update("key1", "value1")
await memento.update("key2", 42)
expect(memento.get("key1")).toBe("value1")
expect(memento.get("key2")).toBe(42)
})
it("should return default value when key doesn't exist", () => {
const memento = new FileMemento(mementoPath)
expect(memento.get("nonexistent", "default")).toBe("default")
expect(memento.get<number>("missing", 0)).toBe(0)
})
it("should persist data to file", async () => {
const memento1 = new FileMemento(mementoPath)
await memento1.update("persisted", "value")
// Create new instance to verify persistence
const memento2 = new FileMemento(mementoPath)
expect(memento2.get("persisted")).toBe("value")
})
it("should delete values when updated with undefined", async () => {
const memento = new FileMemento(mementoPath)
await memento.update("key", "value")
expect(memento.get("key")).toBe("value")
await memento.update("key", undefined)
expect(memento.get("key")).toBeUndefined()
})
it("should return all keys", async () => {
const memento = new FileMemento(mementoPath)
await memento.update("key1", "value1")
await memento.update("key2", "value2")
await memento.update("key3", "value3")
const keys = memento.keys()
expect(keys).toHaveLength(3)
expect(keys).toContain("key1")
expect(keys).toContain("key2")
expect(keys).toContain("key3")
})
it("should clear all data", async () => {
const memento = new FileMemento(mementoPath)
await memento.update("key1", "value1")
await memento.update("key2", "value2")
memento.clear()
expect(memento.keys()).toHaveLength(0)
expect(memento.get("key1")).toBeUndefined()
})
})
describe("FileSecretStorage", () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), "secrets-test-"))
})
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
})
it("should store and retrieve secrets", async () => {
const storage = new FileSecretStorage(tempDir)
await storage.store("apiKey", "sk-test-123")
const retrieved = await storage.get("apiKey")
expect(retrieved).toBe("sk-test-123")
})
it("should return undefined for non-existent secrets", async () => {
const storage = new FileSecretStorage(tempDir)
const result = await storage.get("nonexistent")
expect(result).toBeUndefined()
})
it("should delete secrets", async () => {
const storage = new FileSecretStorage(tempDir)
await storage.store("apiKey", "sk-test-123")
expect(await storage.get("apiKey")).toBe("sk-test-123")
await storage.delete("apiKey")
expect(await storage.get("apiKey")).toBeUndefined()
})
it("should persist secrets across instances", async () => {
const storage1 = new FileSecretStorage(tempDir)
await storage1.store("token", "persistent-value")
const storage2 = new FileSecretStorage(tempDir)
const value = await storage2.get("token")
expect(value).toBe("persistent-value")
})
it("should fire onDidChange event when secret changes", async () => {
const storage = new FileSecretStorage(tempDir)
const events: string[] = []
storage.onDidChange((e) => {
events.push(e.key)
})
await storage.store("key1", "value1")
await storage.store("key2", "value2")
await storage.delete("key1")
expect(events).toEqual(["key1", "key2", "key1"])
})
it("should clear all secrets", async () => {
const storage = new FileSecretStorage(tempDir)
await storage.store("key1", "value1")
await storage.store("key2", "value2")
storage.clearAll()
expect(await storage.get("key1")).toBeUndefined()
expect(await storage.get("key2")).toBeUndefined()
})
it("should create secrets.json file with restrictive permissions on Unix", async () => {
if (process.platform === "win32") {
// Skip on Windows
return
}
const storage = new FileSecretStorage(tempDir)
await storage.store("key", "value")
const secretsPath = path.join(tempDir, "secrets.json")
const stats = fs.statSync(secretsPath)
const mode = stats.mode & 0o777
// Should be 0600 (owner read/write only)
expect(mode).toBe(0o600)
})
})

View file

@ -0,0 +1,181 @@
/**
* CommandsAPI class for VSCode API
*/
import { logs } from "../utils/logger.js"
import { Uri } from "../classes/Uri.js"
import { Position } from "../classes/Position.js"
import { Range } from "../classes/Range.js"
import { Selection } from "../classes/Selection.js"
import { ViewColumn, EndOfLine } from "../types.js"
import type { Thenable } from "../types.js"
import type { TextEditor, TextEditorEdit } from "../interfaces/editor.js"
import type { TextDocument } from "../interfaces/document.js"
import type { Disposable } from "../interfaces/workspace.js"
import type { WorkspaceAPI } from "./WorkspaceAPI.js"
import type { WindowAPI } from "./WindowAPI.js"
/**
* Commands API mock for CLI mode
*/
export class CommandsAPI {
private commands: Map<string, (...args: unknown[]) => unknown> = new Map()
registerCommand(command: string, callback: (...args: unknown[]) => unknown): Disposable {
this.commands.set(command, callback)
return {
dispose: () => {
this.commands.delete(command)
},
}
}
executeCommand<T = unknown>(command: string, ...rest: unknown[]): Thenable<T> {
const handler = this.commands.get(command)
if (handler) {
try {
const result = handler(...rest)
return Promise.resolve(result as T)
} catch (error) {
return Promise.reject(error)
}
}
// Handle built-in commands
switch (command) {
case "workbench.action.files.saveFiles":
case "workbench.action.closeWindow":
case "workbench.action.reloadWindow":
return Promise.resolve(undefined as T)
case "vscode.diff":
// Simulate opening a diff view for the CLI
// The extension's DiffViewProvider expects this to create a diff editor
return this.handleDiffCommand(
rest[0] as Uri,
rest[1] as Uri,
rest[2] as string | undefined,
rest[3],
) as Thenable<T>
default:
logs.warn(`Unknown command: ${command}`, "VSCode.Commands")
return Promise.resolve(undefined as T)
}
}
private async handleDiffCommand(
originalUri: Uri,
modifiedUri: Uri,
title?: string,
_options?: unknown,
): Promise<void> {
// The DiffViewProvider is waiting for the modified document to appear in visibleTextEditors
// We need to simulate this by opening the document and adding it to visible editors
logs.info(`[DIFF] Handling vscode.diff command`, "VSCode.Commands", {
originalUri: originalUri?.toString(),
modifiedUri: modifiedUri?.toString(),
title,
})
if (!modifiedUri) {
logs.warn("[DIFF] vscode.diff called without modified URI", "VSCode.Commands")
return
}
// Get the workspace API to open the document
const workspace = (global as unknown as { vscode?: { workspace?: WorkspaceAPI } }).vscode?.workspace
const window = (global as unknown as { vscode?: { window?: WindowAPI } }).vscode?.window
if (!workspace || !window) {
logs.warn("[DIFF] VSCode APIs not available for diff command", "VSCode.Commands")
return
}
logs.info(
`[DIFF] Current visibleTextEditors count: ${window.visibleTextEditors?.length || 0}`,
"VSCode.Commands",
)
try {
// The document should already be open from the showTextDocument call
// Find it in the existing textDocuments
logs.info(`[DIFF] Looking for already-opened document: ${modifiedUri.fsPath}`, "VSCode.Commands")
let document = workspace.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === modifiedUri.fsPath)
if (!document) {
// If not found, open it now
logs.info(`[DIFF] Document not found, opening: ${modifiedUri.fsPath}`, "VSCode.Commands")
document = await workspace.openTextDocument(modifiedUri)
logs.info(`[DIFF] Document opened successfully, lineCount: ${document.lineCount}`, "VSCode.Commands")
} else {
logs.info(`[DIFF] Found existing document, lineCount: ${document.lineCount}`, "VSCode.Commands")
}
// Create a mock editor for the diff view
const mockEditor: TextEditor = {
document,
selection: new Selection(new Position(0, 0), new Position(0, 0)),
selections: [new Selection(new Position(0, 0), new Position(0, 0))],
visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))],
options: {},
viewColumn: ViewColumn.One,
edit: async (callback: (editBuilder: TextEditorEdit) => void) => {
// Create a mock edit builder
const editBuilder: TextEditorEdit = {
replace: (_range: Range | Position | Selection, _text: string) => {
// In CLI mode, we don't actually edit here
// The DiffViewProvider will handle the actual edits
logs.debug("Mock edit builder replace called", "VSCode.Commands")
},
insert: (_position: Position, _text: string) => {
logs.debug("Mock edit builder insert called", "VSCode.Commands")
},
delete: (_range: Range | Selection) => {
logs.debug("Mock edit builder delete called", "VSCode.Commands")
},
setEndOfLine: (_endOfLine: EndOfLine) => {
logs.debug("Mock edit builder setEndOfLine called", "VSCode.Commands")
},
}
callback(editBuilder)
return true
},
insertSnippet: () => Promise.resolve(true),
setDecorations: () => {},
revealRange: () => {},
show: () => {},
hide: () => {},
}
// Add the editor to visible editors
if (!window.visibleTextEditors) {
window.visibleTextEditors = []
}
// Check if this editor is already in visibleTextEditors (from showTextDocument)
const existingEditor = window.visibleTextEditors.find(
(e: TextEditor) => e.document.uri.fsPath === modifiedUri.fsPath,
)
if (existingEditor) {
logs.info(`[DIFF] Editor already in visibleTextEditors, updating it`, "VSCode.Commands")
// Update the existing editor with the mock editor properties
Object.assign(existingEditor, mockEditor)
} else {
logs.info(`[DIFF] Adding new mock editor to visibleTextEditors`, "VSCode.Commands")
window.visibleTextEditors.push(mockEditor)
}
logs.info(`[DIFF] visibleTextEditors count: ${window.visibleTextEditors.length}`, "VSCode.Commands")
// The onDidChangeVisibleTextEditors event was already fired by showTextDocument
// We don't need to fire it again here
logs.info(
`[DIFF] Diff view simulation complete (events already fired by showTextDocument)`,
"VSCode.Commands",
)
} catch (error) {
logs.error("[DIFF] Error simulating diff view", "VSCode.Commands", { error })
}
}
}

View file

@ -0,0 +1,77 @@
/**
* FileSystemAPI class for VSCode API
*/
import * as fs from "fs"
import * as path from "path"
import { Uri } from "../classes/Uri.js"
import { FileSystemError } from "../classes/Additional.js"
import { ensureDirectoryExists } from "../utils/paths.js"
import type { FileStat } from "../types.js"
/**
* File system API mock for CLI mode
* Provides file operations using Node.js fs module
*/
export class FileSystemAPI {
async stat(uri: Uri): Promise<FileStat> {
try {
const stats = fs.statSync(uri.fsPath)
return {
type: stats.isDirectory() ? 2 : 1, // Directory = 2, File = 1
ctime: stats.ctimeMs,
mtime: stats.mtimeMs,
size: stats.size,
}
} catch {
// If file doesn't exist, assume it's a file for CLI purposes
return {
type: 1, // File
ctime: Date.now(),
mtime: Date.now(),
size: 0,
}
}
}
async readFile(uri: Uri): Promise<Uint8Array> {
try {
const content = fs.readFileSync(uri.fsPath)
return new Uint8Array(content)
} catch (error) {
// Check if it's a file not found error (ENOENT)
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw FileSystemError.FileNotFound(uri)
}
// For other errors, throw a generic FileSystemError
throw new FileSystemError(`Failed to read file: ${uri.fsPath}`)
}
}
async writeFile(uri: Uri, content: Uint8Array): Promise<void> {
try {
// Ensure directory exists
const dir = path.dirname(uri.fsPath)
ensureDirectoryExists(dir)
fs.writeFileSync(uri.fsPath, content)
} catch {
throw new Error(`Failed to write file: ${uri.fsPath}`)
}
}
async delete(uri: Uri): Promise<void> {
try {
fs.unlinkSync(uri.fsPath)
} catch {
throw new Error(`Failed to delete file: ${uri.fsPath}`)
}
}
async createDirectory(uri: Uri): Promise<void> {
try {
fs.mkdirSync(uri.fsPath, { recursive: true })
} catch {
throw new Error(`Failed to create directory: ${uri.fsPath}`)
}
}
}

View file

@ -0,0 +1,69 @@
/**
* TabGroupsAPI class for VSCode API
*/
import { EventEmitter } from "../classes/EventEmitter.js"
import type { Uri } from "../classes/Uri.js"
import type { Disposable } from "../interfaces/workspace.js"
/**
* Tab interface representing an open tab
*/
export interface Tab {
input: TabInputText | unknown
label: string
isActive: boolean
isDirty: boolean
}
/**
* Tab input for text files
*/
export interface TabInputText {
uri: Uri
}
/**
* Tab group interface
*/
export interface TabGroup {
tabs: Tab[]
}
/**
* Tab groups API mock for CLI mode
*/
export class TabGroupsAPI {
private _onDidChangeTabs = new EventEmitter<void>()
private _tabGroups: TabGroup[] = []
get all(): TabGroup[] {
return this._tabGroups
}
onDidChangeTabs(listener: () => void): Disposable {
return this._onDidChangeTabs.event(listener)
}
async close(tab: Tab): Promise<boolean> {
// Find and remove the tab from all groups
for (const group of this._tabGroups) {
const index = group.tabs.indexOf(tab)
if (index !== -1) {
group.tabs.splice(index, 1)
this._onDidChangeTabs.fire()
return true
}
}
return false
}
// Internal method to simulate tab changes for CLI
_simulateTabChange(): void {
this._onDidChangeTabs.fire()
}
dispose(): void {
this._onDidChangeTabs.dispose()
}
}

View file

@ -0,0 +1,362 @@
/**
* WindowAPI class for VSCode API
*/
import { logs } from "../utils/logger.js"
import { Uri } from "../classes/Uri.js"
import { Position } from "../classes/Position.js"
import { Range } from "../classes/Range.js"
import { Selection } from "../classes/Selection.js"
import { EventEmitter } from "../classes/EventEmitter.js"
import { ThemeIcon } from "../classes/Additional.js"
import { OutputChannel } from "../classes/OutputChannel.js"
import { StatusBarItem } from "../classes/StatusBarItem.js"
import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js"
import { TabGroupsAPI } from "./TabGroupsAPI.js"
import { StatusBarAlignment, ViewColumn } from "../types.js"
import type { WorkspaceAPI } from "./WorkspaceAPI.js"
import type { Thenable } from "../types.js"
import type {
TextEditor,
TextEditorSelectionChangeEvent,
TextDocumentShowOptions,
DecorationRenderOptions,
} from "../interfaces/editor.js"
import type { TextDocument } from "../interfaces/document.js"
import type { Terminal, TerminalDimensionsChangeEvent, TerminalDataWriteEvent } from "../interfaces/terminal.js"
import type {
WebviewViewProvider,
WebviewView,
Webview,
ViewBadge,
WebviewViewProviderOptions,
UriHandler,
} from "../interfaces/webview.js"
import type { QuickPickOptions, InputBoxOptions, OpenDialogOptions, Disposable } from "../interfaces/workspace.js"
import type { CancellationToken } from "../interfaces/document.js"
/**
* Window API mock for CLI mode
*/
export class WindowAPI {
public tabGroups: TabGroupsAPI
public visibleTextEditors: TextEditor[] = []
public _onDidChangeVisibleTextEditors = new EventEmitter<TextEditor[]>()
private _workspace?: WorkspaceAPI
private static _decorationCounter = 0
constructor() {
this.tabGroups = new TabGroupsAPI()
}
setWorkspace(workspace: WorkspaceAPI) {
this._workspace = workspace
}
createOutputChannel(name: string): OutputChannel {
return new OutputChannel(name)
}
createStatusBarItem(alignment?: StatusBarAlignment, priority?: number): StatusBarItem
createStatusBarItem(id?: string, alignment?: StatusBarAlignment, priority?: number): StatusBarItem
createStatusBarItem(
idOrAlignment?: string | StatusBarAlignment,
alignmentOrPriority?: StatusBarAlignment | number,
priority?: number,
): StatusBarItem {
// Handle overloaded signatures
let actualAlignment: StatusBarAlignment
let actualPriority: number | undefined
if (typeof idOrAlignment === "string") {
// Called with id, alignment, priority
actualAlignment = (alignmentOrPriority as StatusBarAlignment) ?? StatusBarAlignment.Left
actualPriority = priority
} else {
// Called with alignment, priority
actualAlignment = (idOrAlignment as StatusBarAlignment) ?? StatusBarAlignment.Left
actualPriority = alignmentOrPriority as number | undefined
}
return new StatusBarItem(actualAlignment, actualPriority)
}
createTextEditorDecorationType(_options: DecorationRenderOptions): TextEditorDecorationType {
return new TextEditorDecorationType(`decoration-${++WindowAPI._decorationCounter}`)
}
createTerminal(options?: {
name?: string
shellPath?: string
shellArgs?: string[]
cwd?: string
env?: { [key: string]: string | null | undefined }
iconPath?: ThemeIcon
hideFromUser?: boolean
message?: string
strictEnv?: boolean
}): Terminal {
// Return a mock terminal object
return {
name: options?.name || "Terminal",
processId: Promise.resolve(undefined),
creationOptions: options || {},
exitStatus: undefined,
state: { isInteractedWith: false },
sendText: (text: string, _addNewLine?: boolean) => {
logs.debug(`Terminal sendText: ${text}`, "VSCode.Terminal")
},
show: (_preserveFocus?: boolean) => {
logs.debug("Terminal show called", "VSCode.Terminal")
},
hide: () => {
logs.debug("Terminal hide called", "VSCode.Terminal")
},
dispose: () => {
logs.debug("Terminal disposed", "VSCode.Terminal")
},
}
}
showInformationMessage(message: string, ..._items: string[]): Thenable<string | undefined> {
logs.info(message, "VSCode.Window")
return Promise.resolve(undefined)
}
showWarningMessage(message: string, ..._items: string[]): Thenable<string | undefined> {
logs.warn(message, "VSCode.Window")
return Promise.resolve(undefined)
}
showErrorMessage(message: string, ..._items: string[]): Thenable<string | undefined> {
logs.error(message, "VSCode.Window")
return Promise.resolve(undefined)
}
showQuickPick(items: string[], _options?: QuickPickOptions): Thenable<string | undefined> {
// Return first item for CLI
return Promise.resolve(items[0])
}
showInputBox(_options?: InputBoxOptions): Thenable<string | undefined> {
// Return empty string for CLI
return Promise.resolve("")
}
showOpenDialog(_options?: OpenDialogOptions): Thenable<Uri[] | undefined> {
// Return empty array for CLI
return Promise.resolve([])
}
async showTextDocument(
documentOrUri: TextDocument | Uri,
columnOrOptions?: ViewColumn | TextDocumentShowOptions,
_preserveFocus?: boolean,
): Promise<TextEditor> {
// Mock implementation for CLI
// In a real VSCode environment, this would open the document in an editor
const uri = documentOrUri instanceof Uri ? documentOrUri : documentOrUri.uri
logs.debug(`showTextDocument called for: ${uri?.toString() || "unknown"}`, "VSCode.Window")
// Create a placeholder editor first so it's in visibleTextEditors when onDidOpenTextDocument fires
const placeholderEditor: TextEditor = {
document: { uri } as TextDocument,
selection: new Selection(new Position(0, 0), new Position(0, 0)),
selections: [new Selection(new Position(0, 0), new Position(0, 0))],
visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))],
options: {},
viewColumn: typeof columnOrOptions === "number" ? columnOrOptions : ViewColumn.One,
edit: () => Promise.resolve(true),
insertSnippet: () => Promise.resolve(true),
setDecorations: () => {},
revealRange: () => {},
show: () => {},
hide: () => {},
}
// Add placeholder to visible editors BEFORE opening document
this.visibleTextEditors.push(placeholderEditor)
logs.debug(
`Placeholder editor added to visibleTextEditors, total: ${this.visibleTextEditors.length}`,
"VSCode.Window",
)
// If we have a URI, open the document (this will fire onDidOpenTextDocument)
let document: TextDocument | Uri = documentOrUri
if (documentOrUri instanceof Uri && this._workspace) {
logs.debug("Opening document via workspace.openTextDocument", "VSCode.Window")
document = await this._workspace.openTextDocument(uri)
logs.debug("Document opened successfully", "VSCode.Window")
// Update the placeholder editor with the real document
placeholderEditor.document = document
}
// Fire events immediately using setImmediate
setImmediate(() => {
logs.debug("Firing onDidChangeVisibleTextEditors event", "VSCode.Window")
this._onDidChangeVisibleTextEditors.fire(this.visibleTextEditors)
logs.debug("onDidChangeVisibleTextEditors event fired", "VSCode.Window")
})
logs.debug("Returning editor from showTextDocument", "VSCode.Window")
return placeholderEditor
}
registerWebviewViewProvider(
viewId: string,
provider: WebviewViewProvider,
_options?: WebviewViewProviderOptions,
): Disposable {
// Store the provider for later use by ExtensionHost
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
const extensionHost = (
global as unknown as {
__extensionHost: {
registerWebviewProvider: (viewId: string, provider: WebviewViewProvider) => void
isInInitialSetup: () => boolean
markWebviewReady: () => void
}
}
).__extensionHost
extensionHost.registerWebviewProvider(viewId, provider)
// Set up webview mock that captures messages from the extension
const mockWebview = {
postMessage: (message: unknown): Thenable<boolean> => {
// Forward extension messages to ExtensionHost for CLI consumption
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
;(
global as unknown as {
__extensionHost: { emit: (event: string, message: unknown) => void }
}
).__extensionHost.emit("extensionWebviewMessage", message)
}
return Promise.resolve(true)
},
onDidReceiveMessage: (listener: (message: unknown) => void) => {
// This is how the extension listens for messages from the webview
// We need to connect this to our message bridge
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
;(
global as unknown as {
__extensionHost: { on: (event: string, listener: (message: unknown) => void) => void }
}
).__extensionHost.on("webviewMessage", listener)
}
return { dispose: () => {} }
},
asWebviewUri: (uriArg: Uri) => {
// Convert file URIs to webview-compatible URIs
// For CLI, we can just return a mock webview URI
return Uri.parse(`vscode-webview://webview/${uriArg.path}`)
},
html: "",
options: {},
cspSource: "vscode-webview:",
}
// Provide the mock webview to the provider
if (provider.resolveWebviewView) {
const mockWebviewView = {
webview: mockWebview as Webview,
viewType: viewId,
title: viewId,
description: undefined as string | undefined,
badge: undefined as ViewBadge | undefined,
show: () => {},
onDidChangeVisibility: () => ({ dispose: () => {} }),
onDidDispose: () => ({ dispose: () => {} }),
visible: true,
}
// Call resolveWebviewView immediately with initialization context
// No setTimeout needed - use event-based synchronization instead
;(async () => {
try {
// Pass isInitialSetup flag in context to prevent task abortion
const context = {
preserveFocus: false,
isInitialSetup: extensionHost.isInInitialSetup(),
}
logs.debug(
`Calling resolveWebviewView with isInitialSetup=${context.isInitialSetup}`,
"VSCode.Window",
)
// Await the result to ensure webview is fully initialized before marking ready
await provider.resolveWebviewView(mockWebviewView as WebviewView, {}, {} as CancellationToken)
// Mark webview as ready after resolution completes
extensionHost.markWebviewReady()
logs.debug("Webview resolution complete, marked as ready", "VSCode.Window")
} catch (error) {
logs.error("Error resolving webview view", "VSCode.Window", { error })
}
})()
}
}
return {
dispose: () => {
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
;(
global as unknown as {
__extensionHost: { unregisterWebviewProvider: (viewId: string) => void }
}
).__extensionHost.unregisterWebviewProvider(viewId)
}
},
}
}
registerUriHandler(_handler: UriHandler): Disposable {
// Store the URI handler for later use
return {
dispose: () => {},
}
}
onDidChangeTextEditorSelection(listener: (event: TextEditorSelectionChangeEvent) => void): Disposable {
const emitter = new EventEmitter<TextEditorSelectionChangeEvent>()
return emitter.event(listener)
}
onDidChangeActiveTextEditor(listener: (event: TextEditor | undefined) => void): Disposable {
const emitter = new EventEmitter<TextEditor | undefined>()
return emitter.event(listener)
}
onDidChangeVisibleTextEditors(listener: (editors: TextEditor[]) => void): Disposable {
return this._onDidChangeVisibleTextEditors.event(listener)
}
// Terminal event handlers
onDidCloseTerminal(_listener: (terminal: Terminal) => void): Disposable {
return { dispose: () => {} }
}
onDidOpenTerminal(_listener: (terminal: Terminal) => void): Disposable {
return { dispose: () => {} }
}
onDidChangeActiveTerminal(_listener: (terminal: Terminal | undefined) => void): Disposable {
return { dispose: () => {} }
}
onDidChangeTerminalDimensions(_listener: (event: TerminalDimensionsChangeEvent) => void): Disposable {
return { dispose: () => {} }
}
onDidWriteTerminalData(_listener: (event: TerminalDataWriteEvent) => void): Disposable {
return { dispose: () => {} }
}
get activeTerminal(): Terminal | undefined {
return undefined
}
get terminals(): Terminal[] {
return []
}
}

View file

@ -0,0 +1,322 @@
/**
* WorkspaceAPI class for VSCode API
*/
import * as fs from "fs"
import * as path from "path"
import { logs } from "../utils/logger.js"
import { Uri } from "../classes/Uri.js"
import { Position } from "../classes/Position.js"
import { Range } from "../classes/Range.js"
import { EventEmitter } from "../classes/EventEmitter.js"
import { WorkspaceEdit } from "../classes/TextEdit.js"
import { FileSystemAPI } from "./FileSystemAPI.js"
import { MockWorkspaceConfiguration } from "./WorkspaceConfiguration.js"
import type { ExtensionContextImpl } from "../context/ExtensionContext.js"
import type {
TextDocument,
TextLine,
WorkspaceFoldersChangeEvent,
WorkspaceFolder,
TextDocumentChangeEvent,
ConfigurationChangeEvent,
TextDocumentContentProvider,
FileSystemWatcher,
RelativePattern,
} from "../interfaces/document.js"
import type { Disposable, WorkspaceConfiguration } from "../interfaces/workspace.js"
import type { Thenable } from "../types.js"
/**
* Workspace API mock for CLI mode
*/
export class WorkspaceAPI {
public workspaceFolders: WorkspaceFolder[] | undefined
public name: string | undefined
public workspaceFile: Uri | undefined
public fs: FileSystemAPI
public textDocuments: TextDocument[] = []
private _onDidChangeWorkspaceFolders = new EventEmitter<WorkspaceFoldersChangeEvent>()
private _onDidOpenTextDocument = new EventEmitter<TextDocument>()
private _onDidChangeTextDocument = new EventEmitter<TextDocumentChangeEvent>()
private _onDidCloseTextDocument = new EventEmitter<TextDocument>()
private context: ExtensionContextImpl
constructor(workspacePath: string, context: ExtensionContextImpl) {
this.context = context
this.workspaceFolders = [
{
uri: Uri.file(workspacePath),
name: path.basename(workspacePath),
index: 0,
},
]
this.name = path.basename(workspacePath)
this.fs = new FileSystemAPI()
}
asRelativePath(pathOrUri: string | Uri, includeWorkspaceFolder?: boolean): string {
const fsPath = typeof pathOrUri === "string" ? pathOrUri : pathOrUri.fsPath
// If no workspace folders, return the original path
if (!this.workspaceFolders || this.workspaceFolders.length === 0) {
return fsPath
}
// Try to find a workspace folder that contains this path
for (const folder of this.workspaceFolders) {
const workspacePath = folder.uri.fsPath
// Normalize paths for comparison (handle different path separators)
const normalizedFsPath = path.normalize(fsPath)
const normalizedWorkspacePath = path.normalize(workspacePath)
// Check if the path is within this workspace folder
if (normalizedFsPath.startsWith(normalizedWorkspacePath)) {
// Get the relative path
let relativePath = path.relative(normalizedWorkspacePath, normalizedFsPath)
// If includeWorkspaceFolder is true and there are multiple workspace folders,
// prepend the workspace folder name
if (includeWorkspaceFolder && this.workspaceFolders.length > 1) {
relativePath = path.join(folder.name, relativePath)
}
return relativePath
}
}
// If not within any workspace folder, return the original path
return fsPath
}
onDidChangeWorkspaceFolders(listener: (event: WorkspaceFoldersChangeEvent) => void): Disposable {
return this._onDidChangeWorkspaceFolders.event(listener)
}
onDidChangeConfiguration(listener: (event: ConfigurationChangeEvent) => void): Disposable {
// Create a mock configuration change event emitter
const emitter = new EventEmitter<ConfigurationChangeEvent>()
return emitter.event(listener)
}
onDidChangeTextDocument(listener: (event: TextDocumentChangeEvent) => void): Disposable {
return this._onDidChangeTextDocument.event(listener)
}
onDidOpenTextDocument(listener: (event: TextDocument) => void): Disposable {
logs.debug("Registering onDidOpenTextDocument listener", "VSCode.Workspace")
return this._onDidOpenTextDocument.event(listener)
}
onDidCloseTextDocument(listener: (event: TextDocument) => void): Disposable {
return this._onDidCloseTextDocument.event(listener)
}
getConfiguration(section?: string): WorkspaceConfiguration {
return new MockWorkspaceConfiguration(section, this.context)
}
findFiles(_include: string, _exclude?: string): Thenable<Uri[]> {
// Basic implementation - could be enhanced with glob patterns
return Promise.resolve([])
}
async openTextDocument(uri: Uri): Promise<TextDocument> {
logs.debug(`openTextDocument called for: ${uri.fsPath}`, "VSCode.Workspace")
// Read file content
let content = ""
try {
content = fs.readFileSync(uri.fsPath, "utf-8")
logs.debug(`File content read successfully, length: ${content.length}`, "VSCode.Workspace")
} catch (error) {
logs.warn(`Failed to read file: ${uri.fsPath}`, "VSCode.Workspace", { error })
}
const lines = content.split("\n")
const document: TextDocument = {
uri,
fileName: uri.fsPath,
languageId: "plaintext",
version: 1,
isDirty: false,
isClosed: false,
lineCount: lines.length,
getText: (range?: Range) => {
if (!range) {
return content
}
return lines.slice(range.start.line, range.end.line + 1).join("\n")
},
lineAt: (line: number): TextLine => {
const text = lines[line] || ""
return {
text,
range: new Range(new Position(line, 0), new Position(line, text.length)),
rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)),
firstNonWhitespaceCharacterIndex: text.search(/\S/),
isEmptyOrWhitespace: text.trim().length === 0,
}
},
offsetAt: (position: Position) => {
let offset = 0
for (let i = 0; i < position.line && i < lines.length; i++) {
offset += (lines[i]?.length || 0) + 1 // +1 for newline
}
offset += position.character
return offset
},
positionAt: (offset: number) => {
let currentOffset = 0
for (let i = 0; i < lines.length; i++) {
const lineLength = (lines[i]?.length || 0) + 1 // +1 for newline
if (currentOffset + lineLength > offset) {
return new Position(i, offset - currentOffset)
}
currentOffset += lineLength
}
return new Position(lines.length - 1, lines[lines.length - 1]?.length || 0)
},
save: () => Promise.resolve(true),
validateRange: (range: Range) => range,
validatePosition: (position: Position) => position,
}
// Add to textDocuments array
this.textDocuments.push(document)
logs.debug(`Document added to textDocuments array, total: ${this.textDocuments.length}`, "VSCode.Workspace")
// Fire the event after a small delay to ensure listeners are fully registered
logs.debug("Waiting before firing onDidOpenTextDocument", "VSCode.Workspace")
await new Promise((resolve) => setTimeout(resolve, 10))
logs.debug("Firing onDidOpenTextDocument event", "VSCode.Workspace")
this._onDidOpenTextDocument.fire(document)
logs.debug("onDidOpenTextDocument event fired", "VSCode.Workspace")
return document
}
async applyEdit(edit: WorkspaceEdit): Promise<boolean> {
// In CLI mode, we need to apply the edits to the actual files
try {
for (const [uri, edits] of edit.entries()) {
let filePath = uri.fsPath
// On Windows, strip leading slash if present (e.g., /C:/path becomes C:/path)
if (process.platform === "win32" && filePath.startsWith("/")) {
filePath = filePath.slice(1)
}
let content = ""
// Read existing content if file exists
try {
content = fs.readFileSync(filePath, "utf-8")
} catch {
// File doesn't exist, start with empty content
}
// Apply edits in reverse order to maintain correct positions
const sortedEdits = edits.sort((a, b) => {
const lineDiff = b.range.start.line - a.range.start.line
if (lineDiff !== 0) return lineDiff
return b.range.start.character - a.range.start.character
})
const lines = content.split("\n")
for (const textEdit of sortedEdits) {
const startLine = textEdit.range.start.line
const startChar = textEdit.range.start.character
const endLine = textEdit.range.end.line
const endChar = textEdit.range.end.character
if (startLine === endLine) {
// Single line edit
const line = lines[startLine] || ""
lines[startLine] = line.substring(0, startChar) + textEdit.newText + line.substring(endChar)
} else {
// Multi-line edit
const firstLine = lines[startLine] || ""
const lastLine = lines[endLine] || ""
const newContent =
firstLine.substring(0, startChar) + textEdit.newText + lastLine.substring(endChar)
lines.splice(startLine, endLine - startLine + 1, newContent)
}
}
// Write back to file
const newContent = lines.join("\n")
fs.writeFileSync(filePath, newContent, "utf-8")
// Update the in-memory document object to reflect the new content
// This is critical for CLI mode where DiffViewProvider reads from the document object
const document = this.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === filePath)
if (document) {
const newLines = newContent.split("\n")
// Update document properties with new content
document.lineCount = newLines.length
document.getText = (range?: Range) => {
if (!range) {
return newContent
}
return newLines.slice(range.start.line, range.end.line + 1).join("\n")
}
document.lineAt = (line: number): TextLine => {
const text = newLines[line] || ""
return {
text,
range: new Range(new Position(line, 0), new Position(line, text.length)),
rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)),
firstNonWhitespaceCharacterIndex: text.search(/\S/),
isEmptyOrWhitespace: text.trim().length === 0,
}
}
document.offsetAt = (position: Position) => {
let offset = 0
for (let i = 0; i < position.line && i < newLines.length; i++) {
offset += (newLines[i]?.length || 0) + 1 // +1 for newline
}
offset += position.character
return offset
}
document.positionAt = (offset: number) => {
let currentOffset = 0
for (let i = 0; i < newLines.length; i++) {
const lineLength = (newLines[i]?.length || 0) + 1 // +1 for newline
if (currentOffset + lineLength > offset) {
return new Position(i, offset - currentOffset)
}
currentOffset += lineLength
}
return new Position(newLines.length - 1, newLines[newLines.length - 1]?.length || 0)
}
}
}
return true
} catch (error) {
logs.error("Failed to apply workspace edit", "VSCode.Workspace", { error })
return false
}
}
createFileSystemWatcher(
_globPattern?: string | RelativePattern,
_ignoreCreateEvents?: boolean,
_ignoreChangeEvents?: boolean,
_ignoreDeleteEvents?: boolean,
): FileSystemWatcher {
const emitter = new EventEmitter<Uri>()
return {
onDidChange: (listener: (e: Uri) => void) => emitter.event(listener),
onDidCreate: (listener: (e: Uri) => void) => emitter.event(listener),
onDidDelete: (listener: (e: Uri) => void) => emitter.event(listener),
dispose: () => emitter.dispose(),
}
}
registerTextDocumentContentProvider(_scheme: string, _provider: TextDocumentContentProvider): Disposable {
return { dispose: () => {} }
}
}

View file

@ -0,0 +1,195 @@
/**
* MockWorkspaceConfiguration class for VSCode API
*/
import * as path from "path"
import { logs } from "../utils/logger.js"
import { VSCodeMockPaths, ensureDirectoryExists } from "../utils/paths.js"
import { FileMemento } from "../storage/Memento.js"
import { ConfigurationTarget } from "../types.js"
import type { ConfigurationInspect } from "../types.js"
import type { WorkspaceConfiguration } from "../interfaces/workspace.js"
import type { ExtensionContextImpl } from "../context/ExtensionContext.js"
/**
* In-memory runtime configuration store shared across all MockWorkspaceConfiguration instances.
* This allows configuration to be updated at runtime (e.g., from CLI settings) without
* persisting to disk. Values in this store take precedence over disk-based mementos.
*/
const runtimeConfig: Map<string, unknown> = new Map()
/**
* Set a runtime configuration value.
* @param section The configuration section (e.g., "roo-cline")
* @param key The configuration key (e.g., "commandExecutionTimeout")
* @param value The value to set
*/
export function setRuntimeConfig(section: string, key: string, value: unknown): void {
const fullKey = `${section}.${key}`
runtimeConfig.set(fullKey, value)
logs.debug(`Runtime config set: ${fullKey} = ${JSON.stringify(value)}`, "VSCode.MockWorkspaceConfiguration")
}
/**
* Set multiple runtime configuration values at once.
* @param section The configuration section (e.g., "roo-cline")
* @param values Object containing key-value pairs to set
*/
export function setRuntimeConfigValues(section: string, values: Record<string, unknown>): void {
for (const [key, value] of Object.entries(values)) {
if (value !== undefined) {
setRuntimeConfig(section, key, value)
}
}
}
/**
* Clear all runtime configuration values.
*/
export function clearRuntimeConfig(): void {
runtimeConfig.clear()
logs.debug("Runtime config cleared", "VSCode.MockWorkspaceConfiguration")
}
/**
* Get a runtime configuration value.
* @param fullKey The full configuration key (e.g., "roo-cline.commandExecutionTimeout")
* @returns The value or undefined if not set
*/
export function getRuntimeConfig(fullKey: string): unknown {
return runtimeConfig.get(fullKey)
}
/**
* Mock workspace configuration for CLI mode
* Persists configuration to JSON files
*/
export class MockWorkspaceConfiguration implements WorkspaceConfiguration {
private section: string | undefined
private globalMemento: FileMemento
private workspaceMemento: FileMemento
constructor(section?: string, context?: ExtensionContextImpl) {
this.section = section
if (context) {
// Use the extension context's mementos
this.globalMemento = context.globalState as unknown as FileMemento
this.workspaceMemento = context.workspaceState as unknown as FileMemento
} else {
// Fallback: create our own mementos (shouldn't happen in normal usage)
const globalStoragePath = VSCodeMockPaths.getGlobalStorageDir()
const workspaceStoragePath = VSCodeMockPaths.getWorkspaceStorageDir(process.cwd())
ensureDirectoryExists(globalStoragePath)
ensureDirectoryExists(workspaceStoragePath)
this.globalMemento = new FileMemento(path.join(globalStoragePath, "configuration.json"))
this.workspaceMemento = new FileMemento(path.join(workspaceStoragePath, "configuration.json"))
}
}
get<T>(section: string, defaultValue?: T): T | undefined {
const fullSection = this.section ? `${this.section}.${section}` : section
// Check runtime configuration first (highest priority - set by CLI at runtime)
const runtimeValue = runtimeConfig.get(fullSection)
if (runtimeValue !== undefined) {
return runtimeValue as T
}
// Check workspace configuration (persisted to disk)
const workspaceValue = this.workspaceMemento.get(fullSection)
if (workspaceValue !== undefined && workspaceValue !== null) {
return workspaceValue as T
}
// Check global configuration (persisted to disk)
const globalValue = this.globalMemento.get(fullSection)
if (globalValue !== undefined && globalValue !== null) {
return globalValue as T
}
// Return default value
return defaultValue
}
has(section: string): boolean {
const fullSection = this.section ? `${this.section}.${section}` : section
return this.workspaceMemento.get(fullSection) !== undefined || this.globalMemento.get(fullSection) !== undefined
}
inspect<T>(section: string): ConfigurationInspect<T> | undefined {
const fullSection = this.section ? `${this.section}.${section}` : section
const workspaceValue = this.workspaceMemento.get(fullSection)
const globalValue = this.globalMemento.get(fullSection)
if (workspaceValue !== undefined || globalValue !== undefined) {
return {
key: fullSection,
defaultValue: undefined,
globalValue: globalValue as T | undefined,
workspaceValue: workspaceValue as T | undefined,
workspaceFolderValue: undefined,
}
}
return undefined
}
async update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Promise<void> {
const fullSection = this.section ? `${this.section}.${section}` : section
try {
// Determine which memento to use based on configuration target
const memento =
configurationTarget === ConfigurationTarget.Workspace ? this.workspaceMemento : this.globalMemento
const scope = configurationTarget === ConfigurationTarget.Workspace ? "workspace" : "global"
// Update the memento (this automatically persists to disk)
await memento.update(fullSection, value)
logs.debug(
`Configuration updated: ${fullSection} = ${JSON.stringify(value)} (${scope})`,
"VSCode.MockWorkspaceConfiguration",
)
} catch (error) {
logs.error(`Failed to update configuration: ${fullSection}`, "VSCode.MockWorkspaceConfiguration", {
error,
})
throw error
}
}
// Additional method to reload configuration from disk
public reload(): void {
// FileMemento automatically loads from disk, so we don't need to do anything special
logs.debug("Configuration reload requested", "VSCode.MockWorkspaceConfiguration")
}
// Method to get all configuration data (useful for debugging and generic config loading)
public getAllConfig(): Record<string, unknown> {
const globalKeys = this.globalMemento.keys()
const workspaceKeys = this.workspaceMemento.keys()
const allConfig: Record<string, unknown> = {}
// Add global settings first
for (const key of globalKeys) {
const value = this.globalMemento.get(key)
if (value !== undefined && value !== null) {
allConfig[key] = value
}
}
// Add workspace settings (these override global)
for (const key of workspaceKeys) {
const value = this.workspaceMemento.get(key)
if (value !== undefined && value !== null) {
allConfig[key] = value
}
}
return allConfig
}
}

View file

@ -0,0 +1,315 @@
/**
* Main factory function for creating VSCode API mock
*/
import { machineIdSync } from "../utils/machine-id.js"
import { logs } from "../utils/logger.js"
// Import classes
import { Uri } from "../classes/Uri.js"
import { Position } from "../classes/Position.js"
import { Range } from "../classes/Range.js"
import { Selection } from "../classes/Selection.js"
import { EventEmitter } from "../classes/EventEmitter.js"
import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js"
import {
Location,
Diagnostic,
DiagnosticRelatedInformation,
ThemeColor,
ThemeIcon,
CodeActionKind,
CodeLens,
LanguageModelTextPart,
LanguageModelToolCallPart,
LanguageModelToolResultPart,
FileSystemError,
} from "../classes/Additional.js"
import { CancellationTokenSource } from "../classes/CancellationToken.js"
import { StatusBarItem } from "../classes/StatusBarItem.js"
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
// Import APIs
import { WorkspaceAPI } from "./WorkspaceAPI.js"
import { WindowAPI } from "./WindowAPI.js"
import { CommandsAPI } from "./CommandsAPI.js"
// Import types and enums
import {
ConfigurationTarget,
ViewColumn,
TextEditorRevealType,
StatusBarAlignment,
DiagnosticSeverity,
DiagnosticTag,
EndOfLine,
UIKind,
ExtensionMode,
FileType,
DecorationRangeBehavior,
OverviewRulerLane,
} from "../types.js"
// Import interfaces
import type { CancellationToken } from "../interfaces/document.js"
import type { Disposable, DiagnosticCollection, IdentityInfo } from "../interfaces/workspace.js"
import type { RelativePattern } from "../interfaces/document.js"
import type { UriHandler } from "../interfaces/webview.js"
// Package version constant
const Package = { version: "1.0.0" }
/**
* Options for creating the VSCode API mock
*/
export interface VSCodeAPIMockOptions {
/**
* Custom app root path (for locating ripgrep and other VSCode resources).
* Defaults to the directory containing this module.
*/
appRoot?: string
}
/**
* Create a complete VSCode API mock for CLI mode
*/
export function createVSCodeAPIMock(
extensionRootPath: string,
workspacePath: string,
identity?: IdentityInfo,
options?: VSCodeAPIMockOptions,
) {
const context = new ExtensionContextImpl({
extensionPath: extensionRootPath,
workspacePath: workspacePath,
})
const workspace = new WorkspaceAPI(workspacePath, context)
const window = new WindowAPI()
const commands = new CommandsAPI()
// Link window and workspace for cross-API calls
window.setWorkspace(workspace)
// Environment mock with identity values
const env = {
appName: `wrapper|cli|cli|${Package.version}`,
appRoot: options?.appRoot || import.meta.dirname,
language: "en",
machineId: identity?.machineId || machineIdSync(),
sessionId: identity?.sessionId || "cli-session-id",
remoteName: undefined,
shell: process.env.SHELL || "/bin/bash",
uriScheme: "vscode",
uiKind: 1, // Desktop
openExternal: async (uri: Uri): Promise<boolean> => {
logs.info(`Would open external URL: ${uri.toString()}`, "VSCode.Env")
return true
},
clipboard: {
readText: async (): Promise<string> => {
logs.debug("Clipboard read requested", "VSCode.Clipboard")
return ""
},
writeText: async (text: string): Promise<void> => {
logs.debug(
`Clipboard write: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`,
"VSCode.Clipboard",
)
},
},
}
return {
version: "1.84.0",
Uri,
EventEmitter,
ConfigurationTarget,
ViewColumn,
TextEditorRevealType,
StatusBarAlignment,
DiagnosticSeverity,
DiagnosticTag,
Position,
Range,
Selection,
Location,
Diagnostic,
DiagnosticRelatedInformation,
TextEdit,
WorkspaceEdit,
EndOfLine,
UIKind,
ExtensionMode,
CodeActionKind,
ThemeColor,
ThemeIcon,
DecorationRangeBehavior,
OverviewRulerLane,
StatusBarItem,
CancellationToken: class CancellationTokenClass implements CancellationToken {
isCancellationRequested = false
onCancellationRequested = (_listener: (e: unknown) => void) => ({ dispose: () => {} })
},
CancellationTokenSource,
CodeLens,
LanguageModelTextPart,
LanguageModelToolCallPart,
LanguageModelToolResultPart,
ExtensionContext: ExtensionContextImpl,
FileType,
FileSystemError,
Disposable: class DisposableClass implements Disposable {
dispose(): void {
// No-op for CLI
}
static from(...disposables: Disposable[]): Disposable {
return {
dispose: () => {
disposables.forEach((d) => d.dispose())
},
}
}
},
TabInputText: class TabInputText {
constructor(public uri: Uri) {}
},
TabInputTextDiff: class TabInputTextDiff {
constructor(
public original: Uri,
public modified: Uri,
) {}
},
workspace,
window,
commands,
env,
context,
// Add more APIs as needed
languages: {
registerCodeActionsProvider: () => ({ dispose: () => {} }),
registerCodeLensProvider: () => ({ dispose: () => {} }),
registerCompletionItemProvider: () => ({ dispose: () => {} }),
registerHoverProvider: () => ({ dispose: () => {} }),
registerDefinitionProvider: () => ({ dispose: () => {} }),
registerReferenceProvider: () => ({ dispose: () => {} }),
registerDocumentSymbolProvider: () => ({ dispose: () => {} }),
registerWorkspaceSymbolProvider: () => ({ dispose: () => {} }),
registerRenameProvider: () => ({ dispose: () => {} }),
registerDocumentFormattingEditProvider: () => ({ dispose: () => {} }),
registerDocumentRangeFormattingEditProvider: () => ({ dispose: () => {} }),
registerSignatureHelpProvider: () => ({ dispose: () => {} }),
getDiagnostics: (uri?: Uri): [Uri, Diagnostic[]][] | Diagnostic[] => {
// In CLI mode, we don't have real diagnostics
// Return empty array or empty diagnostics for the specific URI
if (uri) {
return []
}
return []
},
createDiagnosticCollection: (name?: string): DiagnosticCollection => {
const diagnostics = new Map<string, Diagnostic[]>()
const collection: DiagnosticCollection = {
name: name || "default",
set: (
uriOrEntries: Uri | [Uri, Diagnostic[] | undefined][],
diagnosticsOrUndefined?: Diagnostic[] | undefined,
) => {
if (Array.isArray(uriOrEntries)) {
// Handle array of entries
for (const [uri, diags] of uriOrEntries) {
if (diags === undefined) {
diagnostics.delete(uri.toString())
} else {
diagnostics.set(uri.toString(), diags)
}
}
} else {
// Handle single URI
if (diagnosticsOrUndefined === undefined) {
diagnostics.delete(uriOrEntries.toString())
} else {
diagnostics.set(uriOrEntries.toString(), diagnosticsOrUndefined)
}
}
},
delete: (uri: Uri) => {
diagnostics.delete(uri.toString())
},
clear: () => {
diagnostics.clear()
},
forEach: (
callback: (uri: Uri, diagnostics: Diagnostic[], collection: DiagnosticCollection) => void,
thisArg?: unknown,
) => {
diagnostics.forEach((diags, uriString) => {
callback.call(thisArg, Uri.parse(uriString), diags, collection)
})
},
get: (uri: Uri) => {
return diagnostics.get(uri.toString())
},
has: (uri: Uri) => {
return diagnostics.has(uri.toString())
},
dispose: () => {
diagnostics.clear()
},
}
return collection
},
},
debug: {
onDidStartDebugSession: () => ({ dispose: () => {} }),
onDidTerminateDebugSession: () => ({ dispose: () => {} }),
},
tasks: {
onDidStartTask: () => ({ dispose: () => {} }),
onDidEndTask: () => ({ dispose: () => {} }),
},
extensions: {
all: [],
getExtension: (extensionId: string) => {
// Mock the extension object with extensionUri for theme loading
if (extensionId === "RooVeterinaryInc.roo-cline") {
return {
id: extensionId,
extensionUri: context.extensionUri,
extensionPath: context.extensionPath,
isActive: true,
packageJSON: {},
exports: undefined,
activate: () => Promise.resolve(),
}
}
return undefined
},
onDidChange: () => ({ dispose: () => {} }),
},
// Add file system watcher
FileSystemWatcher: class {
onDidChange = () => ({ dispose: () => {} })
onDidCreate = () => ({ dispose: () => {} })
onDidDelete = () => ({ dispose: () => {} })
dispose = () => {}
},
// Add relative pattern
RelativePattern: class implements RelativePattern {
constructor(
public base: string,
public pattern: string,
) {}
},
// Add progress location
ProgressLocation: {
SourceControl: 1,
Window: 10,
Notification: 15,
},
// Add URI handler
UriHandler: class implements UriHandler {
handleUri = (_uri: Uri) => {}
},
}
}

View file

@ -0,0 +1,181 @@
/**
* Additional VSCode API classes for extension support
*
* This file contains supplementary classes and types that extensions may need.
*/
import { Range } from "./Range.js"
import type { IUri, IRange, IPosition, DiagnosticSeverity, DiagnosticTag } from "../types.js"
/**
* Represents a location in source code (URI + Range or Position)
*/
export class Location {
constructor(
public uri: IUri,
public range: IRange | IPosition,
) {}
}
/**
* Related diagnostic information
*/
export class DiagnosticRelatedInformation {
constructor(
public location: Location,
public message: string,
) {}
}
/**
* Represents a diagnostic (error, warning, etc.)
*/
export class Diagnostic {
range: Range
message: string
severity: DiagnosticSeverity
source?: string
code?: string | number | { value: string | number; target: IUri }
relatedInformation?: DiagnosticRelatedInformation[]
tags?: DiagnosticTag[]
constructor(range: IRange, message: string, severity?: DiagnosticSeverity) {
this.range = range as Range
this.message = message
this.severity = severity !== undefined ? severity : 0 // Error
}
}
/**
* Theme color reference
*/
export class ThemeColor {
constructor(public id: string) {}
}
/**
* Theme icon reference
*/
export class ThemeIcon {
constructor(
public id: string,
public color?: ThemeColor,
) {}
}
/**
* Code action kind for categorizing code actions
*/
export class CodeActionKind {
static readonly Empty = new CodeActionKind("")
static readonly QuickFix = new CodeActionKind("quickfix")
static readonly Refactor = new CodeActionKind("refactor")
static readonly RefactorExtract = new CodeActionKind("refactor.extract")
static readonly RefactorInline = new CodeActionKind("refactor.inline")
static readonly RefactorRewrite = new CodeActionKind("refactor.rewrite")
static readonly Source = new CodeActionKind("source")
static readonly SourceOrganizeImports = new CodeActionKind("source.organizeImports")
constructor(public value: string) {}
append(parts: string): CodeActionKind {
return new CodeActionKind(this.value ? `${this.value}.${parts}` : parts)
}
intersects(other: CodeActionKind): boolean {
return this.contains(other) || other.contains(this)
}
contains(other: CodeActionKind): boolean {
return this.value === other.value || other.value.startsWith(this.value + ".")
}
}
/**
* Code lens for displaying inline information
*/
export class CodeLens {
public range: Range
public command?: { command: string; title: string; arguments?: unknown[] } | undefined
public isResolved: boolean = false
constructor(range: IRange, command?: { command: string; title: string; arguments?: unknown[] } | undefined) {
this.range = range as Range
this.command = command
}
}
/**
* Language Model API parts
*/
export class LanguageModelTextPart {
constructor(public value: string) {}
}
export class LanguageModelToolCallPart {
constructor(
public callId: string,
public name: string,
public input: unknown,
) {}
}
export class LanguageModelToolResultPart {
constructor(
public callId: string,
public content: unknown[],
) {}
}
/**
* File system error with specific error codes
*/
export class FileSystemError extends Error {
public code: string
constructor(message: string, code: string = "Unknown") {
super(message)
this.name = "FileSystemError"
this.code = code
}
static FileNotFound(messageOrUri?: string | IUri): FileSystemError {
const message =
typeof messageOrUri === "string" ? messageOrUri : `File not found: ${messageOrUri?.fsPath || "unknown"}`
return new FileSystemError(message, "FileNotFound")
}
static FileExists(messageOrUri?: string | IUri): FileSystemError {
const message =
typeof messageOrUri === "string" ? messageOrUri : `File exists: ${messageOrUri?.fsPath || "unknown"}`
return new FileSystemError(message, "FileExists")
}
static FileNotADirectory(messageOrUri?: string | IUri): FileSystemError {
const message =
typeof messageOrUri === "string"
? messageOrUri
: `File is not a directory: ${messageOrUri?.fsPath || "unknown"}`
return new FileSystemError(message, "FileNotADirectory")
}
static FileIsADirectory(messageOrUri?: string | IUri): FileSystemError {
const message =
typeof messageOrUri === "string"
? messageOrUri
: `File is a directory: ${messageOrUri?.fsPath || "unknown"}`
return new FileSystemError(message, "FileIsADirectory")
}
static NoPermissions(messageOrUri?: string | IUri): FileSystemError {
const message =
typeof messageOrUri === "string" ? messageOrUri : `No permissions: ${messageOrUri?.fsPath || "unknown"}`
return new FileSystemError(message, "NoPermissions")
}
static Unavailable(messageOrUri?: string | IUri): FileSystemError {
const message =
typeof messageOrUri === "string" ? messageOrUri : `Unavailable: ${messageOrUri?.fsPath || "unknown"}`
return new FileSystemError(message, "Unavailable")
}
}

View file

@ -0,0 +1,48 @@
/**
* CancellationToken and CancellationTokenSource for VSCode API
*/
import { EventEmitter } from "./EventEmitter.js"
import type { Disposable } from "../interfaces/workspace.js"
/**
* Cancellation token interface
*/
export interface CancellationToken {
isCancellationRequested: boolean
onCancellationRequested: (listener: (e: unknown) => void) => Disposable
}
/**
* CancellationTokenSource creates and controls a CancellationToken
*/
export class CancellationTokenSource {
private _token: CancellationToken
private _isCancelled = false
private _onCancellationRequestedEmitter = new EventEmitter<void>()
constructor() {
this._token = {
isCancellationRequested: false,
onCancellationRequested: this._onCancellationRequestedEmitter.event,
}
}
get token(): CancellationToken {
return this._token
}
cancel(): void {
if (!this._isCancelled) {
this._isCancelled = true
// Type assertion needed to modify readonly property
;(this._token as { isCancellationRequested: boolean }).isCancellationRequested = true
this._onCancellationRequestedEmitter.fire(undefined)
}
}
dispose(): void {
this.cancel()
this._onCancellationRequestedEmitter.dispose()
}
}

View file

@ -0,0 +1,88 @@
import type { Disposable, Event } from "../types.js"
/**
* VSCode-compatible EventEmitter implementation
*
* Provides a type-safe event emitter that matches VSCode's EventEmitter API.
* Listeners can subscribe to events and will be notified when events are fired.
*
* @example
* ```typescript
* const emitter = new EventEmitter<string>()
*
* // Subscribe to events
* const disposable = emitter.event((value) => {
* console.log('Event fired:', value)
* })
*
* // Fire an event
* emitter.fire('Hello, world!')
*
* // Clean up
* disposable.dispose()
* emitter.dispose()
* ```
*/
export class EventEmitter<T> {
readonly #listeners = new Set<(e: T) => void>()
/**
* The event that listeners can subscribe to
*
* @param listener - The callback function to invoke when the event fires
* @param thisArgs - Optional 'this' context for the listener
* @param disposables - Optional array to add the disposable to
* @returns A disposable to unsubscribe from the event
*/
event: Event<T> = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]): Disposable => {
const fn = thisArgs ? listener.bind(thisArgs) : listener
this.#listeners.add(fn)
const disposable: Disposable = {
dispose: () => {
this.#listeners.delete(fn)
},
}
if (disposables) {
disposables.push(disposable)
}
return disposable
}
/**
* Fire the event, notifying all subscribers
*
* Failure of one or more listeners will not fail this function call.
* Failed listeners will be caught and ignored to prevent one listener
* from breaking others.
*
* @param data - The event data to pass to listeners
*/
fire(data: T): void {
for (const listener of this.#listeners) {
try {
listener(data)
} catch (error) {
// Silently ignore listener errors to prevent one failing listener
// from affecting others. Consumers can add error handling in their listeners.
console.error("EventEmitter listener error:", error)
}
}
}
/**
* Dispose this event emitter and remove all listeners
*/
dispose(): void {
this.#listeners.clear()
}
/**
* Get the current number of listeners (useful for debugging)
*/
get listenerCount(): number {
return this.#listeners.size
}
}

View file

@ -0,0 +1,46 @@
/**
* OutputChannel class for VSCode API
*/
import { logs } from "../utils/logger.js"
import type { Disposable } from "../interfaces/workspace.js"
/**
* Output channel mock for CLI mode
* Logs output to the configured logger instead of VSCode's output panel
*/
export class OutputChannel implements Disposable {
private _name: string
constructor(name: string) {
this._name = name
}
get name(): string {
return this._name
}
append(value: string): void {
logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel")
}
appendLine(value: string): void {
logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel")
}
clear(): void {
// No-op for CLI
}
show(): void {
// No-op for CLI
}
hide(): void {
// No-op for CLI
}
dispose(): void {
// No-op for CLI
}
}

View file

@ -0,0 +1,148 @@
import type { IPosition } from "../types.js"
/**
* Represents a position in a text document
*
* A position is defined by a zero-based line number and a zero-based character offset.
* This class is immutable - all methods that modify the position return a new instance.
*
* @example
* ```typescript
* const pos = new Position(5, 10) // Line 5, character 10
* const next = pos.translate(1, 0) // Line 6, character 10
* ```
*/
export class Position implements IPosition {
/**
* The zero-based line number
*/
public readonly line: number
/**
* The zero-based character offset
*/
public readonly character: number
/**
* Create a new Position
*
* @param line - The zero-based line number
* @param character - The zero-based character offset
*/
constructor(line: number, character: number) {
if (line < 0) {
throw new Error("Line number must be non-negative")
}
if (character < 0) {
throw new Error("Character offset must be non-negative")
}
this.line = line
this.character = character
}
/**
* Check if this position is equal to another position
*/
isEqual(other: IPosition): boolean {
return this.line === other.line && this.character === other.character
}
/**
* Check if this position is before another position
*/
isBefore(other: IPosition): boolean {
if (this.line < other.line) {
return true
}
if (this.line === other.line) {
return this.character < other.character
}
return false
}
/**
* Check if this position is before or equal to another position
*/
isBeforeOrEqual(other: IPosition): boolean {
return this.isBefore(other) || this.isEqual(other)
}
/**
* Check if this position is after another position
*/
isAfter(other: IPosition): boolean {
return !this.isBeforeOrEqual(other)
}
/**
* Check if this position is after or equal to another position
*/
isAfterOrEqual(other: IPosition): boolean {
return !this.isBefore(other)
}
/**
* Compare this position to another
*
* @returns -1 if this position is before, 0 if equal, 1 if after
*/
compareTo(other: IPosition): number {
if (this.line < other.line) {
return -1
}
if (this.line > other.line) {
return 1
}
if (this.character < other.character) {
return -1
}
if (this.character > other.character) {
return 1
}
return 0
}
/**
* Create a new position relative to this position
*
* @param lineDelta - The line delta (default: 0)
* @param characterDelta - The character delta (default: 0)
* @returns A new Position
*/
translate(lineDelta?: number, characterDelta?: number): Position
translate(change: { lineDelta?: number; characterDelta?: number }): Position
translate(
lineDeltaOrChange?: number | { lineDelta?: number; characterDelta?: number },
characterDelta?: number,
): Position {
if (typeof lineDeltaOrChange === "object") {
return new Position(
this.line + (lineDeltaOrChange.lineDelta || 0),
this.character + (lineDeltaOrChange.characterDelta || 0),
)
}
return new Position(this.line + (lineDeltaOrChange || 0), this.character + (characterDelta || 0))
}
/**
* Create a new position with changed line or character
*
* @param line - The new line number (or undefined to keep current)
* @param character - The new character offset (or undefined to keep current)
* @returns A new Position
*/
with(line?: number, character?: number): Position
with(change: { line?: number; character?: number }): Position
with(lineOrChange?: number | { line?: number; character?: number }, character?: number): Position {
if (typeof lineOrChange === "object") {
return new Position(
lineOrChange.line !== undefined ? lineOrChange.line : this.line,
lineOrChange.character !== undefined ? lineOrChange.character : this.character,
)
}
return new Position(
lineOrChange !== undefined ? lineOrChange : this.line,
character !== undefined ? character : this.character,
)
}
}

View file

@ -0,0 +1,137 @@
import { Position } from "./Position.js"
import type { IRange, IPosition } from "../types.js"
/**
* Represents a range in a text document
*
* A range is defined by two positions: a start and an end position.
* This class is immutable - all methods that modify the range return a new instance.
*
* @example
* ```typescript
* // Create a range from line 0 to line 5
* const range = new Range(
* new Position(0, 0),
* new Position(5, 10)
* )
*
* // Or use the overload with line/character numbers
* const range2 = new Range(0, 0, 5, 10)
* ```
*/
export class Range implements IRange {
public readonly start: Position
public readonly end: Position
/**
* Create a new Range
*
* @param start - The start position
* @param end - The end position
*/
constructor(start: IPosition, end: IPosition)
/**
* Create a new Range from line and character numbers
*
* @param startLine - The start line number
* @param startCharacter - The start character offset
* @param endLine - The end line number
* @param endCharacter - The end character offset
*/
constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number)
constructor(
startOrStartLine: IPosition | number,
endOrStartCharacter: IPosition | number,
endLine?: number,
endCharacter?: number,
) {
if (typeof startOrStartLine === "number") {
this.start = new Position(startOrStartLine, endOrStartCharacter as number)
this.end = new Position(endLine!, endCharacter!)
} else {
this.start = startOrStartLine as Position
this.end = endOrStartCharacter as Position
}
}
/**
* Check if this range is empty (start equals end)
*/
get isEmpty(): boolean {
return this.start.isEqual(this.end)
}
/**
* Check if this range is on a single line
*/
get isSingleLine(): boolean {
return this.start.line === this.end.line
}
/**
* Check if this range contains a position or range
*
* @param positionOrRange - The position or range to check
* @returns true if the position/range is within this range
*/
contains(positionOrRange: IPosition | IRange): boolean {
if ("start" in positionOrRange && "end" in positionOrRange) {
// It's a range
return this.contains(positionOrRange.start) && this.contains(positionOrRange.end)
}
// It's a position
return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end)
}
/**
* Check if this range is equal to another range
*/
isEqual(other: IRange): boolean {
return this.start.isEqual(other.start) && this.end.isEqual(other.end)
}
/**
* Get the intersection of this range with another range
*
* @param other - The other range
* @returns The intersection range, or undefined if they don't intersect
*/
intersection(other: IRange): Range | undefined {
const start = this.start.isAfter(other.start) ? this.start : other.start
const end = this.end.isBefore(other.end) ? this.end : other.end
if (start.isAfter(end)) {
return undefined
}
return new Range(start, end)
}
/**
* Get the union of this range with another range
*
* @param other - The other range
* @returns A new range that spans both ranges
*/
union(other: IRange): Range {
const start = this.start.isBefore(other.start) ? this.start : other.start
const end = this.end.isAfter(other.end) ? this.end : other.end
return new Range(start, end)
}
/**
* Create a new range with modified start or end positions
*
* @param start - The new start position (or undefined to keep current)
* @param end - The new end position (or undefined to keep current)
* @returns A new Range
*/
with(start?: IPosition, end?: IPosition): Range
with(change: { start?: IPosition; end?: IPosition }): Range
with(startOrChange?: IPosition | { start?: IPosition; end?: IPosition }, end?: IPosition): Range {
// Check if it's a change object (has start or end property, but not line/character like a Position)
if (startOrChange && typeof startOrChange === "object" && !("line" in startOrChange)) {
const change = startOrChange as { start?: IPosition; end?: IPosition }
return new Range(change.start || this.start, change.end || this.end)
}
return new Range((startOrChange as IPosition) || this.start, end || this.end)
}
}

View file

@ -0,0 +1,79 @@
import { Range } from "./Range.js"
import { Position } from "./Position.js"
import type { ISelection, IPosition } from "../types.js"
/**
* Represents a text selection in an editor
*
* A selection extends Range with anchor and active positions.
* The anchor is where the selection starts, and the active is where it ends.
* The selection can be reversed if the active position is before the anchor.
*
* @example
* ```typescript
* // Create a selection from position 0,0 to 5,10
* const selection = new Selection(
* new Position(0, 0),
* new Position(5, 10)
* )
*
* console.log(selection.isReversed) // false
* ```
*/
export class Selection extends Range implements ISelection {
/**
* The anchor position (where the selection started)
*/
public readonly anchor: Position
/**
* The active position (where the selection currently ends)
*/
public readonly active: Position
/**
* Create a new Selection
*
* @param anchor - The anchor position
* @param active - The active position
*/
constructor(anchor: IPosition, active: IPosition)
/**
* Create a new Selection from line and character numbers
*
* @param anchorLine - The anchor line number
* @param anchorCharacter - The anchor character offset
* @param activeLine - The active line number
* @param activeCharacter - The active character offset
*/
constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number)
constructor(
anchorOrAnchorLine: IPosition | number,
activeOrAnchorCharacter: IPosition | number,
activeLine?: number,
activeCharacter?: number,
) {
let anchor: Position
let active: Position
if (typeof anchorOrAnchorLine === "number") {
anchor = new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number)
active = new Position(activeLine!, activeCharacter!)
} else {
anchor = anchorOrAnchorLine as Position
active = activeOrAnchorCharacter as Position
}
super(anchor, active)
this.anchor = anchor
this.active = active
}
/**
* Check if the selection is reversed
* A reversed selection has the active position before the anchor position
*/
get isReversed(): boolean {
return this.anchor.isAfter(this.active)
}
}

View file

@ -0,0 +1,79 @@
/**
* StatusBarItem class for VSCode API
*/
import { StatusBarAlignment } from "../types.js"
import type { Disposable } from "../interfaces/workspace.js"
/**
* Status bar item mock for CLI mode
*/
export class StatusBarItem implements Disposable {
private _text: string = ""
private _tooltip: string | undefined
private _command: string | undefined
private _color: string | undefined
private _backgroundColor: string | undefined
private _isVisible: boolean = false
constructor(
public readonly alignment: StatusBarAlignment,
public readonly priority?: number,
) {}
get text(): string {
return this._text
}
set text(value: string) {
this._text = value
}
get tooltip(): string | undefined {
return this._tooltip
}
set tooltip(value: string | undefined) {
this._tooltip = value
}
get command(): string | undefined {
return this._command
}
set command(value: string | undefined) {
this._command = value
}
get color(): string | undefined {
return this._color
}
set color(value: string | undefined) {
this._color = value
}
get backgroundColor(): string | undefined {
return this._backgroundColor
}
set backgroundColor(value: string | undefined) {
this._backgroundColor = value
}
get isVisible(): boolean {
return this._isVisible
}
show(): void {
this._isVisible = true
}
hide(): void {
this._isVisible = false
}
dispose(): void {
this._isVisible = false
}
}

View file

@ -0,0 +1,209 @@
import { Position } from "./Position.js"
import { Range } from "./Range.js"
import type { IRange, IPosition } from "../types.js"
/**
* Represents a text edit operation
*
* A text edit replaces text in a specific range with new text.
* This is used to modify documents programmatically.
*
* @example
* ```typescript
* // Replace text in a range
* const edit = TextEdit.replace(
* new Range(0, 0, 0, 5),
* 'Hello'
* )
*
* // Insert text at a position
* const insert = TextEdit.insert(
* new Position(0, 0),
* 'New text'
* )
*
* // Delete text in a range
* const deletion = TextEdit.delete(
* new Range(0, 0, 0, 10)
* )
* ```
*/
export class TextEdit {
/**
* The range to replace
*/
public readonly range: Range
/**
* The new text (empty string for deletion)
*/
public readonly newText: string
/**
* Create a new TextEdit
*
* @param range - The range to replace
* @param newText - The new text
*/
constructor(range: IRange, newText: string) {
this.range = range as Range
this.newText = newText
}
/**
* Create a replace edit
*
* @param range - The range to replace
* @param newText - The new text
* @returns A new TextEdit
*/
static replace(range: IRange, newText: string): TextEdit {
return new TextEdit(range, newText)
}
/**
* Create an insert edit
*
* @param position - The position to insert at
* @param newText - The text to insert
* @returns A new TextEdit
*/
static insert(position: IPosition, newText: string): TextEdit {
return new TextEdit(new Range(position, position), newText)
}
/**
* Create a delete edit
*
* @param range - The range to delete
* @returns A new TextEdit
*/
static delete(range: IRange): TextEdit {
return new TextEdit(range, "")
}
/**
* Create an edit to set the end of line sequence
*
* @returns A new TextEdit (simplified implementation)
*/
static setEndOfLine(): TextEdit {
return new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "")
}
}
/**
* Represents a collection of text edits for a document
*
* A WorkspaceEdit can contain edits for multiple documents.
*
* @example
* ```typescript
* const edit = new WorkspaceEdit()
*
* // Add edits for a file
* edit.set(uri, [
* TextEdit.replace(range1, 'new text'),
* TextEdit.insert(pos, 'inserted')
* ])
*
* // Apply the edit
* await vscode.workspace.applyEdit(edit)
* ```
*/
export class WorkspaceEdit {
private _edits: Map<string, TextEdit[]> = new Map()
/**
* Set edits for a specific URI
*
* @param uri - The document URI
* @param edits - Array of text edits
*/
set(uri: { toString(): string }, edits: TextEdit[]): void {
this._edits.set(uri.toString(), edits)
}
/**
* Get edits for a specific URI
*
* @param uri - The document URI
* @returns Array of text edits, or empty array if none
*/
get(uri: { toString(): string }): TextEdit[] {
return this._edits.get(uri.toString()) || []
}
/**
* Check if edits exist for a URI
*
* @param uri - The document URI
* @returns true if edits exist
*/
has(uri: { toString(): string }): boolean {
return this._edits.has(uri.toString())
}
/**
* Add a delete edit for a range
*
* @param uri - The document URI
* @param range - The range to delete
*/
delete(uri: { toString(): string }, range: IRange): void {
const key = uri.toString()
if (!this._edits.has(key)) {
this._edits.set(key, [])
}
this._edits.get(key)!.push(TextEdit.delete(range))
}
/**
* Add an insert edit
*
* @param uri - The document URI
* @param position - The position to insert at
* @param newText - The text to insert
*/
insert(uri: { toString(): string }, position: IPosition, newText: string): void {
const key = uri.toString()
if (!this._edits.has(key)) {
this._edits.set(key, [])
}
this._edits.get(key)!.push(TextEdit.insert(position, newText))
}
/**
* Add a replace edit
*
* @param uri - The document URI
* @param range - The range to replace
* @param newText - The new text
*/
replace(uri: { toString(): string }, range: IRange, newText: string): void {
const key = uri.toString()
if (!this._edits.has(key)) {
this._edits.set(key, [])
}
this._edits.get(key)!.push(TextEdit.replace(range, newText))
}
/**
* Get the number of documents with edits
*/
get size(): number {
return this._edits.size
}
/**
* Get all URI and edits pairs
*
* @returns Array of [URI, TextEdit[]] pairs
*/
entries(): [{ toString(): string; fsPath: string }, TextEdit[]][] {
return Array.from(this._edits.entries()).map(([uriString, edits]) => {
// Parse the URI string back to a URI-like object
return [{ toString: () => uriString, fsPath: uriString.replace(/^file:\/\//, "") }, edits]
})
}
}

View file

@ -0,0 +1,20 @@
/**
* TextEditorDecorationType class for VSCode API
*/
import type { Disposable } from "../interfaces/workspace.js"
/**
* Text editor decoration type mock for CLI mode
*/
export class TextEditorDecorationType implements Disposable {
public key: string
constructor(key: string) {
this.key = key
}
dispose(): void {
// No-op for CLI
}
}

View file

@ -0,0 +1,124 @@
import * as path from "path"
/**
* Uniform Resource Identifier (URI) implementation
*
* Represents a URI following the RFC 3986 standard.
* This class is compatible with VSCode's Uri class and provides
* file system path handling for cross-platform compatibility.
*
* @example
* ```typescript
* // Create a file URI
* const fileUri = Uri.file('/path/to/file.txt')
* console.log(fileUri.fsPath) // '/path/to/file.txt'
*
* // Parse a URI string
* const uri = Uri.parse('https://example.com/path?query=1#fragment')
* console.log(uri.scheme) // 'https'
* console.log(uri.path) // '/path'
* ```
*/
export class Uri {
public readonly scheme: string
public readonly authority: string
public readonly path: string
public readonly query: string
public readonly fragment: string
constructor(scheme: string, authority: string, path: string, query: string, fragment: string) {
this.scheme = scheme
this.authority = authority
this.path = path
this.query = query
this.fragment = fragment
}
/**
* Create a URI from a file system path
*
* @param path - The file system path
* @returns A new Uri instance with 'file' scheme
*/
static file(fsPath: string): Uri {
return new Uri("file", "", fsPath, "", "")
}
/**
* Parse a URI string
*
* @param value - The URI string to parse
* @returns A new Uri instance
*/
static parse(value: string): Uri {
try {
const url = new URL(value)
return new Uri(
url.protocol.slice(0, -1),
url.hostname,
url.pathname,
url.search.slice(1),
url.hash.slice(1),
)
} catch {
// If URL parsing fails, treat as file path
return Uri.file(value)
}
}
/**
* Join a URI with path segments
*
* @param base - The base URI
* @param pathSegments - Path segments to join
* @returns A new Uri with the joined path
*/
static joinPath(base: Uri, ...pathSegments: string[]): Uri {
const joinedPath = path.join(base.path, ...pathSegments)
return new Uri(base.scheme, base.authority, joinedPath, base.query, base.fragment)
}
/**
* Create a new URI with modifications
*
* @param change - The changes to apply
* @returns A new Uri instance with the changes applied
*/
with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri {
return new Uri(
change.scheme !== undefined ? change.scheme : this.scheme,
change.authority !== undefined ? change.authority : this.authority,
change.path !== undefined ? change.path : this.path,
change.query !== undefined ? change.query : this.query,
change.fragment !== undefined ? change.fragment : this.fragment,
)
}
/**
* Get the file system path representation
* Compatible with both Unix and Windows paths
*/
get fsPath(): string {
return this.path
}
/**
* Convert the URI to a string representation
*/
toString(): string {
return `${this.scheme}://${this.authority}${this.path}${this.query ? "?" + this.query : ""}${this.fragment ? "#" + this.fragment : ""}`
}
/**
* Convert to JSON representation
*/
toJSON(): object {
return {
scheme: this.scheme,
authority: this.authority,
path: this.path,
query: this.query,
fragment: this.fragment,
}
}
}

View file

@ -0,0 +1,158 @@
import * as path from "path"
import * as fs from "fs"
import { Uri } from "../classes/Uri.js"
import { FileMemento } from "../storage/Memento.js"
import { FileSecretStorage } from "../storage/SecretStorage.js"
import { hashWorkspacePath, ensureDirectoryExists } from "../utils/paths.js"
import type {
ExtensionContext,
Extension,
Disposable,
Memento,
SecretStorage,
ExtensionMode,
ExtensionKind,
} from "../types.js"
/**
* Options for creating an ExtensionContext
*/
export interface ExtensionContextOptions {
/**
* Path to the extension's root directory
*/
extensionPath: string
/**
* Path to the workspace directory
*/
workspacePath: string
/**
* Optional custom storage directory (defaults to ~/.vscode-mock)
*/
storageDir?: string
/**
* Extension mode (Production, Development, or Test)
*/
extensionMode?: ExtensionMode
}
/**
* Implementation of VSCode's ExtensionContext
*
* Provides the context object passed to extension activation functions.
* This includes state storage, secrets, and extension metadata.
*
* @example
* ```typescript
* const context = new ExtensionContextImpl({
* extensionPath: '/path/to/extension',
* workspacePath: '/path/to/workspace'
* })
*
* // Use in extension activation
* const api = await extension.activate(context)
* ```
*/
export class ExtensionContextImpl implements ExtensionContext {
public subscriptions: Disposable[] = []
public workspaceState: Memento
public globalState: Memento & { setKeysForSync(keys: readonly string[]): void }
public secrets: SecretStorage
public extensionUri: Uri
public extensionPath: string
public environmentVariableCollection: Record<string, unknown> = {}
public storageUri: Uri | undefined
public storagePath: string | undefined
public globalStorageUri: Uri
public globalStoragePath: string
public logUri: Uri
public logPath: string
public extensionMode: ExtensionMode
public extension: Extension<unknown> | undefined
constructor(options: ExtensionContextOptions) {
this.extensionPath = options.extensionPath
this.extensionUri = Uri.file(options.extensionPath)
this.extensionMode = options.extensionMode || 1 // Default to Production
// Setup storage paths
const baseStorageDir =
options.storageDir || path.join(process.env.HOME || process.env.USERPROFILE || ".", ".vscode-mock")
const workspaceHash = hashWorkspacePath(options.workspacePath)
this.globalStoragePath = path.join(baseStorageDir, "global-storage")
this.globalStorageUri = Uri.file(this.globalStoragePath)
const workspaceStoragePath = path.join(baseStorageDir, "workspace-storage", workspaceHash)
this.storagePath = workspaceStoragePath
this.storageUri = Uri.file(workspaceStoragePath)
this.logPath = path.join(baseStorageDir, "logs")
this.logUri = Uri.file(this.logPath)
// Ensure directories exist
ensureDirectoryExists(this.globalStoragePath)
ensureDirectoryExists(workspaceStoragePath)
ensureDirectoryExists(this.logPath)
// Initialize state storage
this.workspaceState = new FileMemento(path.join(workspaceStoragePath, "workspace-state.json"))
const globalMemento = new FileMemento(path.join(this.globalStoragePath, "global-state.json"))
this.globalState = Object.assign(globalMemento, {
setKeysForSync: (_keys: readonly string[]) => {
// No-op for mock implementation
},
})
this.secrets = new FileSecretStorage(this.globalStoragePath)
// Load extension metadata (packageJSON)
this.extension = this.loadExtensionMetadata()
}
/**
* Load extension metadata from package.json
*/
private loadExtensionMetadata(): Extension<unknown> | undefined {
try {
// Try to load package.json from extension path
const packageJsonPath = path.join(this.extensionPath, "package.json")
if (fs.existsSync(packageJsonPath)) {
const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
const extensionId = `${packageJSON.publisher || "unknown"}.${packageJSON.name || "unknown"}`
return {
id: extensionId,
extensionUri: this.extensionUri,
extensionPath: this.extensionPath,
isActive: true,
packageJSON,
exports: undefined,
extensionKind: 1 as ExtensionKind, // UI
activate: () => Promise.resolve(undefined),
}
}
} catch {
// Ignore errors loading package.json
}
return undefined
}
/**
* Dispose all subscriptions
*/
dispose(): void {
for (const subscription of this.subscriptions) {
try {
subscription.dispose()
} catch (error) {
console.error("Error disposing subscription:", error)
}
}
this.subscriptions = []
}
}

View file

@ -0,0 +1,112 @@
/**
* @roo-code/vscode-shim
*
* A production-ready VSCode API mock for running VSCode extensions in Node.js CLI applications.
* This package provides a complete implementation of the VSCode Extension API, allowing you to
* run VSCode extensions without VSCode installed.
*
* @packageDocumentation
*/
// Export the complete VSCode API implementation
export {
// Main factory function
createVSCodeAPIMock,
// Classes
Uri,
Position,
Range,
Selection,
EventEmitter,
Location,
Diagnostic,
DiagnosticRelatedInformation,
TextEdit,
WorkspaceEdit,
ThemeColor,
ThemeIcon,
CodeActionKind,
CancellationTokenSource,
CodeLens,
LanguageModelTextPart,
LanguageModelToolCallPart,
LanguageModelToolResultPart,
FileSystemError,
OutputChannel,
StatusBarItem,
TextEditorDecorationType,
ExtensionContext,
// API classes
WorkspaceAPI,
WindowAPI,
CommandsAPI,
TabGroupsAPI,
FileSystemAPI,
MockWorkspaceConfiguration,
// Runtime configuration utilities
setRuntimeConfig,
setRuntimeConfigValues,
clearRuntimeConfig,
getRuntimeConfig,
// Enums
ConfigurationTarget,
ViewColumn,
TextEditorRevealType,
StatusBarAlignment,
DiagnosticSeverity,
DiagnosticTag,
EndOfLine,
UIKind,
ExtensionMode,
ExtensionKind,
FileType,
DecorationRangeBehavior,
OverviewRulerLane,
// Types
type IdentityInfo,
type Thenable,
type Disposable,
type TextDocument,
type TextLine,
type WorkspaceFolder,
type WorkspaceConfiguration,
type Memento,
type SecretStorage,
type FileStat,
type Terminal,
type CancellationToken,
} from "./vscode.js"
// Export utilities
export { logs, setLogger, type Logger } from "./utils/logger.js"
export { VSCodeMockPaths } from "./utils/paths.js"
export { machineIdSync } from "./utils/machine-id.js"
// Re-export as createVSCodeAPI for simpler API
export { createVSCodeAPIMock as createVSCodeAPI } from "./vscode.js"
/**
* Quick start function to create a complete VSCode API mock
*
* @example
* ```typescript
* import { createVSCodeAPI } from '@roo-code/vscode-shim'
*
* const vscode = createVSCodeAPI({
* extensionPath: '/path/to/extension',
* workspacePath: '/path/to/workspace'
* })
*
* // Set global vscode for extension to use
* global.vscode = vscode
*
* // Load and activate extension
* const extension = require('/path/to/extension.js')
* const api = await extension.activate(vscode.context)
* ```
*/

View file

@ -0,0 +1,114 @@
/**
* Document-related interfaces for VSCode API
*/
import type { Range } from "../classes/Range.js"
import type { Position } from "../classes/Position.js"
import type { Uri } from "../classes/Uri.js"
import type { Thenable, Disposable } from "../types.js"
/**
* Represents a text document in VSCode
*/
export interface TextDocument {
uri: Uri
fileName: string
languageId: string
version: number
isDirty: boolean
isClosed: boolean
lineCount: number
getText(range?: Range): string
lineAt(line: number): TextLine
offsetAt(position: Position): number
positionAt(offset: number): Position
save(): Thenable<boolean>
validateRange(range: Range): Range
validatePosition(position: Position): Position
}
/**
* Represents a line of text in a document
*/
export interface TextLine {
text: string
range: Range
rangeIncludingLineBreak: Range
firstNonWhitespaceCharacterIndex: number
isEmptyOrWhitespace: boolean
}
/**
* Event fired when workspace folders change
*/
export interface WorkspaceFoldersChangeEvent {
added: WorkspaceFolder[]
removed: WorkspaceFolder[]
}
/**
* Represents a workspace folder
*/
export interface WorkspaceFolder {
uri: Uri
name: string
index: number
}
/**
* Event fired when a text document changes
*/
export interface TextDocumentChangeEvent {
document: TextDocument
contentChanges: readonly TextDocumentContentChangeEvent[]
}
/**
* Represents a change in a text document
*/
export interface TextDocumentContentChangeEvent {
range: Range
rangeOffset: number
rangeLength: number
text: string
}
/**
* Event fired when configuration changes
*/
export interface ConfigurationChangeEvent {
affectsConfiguration(section: string, scope?: Uri): boolean
}
/**
* Provider for text document content
*/
export interface TextDocumentContentProvider {
provideTextDocumentContent(uri: Uri, token: CancellationToken): Thenable<string>
onDidChange?: (listener: (e: Uri) => void) => Disposable
}
/**
* Cancellation token interface (must be local to avoid conflict with ES2023 built-in)
*/
export interface CancellationToken {
isCancellationRequested: boolean
onCancellationRequested: (listener: (e: unknown) => void) => Disposable
}
/**
* File system watcher interface
*/
export interface FileSystemWatcher extends Disposable {
onDidChange: (listener: (e: Uri) => void) => Disposable
onDidCreate: (listener: (e: Uri) => void) => Disposable
onDidDelete: (listener: (e: Uri) => void) => Disposable
}
/**
* Relative pattern for file matching
*/
export interface RelativePattern {
base: string
pattern: string
}

View file

@ -0,0 +1,107 @@
/**
* Editor-related interfaces for VSCode API
*/
import type { Range } from "../classes/Range.js"
import type { Position } from "../classes/Position.js"
import type { Selection } from "../classes/Selection.js"
import type { Uri } from "../classes/Uri.js"
import type { ThemeColor } from "../classes/Additional.js"
import type {
Thenable,
ViewColumn,
TextEditorRevealType,
EndOfLine,
DecorationRangeBehavior,
OverviewRulerLane,
TextEditorOptions,
} from "../types.js"
import type { TextDocument } from "./document.js"
import type { Disposable } from "../types.js"
/**
* Represents a text editor in VSCode
*/
export interface TextEditor {
document: TextDocument
selection: Selection
selections: Selection[]
visibleRanges: Range[]
options: TextEditorOptions
viewColumn?: ViewColumn
edit(callback: (editBuilder: TextEditorEdit) => void): Thenable<boolean>
insertSnippet(
snippet: unknown,
location?: Position | Range | readonly Position[] | readonly Range[],
): Thenable<boolean>
setDecorations(decorationType: TextEditorDecorationType, rangesOrOptions: readonly Range[]): void
revealRange(range: Range, revealType?: TextEditorRevealType): void
show(column?: ViewColumn): void
hide(): void
}
/**
* Builder for text editor edits
*/
export interface TextEditorEdit {
replace(location: Position | Range | Selection, value: string): void
insert(location: Position, value: string): void
delete(location: Range | Selection): void
setEndOfLine(endOfLine: EndOfLine): void
}
/**
* Event fired when text editor selection changes
*/
export interface TextEditorSelectionChangeEvent {
textEditor: TextEditor
selections: readonly Selection[]
kind?: number
}
/**
* Options for showing a text document
*/
export interface TextDocumentShowOptions {
viewColumn?: ViewColumn
preserveFocus?: boolean
preview?: boolean
selection?: Range
}
/**
* Options for rendering decorations
*/
export interface DecorationRenderOptions {
backgroundColor?: string | ThemeColor
border?: string
borderColor?: string | ThemeColor
borderRadius?: string
borderSpacing?: string
borderStyle?: string
borderWidth?: string
color?: string | ThemeColor
cursor?: string
fontStyle?: string
fontWeight?: string
gutterIconPath?: string | Uri
gutterIconSize?: string
isWholeLine?: boolean
letterSpacing?: string
opacity?: string
outline?: string
outlineColor?: string | ThemeColor
outlineStyle?: string
outlineWidth?: string
overviewRulerColor?: string | ThemeColor
overviewRulerLane?: OverviewRulerLane
rangeBehavior?: DecorationRangeBehavior
textDecoration?: string
}
/**
* Text editor decoration type interface
*/
export interface TextEditorDecorationType extends Disposable {
key: string
}

View file

@ -0,0 +1,76 @@
/**
* Terminal-related interfaces for VSCode API
*/
import type { Uri } from "../classes/Uri.js"
import type { ThemeIcon } from "../classes/Additional.js"
import type { Thenable } from "../types.js"
/**
* Represents a terminal in VSCode
*/
export interface Terminal {
name: string
processId: Thenable<number | undefined>
creationOptions: Readonly<TerminalOptions>
exitStatus: TerminalExitStatus | undefined
state: TerminalState
sendText(text: string, addNewLine?: boolean): void
show(preserveFocus?: boolean): void
hide(): void
dispose(): void
}
/**
* Options for creating a terminal
*/
export interface TerminalOptions {
name?: string
shellPath?: string
shellArgs?: string[] | string
cwd?: string | Uri
env?: { [key: string]: string | null | undefined }
iconPath?: Uri | ThemeIcon
hideFromUser?: boolean
message?: string
strictEnv?: boolean
}
/**
* Exit status of a terminal
*/
export interface TerminalExitStatus {
code: number | undefined
reason: number
}
/**
* State of a terminal
*/
export interface TerminalState {
isInteractedWith: boolean
}
/**
* Event fired when terminal dimensions change
*/
export interface TerminalDimensionsChangeEvent {
terminal: Terminal
dimensions: TerminalDimensions
}
/**
* Terminal dimensions
*/
export interface TerminalDimensions {
columns: number
rows: number
}
/**
* Event fired when data is written to terminal
*/
export interface TerminalDataWriteEvent {
terminal: Terminal
data: string
}

View file

@ -0,0 +1,92 @@
/**
* Webview-related interfaces for VSCode API
*/
import type { Uri } from "../classes/Uri.js"
import type { Thenable, Disposable } from "../types.js"
import type { CancellationToken } from "./document.js"
/**
* Webview view provider interface
*/
export interface WebviewViewProvider {
resolveWebviewView(
webviewView: WebviewView,
context: WebviewViewResolveContext,
token: CancellationToken,
): Thenable<void> | void
}
/**
* Webview view interface
*/
export interface WebviewView {
webview: Webview
viewType: string
title?: string
description?: string
badge?: ViewBadge
show(preserveFocus?: boolean): void
onDidChangeVisibility: (listener: () => void) => Disposable
onDidDispose: (listener: () => void) => Disposable
visible: boolean
}
/**
* Webview interface
*/
export interface Webview {
html: string
options: WebviewOptions
cspSource: string
postMessage(message: unknown): Thenable<boolean>
onDidReceiveMessage: (listener: (message: unknown) => void) => Disposable
asWebviewUri(localResource: Uri): Uri
}
/**
* Webview options interface
*/
export interface WebviewOptions {
enableScripts?: boolean
enableForms?: boolean
localResourceRoots?: readonly Uri[]
portMapping?: readonly WebviewPortMapping[]
}
/**
* Webview port mapping interface
*/
export interface WebviewPortMapping {
webviewPort: number
extensionHostPort: number
}
/**
* View badge interface
*/
export interface ViewBadge {
tooltip: string
value: number
}
/**
* Webview view resolve context
*/
export interface WebviewViewResolveContext {
state?: unknown
}
/**
* Webview view provider options
*/
export interface WebviewViewProviderOptions {
retainContextWhenHidden?: boolean
}
/**
* URI handler interface
*/
export interface UriHandler {
handleUri(uri: Uri): void
}

View file

@ -0,0 +1,91 @@
/**
* Workspace-related interfaces for VSCode API
*/
import type { Uri } from "../classes/Uri.js"
import type { Thenable, ConfigurationTarget, ConfigurationInspect } from "../types.js"
/**
* Workspace configuration interface
*/
export interface WorkspaceConfiguration {
get<T>(section: string): T | undefined
get<T>(section: string, defaultValue: T): T
has(section: string): boolean
inspect<T>(section: string): ConfigurationInspect<T> | undefined
update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable<void>
}
/**
* Quick pick options interface
*/
export interface QuickPickOptions {
placeHolder?: string
canPickMany?: boolean
ignoreFocusOut?: boolean
matchOnDescription?: boolean
matchOnDetail?: boolean
}
/**
* Input box options interface
*/
export interface InputBoxOptions {
value?: string
valueSelection?: [number, number]
prompt?: string
placeHolder?: string
password?: boolean
ignoreFocusOut?: boolean
validateInput?(value: string): string | undefined | null | Thenable<string | undefined | null>
}
/**
* Open dialog options interface
*/
export interface OpenDialogOptions {
defaultUri?: Uri
openLabel?: string
canSelectFiles?: boolean
canSelectFolders?: boolean
canSelectMany?: boolean
filters?: { [name: string]: string[] }
title?: string
}
/**
* Disposable interface for VSCode API (must be local to avoid conflict with ES2023 built-in Disposable)
*/
export interface Disposable {
dispose(): void
}
/**
* Diagnostic collection interface
*/
export interface DiagnosticCollection extends Disposable {
name: string
set(uri: Uri, diagnostics: import("../classes/Additional.js").Diagnostic[] | undefined): void
set(entries: [Uri, import("../classes/Additional.js").Diagnostic[] | undefined][]): void
delete(uri: Uri): void
clear(): void
forEach(
callback: (
uri: Uri,
diagnostics: import("../classes/Additional.js").Diagnostic[],
collection: DiagnosticCollection,
) => void,
thisArg?: unknown,
): void
get(uri: Uri): import("../classes/Additional.js").Diagnostic[] | undefined
has(uri: Uri): boolean
}
/**
* Identity information for VSCode environment
*/
export interface IdentityInfo {
machineId: string
sessionId: string
cliUserId?: string
}

View file

@ -0,0 +1,115 @@
import * as fs from "fs"
import * as path from "path"
import { ensureDirectoryExists } from "../utils/paths.js"
import type { Memento } from "../types.js"
/**
* File-based implementation of VSCode's Memento interface
*
* Provides persistent key-value storage backed by a JSON file.
* This implementation automatically loads from and saves to disk.
*
* @example
* ```typescript
* const memento = new FileMemento('/path/to/state.json')
*
* // Store a value
* await memento.update('lastOpenFile', '/path/to/file.txt')
*
* // Retrieve a value
* const file = memento.get<string>('lastOpenFile')
*
* // With default value
* const count = memento.get<number>('count', 0)
* ```
*/
export class FileMemento implements Memento {
private data: Record<string, unknown> = {}
private filePath: string
/**
* Create a new FileMemento
*
* @param filePath - Path to the JSON file for persistence
*/
constructor(filePath: string) {
this.filePath = filePath
this.loadFromFile()
}
/**
* Load data from the JSON file
*/
private loadFromFile(): void {
try {
if (fs.existsSync(this.filePath)) {
const content = fs.readFileSync(this.filePath, "utf-8")
this.data = JSON.parse(content)
}
} catch (error) {
console.warn(`Failed to load state from ${this.filePath}:`, error)
this.data = {}
}
}
/**
* Save data to the JSON file
*/
private saveToFile(): void {
try {
// Ensure directory exists
const dir = path.dirname(this.filePath)
ensureDirectoryExists(dir)
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
} catch (error) {
console.warn(`Failed to save state to ${this.filePath}:`, error)
}
}
/**
* Get a value from storage
*
* @param key - The key to retrieve
* @param defaultValue - Optional default value if key doesn't exist
* @returns The stored value or default value
*/
get<T>(key: string): T | undefined
get<T>(key: string, defaultValue: T): T
get<T>(key: string, defaultValue?: T): T | undefined {
const value = this.data[key]
return value !== undefined && value !== null ? (value as T) : defaultValue
}
/**
* Update a value in storage
*
* @param key - The key to update
* @param value - The value to store (undefined to delete)
* @returns A promise that resolves when the update is complete
*/
async update(key: string, value: unknown): Promise<void> {
if (value === undefined) {
delete this.data[key]
} else {
this.data[key] = value
}
this.saveToFile()
}
/**
* Get all keys in storage
*
* @returns An array of all keys
*/
keys(): readonly string[] {
return Object.keys(this.data)
}
/**
* Clear all data from storage
*/
clear(): void {
this.data = {}
this.saveToFile()
}
}

View file

@ -0,0 +1,138 @@
import * as fs from "fs"
import * as path from "path"
import { EventEmitter } from "../classes/EventEmitter.js"
import { ensureDirectoryExists } from "../utils/paths.js"
import type { SecretStorage, SecretStorageChangeEvent } from "../types.js"
/**
* File-based implementation of VSCode's SecretStorage interface
*
* Stores secrets in a JSON file on disk. While not encrypted like VSCode's
* native keychain integration, this provides a simple, cross-platform solution
* suitable for CLI applications.
*
* **Security Notes:**
* - Secrets are stored as plain JSON (not encrypted)
* - File permissions should be set restrictive (0600)
* - For production, consider using environment variables instead
* - Suitable for development and non-critical secrets
*
* @example
* ```typescript
* const storage = new FileSecretStorage('/path/to/secrets.json')
*
* // Store a secret
* await storage.store('apiKey', 'sk-...')
*
* // Retrieve a secret
* const key = await storage.get('apiKey')
*
* // Listen for changes
* storage.onDidChange((e) => {
* console.log(`Secret ${e.key} changed`)
* })
* ```
*/
export class FileSecretStorage implements SecretStorage {
private secrets: Record<string, string> = {}
private _onDidChange = new EventEmitter<SecretStorageChangeEvent>()
private filePath: string
/**
* Create a new FileSecretStorage
*
* @param storagePath - Directory path where secrets.json will be stored
*/
constructor(storagePath: string) {
this.filePath = path.join(storagePath, "secrets.json")
this.loadFromFile()
}
/**
* Load secrets from the JSON file
*/
private loadFromFile(): void {
try {
if (fs.existsSync(this.filePath)) {
const content = fs.readFileSync(this.filePath, "utf-8")
this.secrets = JSON.parse(content)
}
} catch (error) {
console.warn(`Failed to load secrets from ${this.filePath}:`, error)
this.secrets = {}
}
}
/**
* Save secrets to the JSON file with restrictive permissions
*/
private saveToFile(): void {
try {
// Ensure directory exists
const dir = path.dirname(this.filePath)
ensureDirectoryExists(dir)
// Write the file
fs.writeFileSync(this.filePath, JSON.stringify(this.secrets, null, 2))
// Set restrictive permissions (owner read/write only) on Unix-like systems
if (process.platform !== "win32") {
try {
fs.chmodSync(this.filePath, 0o600)
} catch {
// Ignore chmod errors (might not be supported on some filesystems)
}
}
} catch (error) {
console.warn(`Failed to save secrets to ${this.filePath}:`, error)
}
}
/**
* Retrieve a secret by key
*
* @param key - The secret key
* @returns The secret value or undefined if not found
*/
async get(key: string): Promise<string | undefined> {
return this.secrets[key]
}
/**
* Store a secret
*
* @param key - The secret key
* @param value - The secret value
*/
async store(key: string, value: string): Promise<void> {
this.secrets[key] = value
this.saveToFile()
this._onDidChange.fire({ key })
}
/**
* Delete a secret
*
* @param key - The secret key to delete
*/
async delete(key: string): Promise<void> {
delete this.secrets[key]
this.saveToFile()
this._onDidChange.fire({ key })
}
/**
* Event fired when a secret changes
*/
get onDidChange() {
return this._onDidChange.event
}
/**
* Clear all secrets (useful for testing)
*/
clearAll(): void {
this.secrets = {}
this.saveToFile()
}
}

View file

@ -0,0 +1,344 @@
/**
* Core VSCode API type definitions
*
* This file contains TypeScript type definitions that match the VSCode Extension API.
* These types allow VSCode extensions to run in Node.js without VSCode installed.
*/
/**
* Represents a thenable (Promise-like) value
*/
export type Thenable<T> = Promise<T>
/**
* Represents a disposable resource that can be cleaned up
*/
export interface Disposable {
dispose(): void
}
/**
* Represents a Uniform Resource Identifier (URI)
*/
export interface IUri {
scheme: string
authority: string
path: string
query: string
fragment: string
fsPath: string
toString(): string
}
/**
* Represents a position in a text document (line and character)
*/
export interface IPosition {
line: number
character: number
isEqual(other: IPosition): boolean
isBefore(other: IPosition): boolean
isBeforeOrEqual(other: IPosition): boolean
isAfter(other: IPosition): boolean
isAfterOrEqual(other: IPosition): boolean
compareTo(other: IPosition): number
}
/**
* Represents a range in a text document (start and end positions)
*/
export interface IRange {
start: IPosition
end: IPosition
isEmpty: boolean
isSingleLine: boolean
contains(positionOrRange: IPosition | IRange): boolean
isEqual(other: IRange): boolean
intersection(other: IRange): IRange | undefined
union(other: IRange): IRange
}
/**
* Represents a selection in a text editor (extends Range with anchor and active positions)
*/
export interface ISelection extends IRange {
anchor: IPosition
active: IPosition
isReversed: boolean
}
/**
* Represents a line of text in a document
*/
export interface TextLine {
text: string
range: IRange
rangeIncludingLineBreak: IRange
firstNonWhitespaceCharacterIndex: number
isEmptyOrWhitespace: boolean
}
/**
* Represents a text document
*/
export interface TextDocument {
uri: IUri
fileName: string
languageId: string
version: number
isDirty: boolean
isClosed: boolean
lineCount: number
getText(range?: IRange): string
lineAt(line: number): TextLine
offsetAt(position: IPosition): number
positionAt(offset: number): IPosition
save(): Thenable<boolean>
validateRange(range: IRange): IRange
validatePosition(position: IPosition): IPosition
}
/**
* Configuration target for settings
*/
export enum ConfigurationTarget {
Global = 1,
Workspace = 2,
WorkspaceFolder = 3,
}
/**
* Workspace folder representation
*/
export interface WorkspaceFolder {
uri: IUri
name: string
index: number
}
/**
* Workspace configuration interface
*/
export interface WorkspaceConfiguration {
get<T>(section: string): T | undefined
get<T>(section: string, defaultValue: T): T
has(section: string): boolean
inspect<T>(section: string): ConfigurationInspect<T> | undefined
update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable<void>
}
/**
* Configuration inspection result
*/
export interface ConfigurationInspect<T> {
key: string
defaultValue?: T
globalValue?: T
workspaceValue?: T
workspaceFolderValue?: T
}
/**
* Memento (state storage) interface
*/
export interface Memento {
get<T>(key: string): T | undefined
get<T>(key: string, defaultValue: T): T
update(key: string, value: unknown): Thenable<void>
keys(): readonly string[]
}
/**
* Secret storage interface for secure credential storage
*/
export interface SecretStorage {
get(key: string): Thenable<string | undefined>
store(key: string, value: string): Thenable<void>
delete(key: string): Thenable<void>
onDidChange: Event<SecretStorageChangeEvent>
}
/**
* Secret storage change event
*/
export interface SecretStorageChangeEvent {
key: string
}
/**
* Represents an extension
*/
export interface Extension<T> {
id: string
extensionUri: IUri
extensionPath: string
isActive: boolean
packageJSON: Record<string, unknown>
exports: T
extensionKind: ExtensionKind
activate(): Thenable<T>
}
/**
* Extension kind enum
*/
export enum ExtensionKind {
UI = 1,
Workspace = 2,
}
/**
* Extension context provided to extension activation
*/
export interface ExtensionContext {
subscriptions: Disposable[]
workspaceState: Memento
globalState: Memento & { setKeysForSync(keys: readonly string[]): void }
secrets: SecretStorage
extensionUri: IUri
extensionPath: string
environmentVariableCollection: Record<string, unknown>
storageUri: IUri | undefined
storagePath: string | undefined
globalStorageUri: IUri
globalStoragePath: string
logUri: IUri
logPath: string
extensionMode: ExtensionMode
extension: Extension<unknown> | undefined
}
/**
* Extension mode enum
*/
export enum ExtensionMode {
Production = 1,
Development = 2,
Test = 3,
}
/**
* Event emitter event type
*/
export type Event<T> = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]) => Disposable
/**
* Cancellation token for async operations
*/
export interface CancellationToken {
isCancellationRequested: boolean
onCancellationRequested: Event<unknown>
}
/**
* File system file type enum
*/
export enum FileType {
Unknown = 0,
File = 1,
Directory = 2,
SymbolicLink = 64,
}
/**
* File system stat information
*/
export interface FileStat {
type: FileType
ctime: number
mtime: number
size: number
}
/**
* Text editor options
*/
export interface TextEditorOptions {
tabSize?: number
insertSpaces?: boolean
cursorStyle?: number
lineNumbers?: number
}
/**
* View column enum for editor placement
*/
export enum ViewColumn {
Active = -1,
Beside = -2,
One = 1,
Two = 2,
Three = 3,
}
/**
* UI Kind enum
*/
export enum UIKind {
Desktop = 1,
Web = 2,
}
/**
* End of line sequence enum
*/
export enum EndOfLine {
LF = 1,
CRLF = 2,
}
/**
* Status bar alignment
*/
export enum StatusBarAlignment {
Left = 1,
Right = 2,
}
/**
* Diagnostic severity levels
*/
export enum DiagnosticSeverity {
Error = 0,
Warning = 1,
Information = 2,
Hint = 3,
}
/**
* Diagnostic tags
*/
export enum DiagnosticTag {
Unnecessary = 1,
Deprecated = 2,
}
/**
* Overview ruler lane
*/
export enum OverviewRulerLane {
Left = 1,
Center = 2,
Right = 4,
Full = 7,
}
/**
* Decoration range behavior
*/
export enum DecorationRangeBehavior {
OpenOpen = 0,
ClosedClosed = 1,
OpenClosed = 2,
ClosedOpen = 3,
}
/**
* Text editor reveal type
*/
export enum TextEditorRevealType {
Default = 0,
InCenter = 1,
InCenterIfOutsideViewport = 2,
AtTop = 3,
}

View file

@ -0,0 +1,52 @@
/**
* Simple logger stub for VSCode mock
* Users can provide their own logger by calling setLogger()
*/
export interface Logger {
info(message: string, context?: string, meta?: unknown): void
warn(message: string, context?: string, meta?: unknown): void
error(message: string, context?: string, meta?: unknown): void
debug(message: string, context?: string, meta?: unknown): void
}
class ConsoleLogger implements Logger {
info(message: string, context?: string, _meta?: unknown): void {
console.log(`[${context || "INFO"}] ${message}`)
}
warn(message: string, context?: string, _meta?: unknown): void {
console.warn(`[${context || "WARN"}] ${message}`)
}
error(message: string, context?: string, _meta?: unknown): void {
console.error(`[${context || "ERROR"}] ${message}`)
}
debug(message: string, context?: string, _meta?: unknown): void {
if (process.env.DEBUG) {
console.debug(`[${context || "DEBUG"}] ${message}`)
}
}
}
let logger: Logger = new ConsoleLogger()
/**
* Set a custom logger
*
* @param customLogger - Your logger implementation
*/
export function setLogger(customLogger: Logger): void {
logger = customLogger
}
/**
* Get the current logger
*/
export const logs = {
info: (message: string, context?: string, meta?: unknown) => logger.info(message, context, meta),
warn: (message: string, context?: string, meta?: unknown) => logger.warn(message, context, meta),
error: (message: string, context?: string, meta?: unknown) => logger.error(message, context, meta),
debug: (message: string, context?: string, meta?: unknown) => logger.debug(message, context, meta),
}

View file

@ -0,0 +1,44 @@
/**
* Machine ID generation
* Simple implementation to replace node-machine-id dependency
*/
import * as fs from "fs"
import * as path from "path"
import * as crypto from "crypto"
import * as os from "os"
import { ensureDirectoryExists } from "./paths.js"
/**
* Get or create a unique machine ID
* Stores in ~/.vscode-mock/.machine-id for persistence
*/
export function machineIdSync(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || "."
const idPath = path.join(homeDir, ".vscode-mock", ".machine-id")
// Try to read existing ID
try {
if (fs.existsSync(idPath)) {
return fs.readFileSync(idPath, "utf-8").trim()
}
} catch {
// Fall through to generate new ID
}
// Generate new ID based on hostname and random data
const hostname = os.hostname()
const randomData = crypto.randomBytes(16).toString("hex")
const machineId = crypto.createHash("sha256").update(`${hostname}-${randomData}`).digest("hex")
// Save for future use
try {
const dir = path.dirname(idPath)
ensureDirectoryExists(dir)
fs.writeFileSync(idPath, machineId)
} catch {
// Ignore save errors
}
return machineId
}

View file

@ -0,0 +1,89 @@
/**
* Path utilities for VSCode mock storage
*/
import * as fs from "fs"
import * as path from "path"
const STORAGE_BASE_DIR = ".vscode-mock"
/**
* Get the base storage directory
*/
function getBaseStorageDir(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || "."
return path.join(homeDir, STORAGE_BASE_DIR)
}
/**
* Hash a workspace path to create a unique directory name
*
* @param workspacePath - The workspace path to hash
* @returns A hexadecimal hash string
*/
export function hashWorkspacePath(workspacePath: string): string {
let hash = 0
for (let i = 0; i < workspacePath.length; i++) {
const char = workspacePath.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32-bit integer
}
return Math.abs(hash).toString(16)
}
/**
* Ensure a directory exists, creating it if necessary
*
* @param dirPath - The directory path to ensure exists
*/
export function ensureDirectoryExists(dirPath: string): void {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
} catch (error) {
console.warn(`Failed to create directory ${dirPath}:`, error)
}
}
/**
* Initialize workspace directories
*/
export function initializeWorkspace(workspacePath: string): void {
const dirs = [getGlobalStorageDir(), getWorkspaceStorageDir(workspacePath), getLogsDir()]
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
}
/**
* Get global storage directory
*/
export function getGlobalStorageDir(): string {
return path.join(getBaseStorageDir(), "global-storage")
}
/**
* Get workspace-specific storage directory
*/
export function getWorkspaceStorageDir(workspacePath: string): string {
const hash = hashWorkspacePath(workspacePath)
return path.join(getBaseStorageDir(), "workspace-storage", hash)
}
/**
* Get logs directory
*/
export function getLogsDir(): string {
return path.join(getBaseStorageDir(), "logs")
}
export const VSCodeMockPaths = {
initializeWorkspace,
getGlobalStorageDir,
getWorkspaceStorageDir,
getLogsDir,
}

View file

@ -0,0 +1,153 @@
/**
* VSCode API Mock - Barrel Export File
*
* This file re-exports all components from the modular files for backwards compatibility.
* All imports from this file will continue to work as before.
*/
// ============================================================================
// Classes from ./classes/
// ============================================================================
export { Position } from "./classes/Position.js"
export { Range } from "./classes/Range.js"
export { Selection } from "./classes/Selection.js"
export { Uri } from "./classes/Uri.js"
export { EventEmitter } from "./classes/EventEmitter.js"
export { TextEdit, WorkspaceEdit } from "./classes/TextEdit.js"
export {
Location,
Diagnostic,
DiagnosticRelatedInformation,
ThemeColor,
ThemeIcon,
CodeActionKind,
CodeLens,
LanguageModelTextPart,
LanguageModelToolCallPart,
LanguageModelToolResultPart,
FileSystemError,
} from "./classes/Additional.js"
export { CancellationTokenSource, type CancellationToken } from "./classes/CancellationToken.js"
export { OutputChannel } from "./classes/OutputChannel.js"
export { StatusBarItem } from "./classes/StatusBarItem.js"
export { TextEditorDecorationType } from "./classes/TextEditorDecorationType.js"
// ============================================================================
// Context
// ============================================================================
export { ExtensionContextImpl as ExtensionContext } from "./context/ExtensionContext.js"
// ============================================================================
// API Classes from ./api/
// ============================================================================
export { FileSystemAPI } from "./api/FileSystemAPI.js"
export {
MockWorkspaceConfiguration,
setRuntimeConfig,
setRuntimeConfigValues,
clearRuntimeConfig,
getRuntimeConfig,
} from "./api/WorkspaceConfiguration.js"
export { WorkspaceAPI } from "./api/WorkspaceAPI.js"
export { TabGroupsAPI, type Tab, type TabInputText, type TabGroup } from "./api/TabGroupsAPI.js"
export { WindowAPI } from "./api/WindowAPI.js"
export { CommandsAPI } from "./api/CommandsAPI.js"
export { createVSCodeAPIMock } from "./api/create-vscode-api-mock.js"
// ============================================================================
// Enums from ./types.ts
// ============================================================================
export {
ConfigurationTarget,
ViewColumn,
TextEditorRevealType,
StatusBarAlignment,
DiagnosticSeverity,
DiagnosticTag,
EndOfLine,
UIKind,
ExtensionMode,
ExtensionKind,
FileType,
DecorationRangeBehavior,
OverviewRulerLane,
} from "./types.js"
// ============================================================================
// Types from ./types.ts
// ============================================================================
export type { Thenable, Memento, FileStat, TextEditorOptions, ConfigurationInspect } from "./types.js"
// ============================================================================
// Interfaces from ./interfaces/
// ============================================================================
// Document interfaces
export type {
TextDocument,
TextLine,
WorkspaceFoldersChangeEvent,
WorkspaceFolder,
TextDocumentChangeEvent,
TextDocumentContentChangeEvent,
ConfigurationChangeEvent,
TextDocumentContentProvider,
FileSystemWatcher,
RelativePattern,
} from "./interfaces/document.js"
// Editor interfaces
export type {
TextEditor,
TextEditorEdit,
TextEditorSelectionChangeEvent,
TextDocumentShowOptions,
DecorationRenderOptions,
} from "./interfaces/editor.js"
// Terminal interfaces
export type {
Terminal,
TerminalOptions,
TerminalExitStatus,
TerminalState,
TerminalDimensionsChangeEvent,
TerminalDimensions,
TerminalDataWriteEvent,
} from "./interfaces/terminal.js"
// Webview interfaces
export type {
WebviewViewProvider,
WebviewView,
Webview,
WebviewOptions,
WebviewPortMapping,
ViewBadge,
WebviewViewResolveContext,
WebviewViewProviderOptions,
UriHandler,
} from "./interfaces/webview.js"
// Workspace interfaces
export type {
WorkspaceConfiguration,
QuickPickOptions,
InputBoxOptions,
OpenDialogOptions,
Disposable,
DiagnosticCollection,
IdentityInfo,
} from "./interfaces/workspace.js"
// ============================================================================
// Secret Storage interface (backwards compatibility)
// ============================================================================
export interface SecretStorage {
get(key: string): Thenable<string | undefined>
store(key: string, value: string): Thenable<void>
delete(key: string): Thenable<void>
}
// Import Thenable for SecretStorage interface
import type { Thenable } from "./types.js"

View file

@ -0,0 +1,9 @@
{
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist"
},
"include": ["src", "scripts", "*.config.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
watch: false,
},
})

71
pnpm-lock.yaml generated
View file

@ -78,6 +78,43 @@ importers:
specifier: ^5.4.5
version: 5.8.3
apps/cli:
dependencies:
'@roo-code/types':
specifier: workspace:^
version: link:../../packages/types
'@roo-code/vscode-shim':
specifier: workspace:^
version: link:../../packages/vscode-shim
'@vscode/ripgrep':
specifier: ^1.15.9
version: 1.17.0
commander:
specifier: ^12.1.0
version: 12.1.0
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
version: link:../../packages/config-eslint
'@roo-code/config-typescript':
specifier: workspace:^
version: link:../../packages/config-typescript
'@types/node':
specifier: ^24.1.0
version: 24.2.1
rimraf:
specifier: ^6.0.1
version: 6.0.1
tsup:
specifier: ^8.4.0
version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
typescript:
specifier: 5.8.3
version: 5.8.3
vitest:
specifier: ^3.2.3
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
apps/vscode-e2e:
devDependencies:
'@roo-code/config-eslint':
@ -650,6 +687,21 @@ importers:
specifier: ^3.2.3
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
packages/vscode-shim:
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
version: link:../config-eslint
'@roo-code/config-typescript':
specifier: workspace:^
version: link:../config-typescript
'@types/node':
specifier: ^24.1.0
version: 24.2.1
vitest:
specifier: ^3.2.3
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
src:
dependencies:
'@anthropic-ai/bedrock-sdk':
@ -4362,6 +4414,9 @@ packages:
'@vscode/codicons@0.0.36':
resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==}
'@vscode/ripgrep@1.17.0':
resolution: {integrity: sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==}
'@vscode/test-cli@0.0.11':
resolution: {integrity: sha512-qO332yvzFqGhBMJrp6TdwbIydiHgCtxXc2Nl6M58mbH/Z+0CyLR76Jzv4YWPEthhrARprzCRJUqzFvTHFhTj7Q==}
engines: {node: '>=18'}
@ -14180,6 +14235,14 @@ snapshots:
'@vscode/codicons@0.0.36': {}
'@vscode/ripgrep@1.17.0':
dependencies:
https-proxy-agent: 7.0.6
proxy-from-env: 1.1.0
yauzl: 2.10.0
transitivePeerDependencies:
- supports-color
'@vscode/test-cli@0.0.11':
dependencies:
'@types/mocha': 10.0.10
@ -17568,7 +17631,7 @@ snapshots:
ansi-escapes: 7.0.0
cli-cursor: 5.0.0
slice-ansi: 7.1.0
strip-ansi: 7.1.0
strip-ansi: 7.1.2
wrap-ansi: 9.0.0
longest-streak@3.1.0: {}
@ -18485,7 +18548,7 @@ snapshots:
log-symbols: 6.0.0
stdin-discarder: 0.2.2
string-width: 7.2.0
strip-ansi: 7.1.0
strip-ansi: 7.1.2
os-name@6.1.0:
dependencies:
@ -19891,7 +19954,7 @@ snapshots:
dependencies:
emoji-regex: 10.4.0
get-east-asian-width: 1.3.0
strip-ansi: 7.1.0
strip-ansi: 7.1.2
string.prototype.codepointat@0.2.1: {}
@ -21124,7 +21187,7 @@ snapshots:
dependencies:
ansi-styles: 6.2.1
string-width: 7.2.0
strip-ansi: 7.1.0
strip-ansi: 7.1.2
wrappy@1.0.2: {}

View file

@ -822,8 +822,6 @@ export class NativeToolCallParser {
default:
if (customToolRegistry.has(resolvedName)) {
nativeArgs = args as NativeArgsFor<TName>
} else {
console.error(`Unhandled tool: ${resolvedName}`)
}
break

View file

@ -1090,7 +1090,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// state.
askTs = Date.now()
this.lastMessageTs = askTs
console.log(`Task#ask: new partial ask -> ${type} @ ${askTs}`)
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected })
// console.log("Task#ask: current ask promise was ignored (#2)")
throw new AskIgnoredError("new partial")
@ -1115,7 +1114,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// So in this case we must make sure that the message ts is
// never altered after first setting it.
askTs = lastMessage.ts
console.log(`Task#ask: updating previous partial ask -> ${type} @ ${askTs}`)
this.lastMessageTs = askTs
lastMessage.text = text
lastMessage.partial = false
@ -1129,7 +1127,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseText = undefined
this.askResponseImages = undefined
askTs = Date.now()
console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`)
this.lastMessageTs = askTs
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected })
}
@ -1140,7 +1137,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseText = undefined
this.askResponseImages = undefined
askTs = Date.now()
console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`)
this.lastMessageTs = askTs
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected })
}
@ -1170,15 +1166,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// block (via the `pWaitFor`).
const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs)
const isMessageQueued = !this.messageQueueService.isEmpty()
const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask"
if (isBlocking) {
console.log(`Task#ask will block -> type: ${type}`)
}
if (isStatusMutable) {
console.log(`Task#ask: status is mutable -> type: ${type}`)
const statusMutationTimeout = 2_000
if (isInteractiveAsk(type)) {
@ -1217,8 +1207,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
)
}
} else if (isMessageQueued) {
console.log(`Task#ask: will process message queue -> type: ${type}`)
const message = this.messageQueueService.dequeueMessage()
if (message) {
@ -1277,7 +1265,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Could happen if we send multiple asks in a row i.e. with
// command_output. It's important that when we know an ask could
// fail, it is handled gracefully.
console.log("Task#ask: current ask promise was ignored")
throw new AskIgnoredError("superseded")
}
@ -2515,7 +2502,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
lastMessage.partial = false
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
console.log("updating partial message", lastMessage)
}
// Update `api_req_started` to have cancelled and cost, so that

View file

@ -199,7 +199,6 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
psTree(this.pid, async (err, children) => {
if (!err) {
const pids = children.map((p) => parseInt(p.PID))
console.error(`[ExecaTerminalProcess#abort] SIGKILL children -> ${pids.join(", ")}`)
for (const pid of pids) {
try {