From 66733e9c82bf054e7bf8417f250853157361a27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 11 Feb 2026 01:01:45 -0500 Subject: [PATCH] Support multiple workspace folders with independent Ruby environments Refactor RubyEnvironmentManager to manage multiple RubyEnvironment instances keyed by workspace folder. Update API methods to accept an optional workspace parameter and return the Ruby environment for that specific workspace. Add event handling to emit changes for the correct workspace context. --- README.md | 23 +- src/extension.ts | 5 +- src/rubyEnvironment.ts | 83 ++-- src/rubyEnvironmentManager.ts | 134 ++++++ src/test/extension.test.ts | 31 +- src/test/helpers.ts | 242 +++++++++++ src/test/rubyEnvironment.test.ts | 534 +++++++++++++++++------- src/test/rubyEnvironmentManager.test.ts | 229 ++++++++++ src/test/workspaceContext.test.ts | 63 +++ src/types.ts | 12 +- src/workspaceContext.ts | 43 ++ 11 files changed, 1183 insertions(+), 216 deletions(-) create mode 100644 src/rubyEnvironmentManager.ts create mode 100644 src/test/rubyEnvironmentManager.test.ts create mode 100644 src/test/workspaceContext.test.ts create mode 100644 src/workspaceContext.ts diff --git a/README.md b/README.md index cfbee6f..bef18ab 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ need to activate the Ruby environment in order to launch Ruby executables, such ## Features - **Automatic Ruby detection**: Discovers the Ruby interpreter installed on your machine +- **Multi-workspace support**: Each workspace folder can have its own independent Ruby environment configuration - **Version manager integrations**: Supports popular version managers including: - [chruby](https://github.com/postmodern/chruby) - [rbenv](https://github.com/rbenv/rbenv) @@ -54,20 +55,28 @@ if (rubyEnvExtension) { ### Activating the Ruby Environment -Request the extension to activate Ruby for a specific workspace: +The extension automatically activates all workspace folders when it loads. If you need to ensure a specific workspace is activated: ```typescript -await api.activate(vscode.workspace.workspaceFolders?.[0]); +// Activate a specific workspace folder +await api.activateWorkspace(vscode.workspace.workspaceFolders?.[0]); + +// Or activate without a workspace (uses current working directory) +await api.activateWorkspace(undefined); ``` ### Getting the Current Ruby Definition -Retrieve the currently activated Ruby environment: +Retrieve the Ruby environment for a specific workspace folder: ```typescript import type { RubyDefinition } from "@shopify/ruby-environments-types"; -const ruby: RubyDefinition | null = api.getRuby(); +// Get Ruby for a specific workspace folder +const ruby: RubyDefinition | null = api.getRuby(vscode.workspace.workspaceFolders?.[0]); + +// Or get Ruby for the default workspace (when no folder is open) +const ruby: RubyDefinition | null = api.getRuby(undefined); if (ruby === null) { console.log("Ruby environment not yet activated"); @@ -88,9 +97,9 @@ Listen for changes to the Ruby environment (e.g., when the user switches Ruby ve import type { RubyChangeEvent } from "@shopify/ruby-environments-types"; const disposable = api.onDidRubyChange((event: RubyChangeEvent) => { - console.log(`Ruby changed in workspace: ${event.workspace?.name}`); + console.log(`Ruby changed in workspace: ${event.workspace?.name || "default"}`); - if (!event.ruby.error) { + if (event.ruby && !event.ruby.error) { console.log(`New Ruby version: ${event.ruby.rubyVersion}`); } }); @@ -99,6 +108,8 @@ const disposable = api.onDidRubyChange((event: RubyChangeEvent) => { context.subscriptions.push(disposable); ``` +**Note**: This event fires for Ruby environment changes in any workspace folder. Use `event.workspace` to determine which workspace was affected. + ### Extension Dependency To ensure your extension loads after Ruby Environments, add it as a dependency in your `package.json`: diff --git a/src/extension.ts b/src/extension.ts index 837dec0..030be53 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,11 +1,10 @@ import * as vscode from "vscode"; -import { RubyEnvironmentManager } from "./rubyEnvironment"; +import { RubyEnvironmentManager } from "./rubyEnvironmentManager"; import { RubyEnvironmentsApi } from "./types"; export async function activate(context: vscode.ExtensionContext): Promise { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const manager = new RubyEnvironmentManager(context); - await manager.activate(workspaceFolder); + await manager.activate(); return manager; } diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts index 201b61e..f0dccee 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -1,67 +1,50 @@ import * as vscode from "vscode"; -import { RubyStatus } from "./status"; -import { RubyChangeEvent, OptionalRubyDefinition, RubyEnvironmentsApi, RubyDefinition, JitType } from "./types"; +import { RubyChangeEvent, OptionalRubyDefinition, RubyDefinition, JitType } from "./types"; import { asyncExec, isWindows } from "./common"; +import { WorkspaceContext } from "./workspaceContext"; // Separators for parsing activation script output export const ACTIVATION_SEPARATOR = "RUBY_ENVIRONMENTS_ACTIVATION_SEPARATOR"; export const VALUE_SEPARATOR = "RUBY_ENVIRONMENTS_VS"; export const FIELD_SEPARATOR = "RUBY_ENVIRONMENTS_FS"; -export class RubyEnvironmentManager implements RubyEnvironmentsApi { +/** + * Manages the Ruby environment for a single workspace context + */ +export class RubyEnvironment { private readonly context: vscode.ExtensionContext; - private readonly status: RubyStatus; + private readonly workspace: WorkspaceContext; private readonly changeEmitter: vscode.EventEmitter; private currentRubyDefinition: OptionalRubyDefinition = null; - constructor(context: vscode.ExtensionContext) { + constructor( + context: vscode.ExtensionContext, + workspace: WorkspaceContext, + changeEmitter: vscode.EventEmitter, + ) { this.context = context; - this.changeEmitter = new vscode.EventEmitter(); - this.status = new RubyStatus(this.changeEmitter.event); - - // Register disposables - context.subscriptions.push(this.status); - context.subscriptions.push(this.changeEmitter); - - // Watch for configuration changes - const configWatcher = vscode.workspace.onDidChangeConfiguration(async (e) => { - if (e.affectsConfiguration("rubyEnvironments")) { - await this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); - } - }); - context.subscriptions.push(configWatcher); - - // Register command to select Ruby installation - const selectRuby = vscode.commands.registerCommand("ruby-environments.selectRuby", async () => { - await this.selectRuby(); - }); - context.subscriptions.push(selectRuby); + this.workspace = workspace; + this.changeEmitter = changeEmitter; } - async activate(workspace: vscode.WorkspaceFolder | undefined): Promise { + async activate(): Promise { // Load Ruby definition from configuration and emit change event - await this.updateRubyDefinition(workspace); + await this.updateRubyDefinition(); } getRuby(): OptionalRubyDefinition { return this.currentRubyDefinition; } - get onDidRubyChange(): vscode.Event { - return this.changeEmitter.event; - } - - private async updateRubyDefinition(workspace: vscode.WorkspaceFolder | undefined): Promise { - this.currentRubyDefinition = await this.getRubyDefinitionFromConfig(workspace); + async updateRubyDefinition(): Promise { + this.currentRubyDefinition = await this.getRubyDefinitionFromConfig(); this.changeEmitter.fire({ - workspace: workspace, + workspace: this.workspace.workspaceFolder, ruby: this.currentRubyDefinition, }); } - private async getRubyDefinitionFromConfig( - workspace: vscode.WorkspaceFolder | undefined, - ): Promise { + private async getRubyDefinitionFromConfig(): Promise { const rubyPath = this.getRubyPath(); if (!rubyPath) { @@ -79,7 +62,7 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { shell = vscode.env.shell; } - const cwd = workspace?.uri.fsPath || process.cwd(); + const cwd = this.workspace.uri.fsPath; const result = await asyncExec(command, { cwd, @@ -96,11 +79,13 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { } private getRubyPath(): string | undefined { - // First check workspace state (set by the selectRuby command) - const workspaceRubyPath = this.context.workspaceState.get("rubyPath"); + const workspaceKey = this.workspace.getStorageKey(); + + // First check workspace state (set by the selectRuby command) for this specific workspace + const workspaceRubyPath = this.context.workspaceState.get(workspaceKey); - // Then fall back to configuration - const config = vscode.workspace.getConfiguration("rubyEnvironments"); + // Then fall back to workspace-specific configuration + const config = vscode.workspace.getConfiguration("rubyEnvironments", this.workspace.uri); const configuredRubyPath = config.get("rubyPath"); return workspaceRubyPath || configuredRubyPath; @@ -130,8 +115,9 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { }; } - private async selectRuby(): Promise { + async selectRuby(): Promise { const rubyPath = this.getRubyPath(); + const workspaceName = this.workspace.name; // Show options for how to set the path const option = await vscode.window.showQuickPick( @@ -140,7 +126,7 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { { label: "$(edit) Enter path manually...", value: "manual" }, ], { - placeHolder: `Current path: ${rubyPath || "not set"}`, + placeHolder: `Current path for ${workspaceName}: ${rubyPath || "not set"}`, }, ); @@ -168,7 +154,7 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { } } else if (option.value === "manual") { newPath = await vscode.window.showInputBox({ - prompt: "Enter Ruby executable path", + prompt: `Enter Ruby executable path for ${workspaceName}`, value: rubyPath, placeHolder: "ruby", validateInput: (value) => { @@ -181,9 +167,10 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { } if (newPath) { - await this.context.workspaceState.update("rubyPath", newPath); - await this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); - vscode.window.showInformationMessage(`Ruby executable path updated to ${newPath}`); + const workspaceKey = this.workspace.getStorageKey(); + await this.context.workspaceState.update(workspaceKey, newPath); + await this.updateRubyDefinition(); + vscode.window.showInformationMessage(`Ruby executable path for ${workspaceName} updated to ${newPath}`); } } } diff --git a/src/rubyEnvironmentManager.ts b/src/rubyEnvironmentManager.ts new file mode 100644 index 0000000..df93427 --- /dev/null +++ b/src/rubyEnvironmentManager.ts @@ -0,0 +1,134 @@ +import * as vscode from "vscode"; +import { RubyStatus } from "./status"; +import { RubyChangeEvent, OptionalRubyDefinition, RubyEnvironmentsApi } from "./types"; +import { RubyEnvironment } from "./rubyEnvironment"; +import { WorkspaceContext } from "./workspaceContext"; + +/** + * Manages Ruby environments for all workspace folders + */ +export class RubyEnvironmentManager implements RubyEnvironmentsApi { + private readonly context: vscode.ExtensionContext; + private readonly status: RubyStatus; + private readonly changeEmitter: vscode.EventEmitter; + private readonly environments: Map; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + this.changeEmitter = new vscode.EventEmitter(); + this.environments = new Map(); + this.status = new RubyStatus(this.changeEmitter.event); + + // Register disposables + context.subscriptions.push(this.status); + context.subscriptions.push(this.changeEmitter); + + // Watch for configuration changes + const configWatcher = vscode.workspace.onDidChangeConfiguration(async (e) => { + if (e.affectsConfiguration("rubyEnvironments")) { + // Update all environments + for (const environment of this.environments.values()) { + await environment.updateRubyDefinition(); + } + } + }); + context.subscriptions.push(configWatcher); + + // Watch for workspace folder changes + const workspaceFoldersWatcher = vscode.workspace.onDidChangeWorkspaceFolders(async (e) => { + // Remove environments for removed folders + for (const folder of e.removed) { + const key = WorkspaceContext.fromWorkspaceFolder(folder).key; + this.environments.delete(key); + } + + // Add environments for added folders + for (const folder of e.added) { + await this.createEnvironment(WorkspaceContext.fromWorkspaceFolder(folder)); + } + }); + context.subscriptions.push(workspaceFoldersWatcher); + + // Register command to select Ruby installation + const selectRuby = vscode.commands.registerCommand("ruby-environments.selectRuby", async () => { + await this.selectRuby(); + }); + context.subscriptions.push(selectRuby); + } + + async activate(): Promise { + // Activate all workspace folders (initial activation) + const workspaceFolders = vscode.workspace.workspaceFolders || []; + + // Handle the case where there are no workspace folders but we still want to provide an environment + if (workspaceFolders.length === 0) { + await this.createEnvironment(WorkspaceContext.createDefault()); + } else { + for (const folder of workspaceFolders) { + await this.createEnvironment(WorkspaceContext.fromWorkspaceFolder(folder)); + } + } + } + + async activateWorkspace(workspace: vscode.WorkspaceFolder | undefined): Promise { + const workspaceContext = WorkspaceContext.from(workspace); + const key = workspaceContext.key; + const environment = this.environments.get(key); + + if (!environment) { + await this.createEnvironment(workspaceContext); + } + } + + getRuby(workspace: vscode.WorkspaceFolder | undefined): OptionalRubyDefinition { + const workspaceContext = WorkspaceContext.from(workspace); + const key = workspaceContext.key; + const environment = this.environments.get(key); + return environment?.getRuby() || null; + } + + get onDidRubyChange(): vscode.Event { + return this.changeEmitter.event; + } + + private async createEnvironment(workspaceContext: WorkspaceContext): Promise { + const environment = new RubyEnvironment(this.context, workspaceContext, this.changeEmitter); + this.environments.set(workspaceContext.key, environment); + await environment.activate(); + } + + private async selectRuby(): Promise { + // If there are multiple workspace folders, ask the user which one to configure + const workspaceFolders = vscode.workspace.workspaceFolders || []; + + let targetWorkspace: vscode.WorkspaceFolder | undefined; + + if (workspaceFolders.length > 1) { + const selected = await vscode.window.showQuickPick( + workspaceFolders.map((folder) => ({ + label: folder.name, + description: folder.uri.fsPath, + workspace: folder, + })), + { + placeHolder: "Select workspace folder to configure Ruby for", + }, + ); + + if (!selected) { + return; + } + + targetWorkspace = selected.workspace; + } else if (workspaceFolders.length === 1) { + targetWorkspace = workspaceFolders[0]; + } + + const workspaceContext = WorkspaceContext.from(targetWorkspace); + const environment = this.environments.get(workspaceContext.key); + + if (environment) { + await environment.selectRuby(); + } + } +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 81b3d6a..9da0bbd 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -2,15 +2,15 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { suite, test, beforeEach, afterEach } from "mocha"; import { activate, deactivate } from "../extension"; -import { FakeContext, createContext } from "./helpers"; +import * as helpers from "./helpers"; import { RubyEnvironmentsApi } from "../types"; suite("Extension Test Suite", () => { suite("activate", () => { - let context: FakeContext; + let context: helpers.FakeContext; beforeEach(() => { - context = createContext(); + context = helpers.createContext(); }); afterEach(() => { @@ -18,16 +18,25 @@ suite("Extension Test Suite", () => { }); test("returns an object implementing RubyEnvironmentsApi", async () => { - const api = await activate(context); + const api: RubyEnvironmentsApi = await activate(context); assert.strictEqual(typeof api, "object", "activate should return an object"); assert.strictEqual(typeof api.activate, "function", "API should have an activate method"); + assert.strictEqual(typeof api.activateWorkspace, "function", "API should have an activateWorkspace method"); assert.strictEqual(typeof api.getRuby, "function", "API should have a getRuby method"); assert.strictEqual(typeof api.onDidRubyChange, "function", "API should have an onDidRubyChange event"); - const result = api.activate(undefined); - assert.ok(result instanceof Promise, "activate should return a Promise"); - await result; + // Test that activate returns a Promise + const activatePromise = api.activate(); + assert.ok(activatePromise instanceof Promise, "activate should return a Promise"); + + // Test that activateWorkspace accepts workspace folder parameter and returns a Promise + const activateWorkspacePromise = api.activateWorkspace(undefined); + assert.ok(activateWorkspacePromise instanceof Promise, "activateWorkspace should return a Promise"); + + // Test that getRuby accepts workspace folder parameter + const ruby = api.getRuby(undefined); + assert.ok(ruby === null || typeof ruby === "object", "getRuby should return null or an object"); }); test("returned API conforms to RubyEnvironmentsApi interface", async () => { @@ -37,22 +46,22 @@ suite("Extension Test Suite", () => { assert.ok(typedApi, "API should conform to RubyEnvironmentsApi interface"); }); - test("registers emitter, status, config watcher, and command subscriptions", async () => { + test("registers emitter, status, config watcher, workspace watcher, and command subscriptions", async () => { assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially"); await activate(context); assert.strictEqual( context.subscriptions.length, - 4, - "Extension should register four subscriptions (emitter, status, config watcher, and command)", + 5, + "Extension should register five subscriptions (emitter, status, config watcher, workspace watcher, and command)", ); }); }); suite("selectRuby command", () => { test("command is registered", async () => { - const mockContext = createContext(); + const mockContext = helpers.createContext(); await activate(mockContext); const commands = await vscode.commands.getCommands(true); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index ef97e2b..f27044f 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -1,7 +1,68 @@ import * as vscode from "vscode"; +import * as assert from "assert"; +import sinon from "sinon"; +import * as common from "../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../rubyEnvironment"; +import { JitType, OptionalRubyDefinition } from "../types"; +/** + * A fake extension context for testing that includes workspace state management + * and a dispose method for cleanup. + * + * Use this type when creating test contexts with {@link createContext}. + */ export type FakeContext = vscode.ExtensionContext & { dispose: () => void }; +/** + * Creates a mock workspace folder for testing. + * + * Returns a minimal workspace folder object with: + * - uri: /test/workspace + * - name: "test" + * - index: 0 + * + * @returns A mock workspace folder suitable for testing + * + * @example + * ```typescript + * const workspace = createTestWorkspace(); + * const context = WorkspaceContext.fromWorkspaceFolder(workspace); + * ``` + */ +export function createTestWorkspace(): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file("/test/workspace"), + name: "test", + index: 0, + }; +} + +/** + * Creates a fake extension context for testing with workspace state management. + * + * The returned context includes: + * - A subscriptions array for tracking disposables + * - A workspace state storage backed by a Map + * - An extensionUri pointing to the project root + * - A dispose() method for cleanup + * + * Always call dispose() after tests to prevent memory leaks. + * + * @returns A fake extension context with full workspace state support + * + * @example + * ```typescript + * let context: FakeContext; + * + * beforeEach(() => { + * context = createContext(); + * }); + * + * afterEach(() => { + * context.dispose(); + * }); + * ``` + */ export function createContext(): FakeContext { const subscriptions: vscode.Disposable[] = []; const workspaceStateStorage = new Map(); @@ -26,3 +87,184 @@ export function createContext(): FakeContext { }, } as unknown as FakeContext; } + +/** + * Creates a mock Ruby activation response string with default values. + * + * The response includes: + * - Ruby version: 3.3.0 + * - Gem paths: /path/to/gems, /another/path + * - YJIT enabled: true + * - Environment variables: PATH=/usr/bin, HOME=/home/user + * + * Use this when you need a standard Ruby activation response for testing. + * For custom responses, manually construct the string using the same format. + * + * @returns A properly formatted Ruby activation response string + * + * @example + * ```typescript + * const response = createMockRubyResponse(); + * stubAsyncExec(sandbox, response); + * ``` + */ +export function createMockRubyResponse(): string { + return [ + "3.3.0", + "/path/to/gems,/another/path", + "true", + `PATH${VALUE_SEPARATOR}/usr/bin`, + `HOME${VALUE_SEPARATOR}/home/user`, + ].join(FIELD_SEPARATOR); +} + +/** + * Stubs the asyncExec function to return a mock Ruby activation response. + * + * If no response is provided, uses {@link createMockRubyResponse} to generate + * a default response with standard Ruby configuration. + * + * The stub wraps the response with activation separators to match the actual + * activation script output format. + * + * @param sandbox - The Sinon sandbox for managing stubs + * @param response - Optional custom Ruby activation response string + * @returns A Sinon stub for asyncExec that can be used for assertions + * + * @example + * ```typescript + * // Use default response + * const execStub = stubAsyncExec(sandbox); + * + * // Use custom response + * const customResponse = "3.2.0" + FIELD_SEPARATOR + "/custom/gems" + ...; + * stubAsyncExec(sandbox, customResponse); + * ``` + */ +export function stubAsyncExec(sandbox: sinon.SinonSandbox, response?: string): sinon.SinonStub { + const envStub = response || createMockRubyResponse(); + return sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); +} + +/** + * Builds the expected Ruby activation command string for verification. + * + * Constructs the command that should be passed to asyncExec when activating + * a Ruby environment. The command includes: + * - The Ruby interpreter path + * - Warning suppression (-W0) + * - UTF-8 encoding (-EUTF-8:UTF-8) + * - The activation script path + * + * Use this to verify that asyncExec was called with the correct command. + * + * @param context - The extension context containing the extension URI + * @param rubyPath - The path to the Ruby interpreter + * @returns The expected command string for Ruby activation + * + * @example + * ```typescript + * const execStub = stubAsyncExec(sandbox); + * await rubyEnv.activate(); + * + * const expectedCommand = buildExpectedCommand(context, "/usr/bin/ruby"); + * assert.ok(execStub.calledWith(expectedCommand)); + * ``` + */ +export function buildExpectedCommand(context: FakeContext, rubyPath: string): string { + const activationUri = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + return `${rubyPath} -W0 -EUTF-8:UTF-8 '${activationUri.fsPath}'`; +} + +/** + * Gets the expected shell value for the current platform. + * + * Returns: + * - undefined on Windows (uses default Windows shell) + * - vscode.env.shell on Unix-like systems (bash, zsh, etc.) + * + * Use this when verifying asyncExec was called with the correct shell option. + * + * @returns The expected shell path or undefined for Windows + * + * @example + * ```typescript + * const shell = getExpectedShell(); + * assert.ok(execStub.calledWithMatch(sinon.match.any, { shell })); + * ``` + */ +export function getExpectedShell(): string | undefined { + return common.isWindows() ? undefined : vscode.env.shell; +} + +/** + * Asserts that a Ruby definition matches expected values. + * + * Verifies the Ruby definition object returned from Ruby environment activation, + * checking: + * - The definition is not null + * - error flag is false + * - Ruby version matches expected value + * - Available JITs match expected array + * - Gem paths match expected array + * - Environment variables match expected values + * + * All parameters are optional and default to standard test values: + * - rubyVersion: "3.3.0" + * - availableJITs: [JitType.YJIT] + * - gemPath: ["/path/to/gems", "/another/path"] + * - envVars: { PATH: "/usr/bin", HOME: "/home/user" } + * + * @param ruby - The Ruby definition to assert against + * @param expected - Optional object with expected values to override defaults + * @param expected.rubyVersion - Expected Ruby version string + * @param expected.availableJITs - Expected array of JIT types + * @param expected.gemPath - Expected array of gem paths + * @param expected.envVars - Expected environment variable key-value pairs + * + * @example + * ```typescript + * // Assert with default values + * const ruby = rubyEnv.getRuby(); + * assertRubyDefinition(ruby); + * + * // Assert with custom version + * assertRubyDefinition(ruby, { rubyVersion: "3.2.0" }); + * + * // Assert with multiple custom values + * assertRubyDefinition(ruby, { + * rubyVersion: "3.1.0", + * availableJITs: [], + * gemPath: ["/custom/gems"] + * }); + * ``` + */ +export function assertRubyDefinition( + ruby: OptionalRubyDefinition, + expected: { + rubyVersion?: string; + availableJITs?: JitType[]; + gemPath?: string[]; + envVars?: Record; + } = {}, +): void { + const { + rubyVersion = "3.3.0", + availableJITs = [JitType.YJIT], + gemPath = ["/path/to/gems", "/another/path"], + envVars = { PATH: "/usr/bin", HOME: "/home/user" }, + } = expected; + + assert.ok(ruby !== null, "Should return Ruby definition"); + assert.strictEqual(ruby.error, false); + assert.strictEqual(ruby.rubyVersion, rubyVersion); + assert.deepStrictEqual(ruby.availableJITs, availableJITs); + assert.deepStrictEqual(ruby.gemPath, gemPath); + + for (const [key, value] of Object.entries(envVars)) { + assert.strictEqual(ruby.env?.[key], value, `Expected env.${key} to be ${value}`); + } +} diff --git a/src/test/rubyEnvironment.test.ts b/src/test/rubyEnvironment.test.ts index 009dc93..361d7be 100644 --- a/src/test/rubyEnvironment.test.ts +++ b/src/test/rubyEnvironment.test.ts @@ -3,105 +3,48 @@ import * as vscode from "vscode"; import sinon from "sinon"; import { suite, test, beforeEach, afterEach } from "mocha"; import * as common from "../common"; -import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, RubyEnvironmentManager, VALUE_SEPARATOR } from "../rubyEnvironment"; -import { FakeContext, createContext } from "./helpers"; -import { JitType, OptionalRubyDefinition, RubyChangeEvent } from "../types"; - -// Test helpers -function createTestWorkspace(): vscode.WorkspaceFolder { - return { - uri: vscode.Uri.file("/test/workspace"), - name: "test", - index: 0, - }; -} - -function createMockRubyResponse(): string { - return [ - "3.3.0", - "/path/to/gems,/another/path", - "true", - `PATH${VALUE_SEPARATOR}/usr/bin`, - `HOME${VALUE_SEPARATOR}/home/user`, - ].join(FIELD_SEPARATOR); -} - -function stubAsyncExec(sandbox: sinon.SinonSandbox, response?: string): sinon.SinonStub { - const envStub = response || createMockRubyResponse(); - return sandbox.stub(common, "asyncExec").resolves({ - stdout: "", - stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, - }); -} - -function buildExpectedCommand(context: FakeContext, rubyPath: string): string { - const activationUri = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); - return `${rubyPath} -W0 -EUTF-8:UTF-8 '${activationUri.fsPath}'`; -} - -function getExpectedShell(): string | undefined { - return common.isWindows() ? undefined : vscode.env.shell; -} - -function assertRubyDefinition(ruby: OptionalRubyDefinition): void { - assert.ok(ruby !== null, "Should return Ruby definition"); - assert.strictEqual(ruby.error, false); - assert.strictEqual(ruby.rubyVersion, "3.3.0"); - assert.deepStrictEqual(ruby.availableJITs, [JitType.YJIT]); - assert.deepStrictEqual(ruby.gemPath, ["/path/to/gems", "/another/path"]); - assert.strictEqual(ruby.env?.PATH, "/usr/bin"); - assert.strictEqual(ruby.env?.HOME, "/home/user"); -} - -suite("RubyEnvironmentManager", () => { - let context: FakeContext; - let manager: RubyEnvironmentManager; +import { RubyEnvironment, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../rubyEnvironment"; +import { WorkspaceContext } from "../workspaceContext"; +import * as helpers from "./helpers"; +import { RubyChangeEvent } from "../types"; + +suite("RubyEnvironment", () => { + let context: helpers.FakeContext; + let workspace: WorkspaceContext; + let changeEmitter: vscode.EventEmitter; + let rubyEnv: RubyEnvironment; let sandbox: sinon.SinonSandbox; beforeEach(() => { - context = createContext(); + context = helpers.createContext(); + workspace = WorkspaceContext.fromWorkspaceFolder(helpers.createTestWorkspace()); + changeEmitter = new vscode.EventEmitter(); sandbox = sinon.createSandbox(); }); afterEach(async () => { context.dispose(); + changeEmitter.dispose(); // Clean up any configuration changes const config = vscode.workspace.getConfiguration("rubyEnvironments"); await config.update("rubyPath", undefined, vscode.ConfigurationTarget.Global); sandbox.restore(); }); - suite("constructor", () => { - test("registers subscriptions", () => { - const initialSubscriptions = context.subscriptions.length; - manager = new RubyEnvironmentManager(context); - - assert.ok(context.subscriptions.length > initialSubscriptions, "Manager should register subscriptions"); - }); - - test("registers selectRuby command", async () => { - manager = new RubyEnvironmentManager(context); - - const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("ruby-environments.selectRuby"), "Command should be registered"); - }); - }); - suite("getRuby", () => { test("returns null before activation", () => { - manager = new RubyEnvironmentManager(context); - - const ruby = manager.getRuby(); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + const ruby = rubyEnv.getRuby(); assert.strictEqual(ruby, null, "Should return null before activation"); }); }); suite("activate", () => { test("does not set Ruby definition if not configured", async () => { - manager = new RubyEnvironmentManager(context); - await manager.activate(undefined); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); - const ruby = manager.getRuby(); + const ruby = rubyEnv.getRuby(); assert.strictEqual(ruby, null, "Should return null if not configured"); }); @@ -109,130 +52,435 @@ suite("RubyEnvironmentManager", () => { const config = vscode.workspace.getConfiguration("rubyEnvironments"); await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); - const workspaceFolder = createTestWorkspace(); - const execStub = stubAsyncExec(sandbox); - - manager = new RubyEnvironmentManager(context); - await manager.activate(workspaceFolder); + const execStub = helpers.stubAsyncExec(sandbox); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); - const expectedCommand = buildExpectedCommand(context, "/usr/bin/ruby"); - const shell = getExpectedShell(); + const expectedCommand = helpers.buildExpectedCommand(context, "/usr/bin/ruby"); + const shell = helpers.getExpectedShell(); assert.ok( execStub.calledOnceWithExactly(expectedCommand, { - cwd: workspaceFolder.uri.fsPath, + cwd: workspace.uri.fsPath, shell, env: process.env, }), - `Expected asyncExec to be called with correct arguments`, + "Expected asyncExec to be called with correct arguments", ); - const ruby = manager.getRuby(); - assertRubyDefinition(ruby); + const ruby = rubyEnv.getRuby(); + helpers.assertRubyDefinition(ruby); }); test("initializes Ruby definition from workspace state", async () => { - await context.workspaceState.update("rubyPath", "/custom/ruby/path"); - manager = new RubyEnvironmentManager(context); + await context.workspaceState.update(workspace.getStorageKey(), "/custom/ruby/path"); - const workspaceFolder = createTestWorkspace(); - const execStub = stubAsyncExec(sandbox); + const execStub = helpers.stubAsyncExec(sandbox); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); - await manager.activate(workspaceFolder); - - const expectedCommand = buildExpectedCommand(context, "/custom/ruby/path"); - const shell = getExpectedShell(); + const expectedCommand = helpers.buildExpectedCommand(context, "/custom/ruby/path"); + const shell = helpers.getExpectedShell(); assert.ok( execStub.calledOnceWithExactly(expectedCommand, { - cwd: workspaceFolder.uri.fsPath, + cwd: workspace.uri.fsPath, shell, env: process.env, }), - `Expected asyncExec to be called with correct arguments`, + "Expected asyncExec to be called with correct arguments", ); - const ruby = manager.getRuby(); + const ruby = rubyEnv.getRuby(); assert.ok(ruby !== null, "Should return Ruby definition"); assert.strictEqual(ruby.error, false); }); - }); - suite("onDidRubyChange", () => { - test("is an event that can be subscribed to", () => { - manager = new RubyEnvironmentManager(context); + test("workspace state takes precedence over configuration", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/config/ruby", vscode.ConfigurationTarget.Global); + await context.workspaceState.update(workspace.getStorageKey(), "/state/ruby"); + + const execStub = helpers.stubAsyncExec(sandbox); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); + + const expectedCommand = helpers.buildExpectedCommand(context, "/state/ruby"); + assert.ok(execStub.calledOnceWith(expectedCommand), "Should use workspace state path over config path"); + }); + + test("fires change event on activation", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + helpers.stubAsyncExec(sandbox); let eventFired = false; - const disposable = manager.onDidRubyChange(() => { + let receivedEvent: RubyChangeEvent | undefined; + const disposable = changeEmitter.event((event) => { eventFired = true; + receivedEvent = event; }); - assert.ok(disposable, "onDidRubyChange should return a disposable"); - assert.strictEqual(typeof disposable.dispose, "function", "disposable should have a dispose method"); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); + + assert.strictEqual(eventFired, true, "Event should have fired after activation"); + assert.ok(receivedEvent, "Event data should be received"); + assert.strictEqual(receivedEvent.workspace, workspace.workspaceFolder); + + const ruby = receivedEvent.ruby; + assert.ok(ruby !== null, "Ruby definition should be present"); + assert.strictEqual(ruby.error, false); disposable.dispose(); - assert.strictEqual(eventFired, false, "event should not have fired yet"); }); - test("fires when activate is called", async () => { + test("handles activation errors gracefully", async () => { const config = vscode.workspace.getConfiguration("rubyEnvironments"); - await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + await config.update("rubyPath", "/invalid/ruby", vscode.ConfigurationTarget.Global); - const workspaceFolder = createTestWorkspace(); - const execStub = stubAsyncExec(sandbox); + sandbox.stub(common, "asyncExec").rejects(new Error("Command failed")); - manager = new RubyEnvironmentManager(context); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); - let eventFired = false; - let receivedEvent: RubyChangeEvent | undefined; - const disposable = manager.onDidRubyChange((event) => { - eventFired = true; - receivedEvent = event; + const ruby = rubyEnv.getRuby(); + assert.ok(ruby !== null, "Should return error Ruby definition"); + assert.strictEqual(ruby.error, true); + }); + + test("handles missing activation separator in response", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: "This is an incomplete response without separators", }); - await manager.activate(workspaceFolder); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); - const expectedCommand = buildExpectedCommand(context, "/usr/bin/ruby"); - const shell = getExpectedShell(); + const ruby = rubyEnv.getRuby(); + assert.ok(ruby !== null, "Should return error Ruby definition"); + assert.strictEqual(ruby.error, true); + }); + }); - assert.ok( - execStub.calledOnceWithExactly(expectedCommand, { - cwd: workspaceFolder.uri.fsPath, - shell, - env: process.env, - }), - `Expected asyncExec to be called with correct arguments`, - ); + suite("updateRubyDefinition", () => { + test("updates Ruby definition and fires change event", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); - assert.strictEqual(eventFired, true, "Event should have fired after activation"); - assert.ok(receivedEvent, "Event data should be received"); - assert.strictEqual(receivedEvent.workspace, workspaceFolder, "Workspace should match"); + helpers.stubAsyncExec(sandbox); + + let eventCount = 0; + const disposable = changeEmitter.event(() => { + eventCount++; + }); + + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.updateRubyDefinition(); - assertRubyDefinition(receivedEvent.ruby); + assert.strictEqual(eventCount, 1, "Event should fire once"); + + const ruby = rubyEnv.getRuby(); + assert.ok(ruby !== null, "Ruby definition should be updated"); + assert.strictEqual(ruby.error, false); disposable.dispose(); }); + }); + + suite("parseActivationResult", () => { + test("parses complete activation response with YJIT", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + const response = [ + "3.3.0", + "/path/to/gems,/another/path", + "true", + `PATH${VALUE_SEPARATOR}/usr/bin`, + `HOME${VALUE_SEPARATOR}/home/user`, + ].join(FIELD_SEPARATOR); + + helpers.stubAsyncExec(sandbox, response); + + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); + + const ruby = rubyEnv.getRuby(); + helpers.assertRubyDefinition(ruby); + }); + + test("parses activation response without YJIT", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + const response = [ + "3.2.0", + "/path/to/gems", + "", // Empty YJIT field + `PATH${VALUE_SEPARATOR}/usr/bin`, + ].join(FIELD_SEPARATOR); + + helpers.stubAsyncExec(sandbox, response); - test("fires when configuration changes after activation", async () => { - manager = new RubyEnvironmentManager(context); - await manager.activate(undefined); + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); - const eventPromise = new Promise((resolve) => { - manager.onDidRubyChange(() => { - resolve(); - }); + const ruby = rubyEnv.getRuby(); + helpers.assertRubyDefinition(ruby, { + rubyVersion: "3.2.0", + availableJITs: [], + gemPath: ["/path/to/gems"], + envVars: { PATH: "/usr/bin" }, }); + }); + + test("parses activation response with multiple gem paths", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + const response = ["3.3.0", "/path/one,/path/two,/path/three", "true"].join(FIELD_SEPARATOR); + + helpers.stubAsyncExec(sandbox, response); + + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Event did not fire within timeout")), 100); + const ruby = rubyEnv.getRuby(); + helpers.assertRubyDefinition(ruby, { + gemPath: ["/path/one", "/path/two", "/path/three"], + envVars: {}, }); + }); + test("parses activation response with multiple environment variables", async () => { const config = vscode.workspace.getConfiguration("rubyEnvironments"); - await config.update("rubyPath", "/test/ruby/path", vscode.ConfigurationTarget.Global); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); - await Promise.race([eventPromise, timeoutPromise]); + const response = [ + "3.3.0", + "/path/to/gems", + "true", + `PATH${VALUE_SEPARATOR}/usr/bin`, + `HOME${VALUE_SEPARATOR}/home/user`, + `GEM_HOME${VALUE_SEPARATOR}/home/user/.gem`, + `RUBY_VERSION${VALUE_SEPARATOR}3.3.0`, + ].join(FIELD_SEPARATOR); + + helpers.stubAsyncExec(sandbox, response); + + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + await rubyEnv.activate(); + + const ruby = rubyEnv.getRuby(); + helpers.assertRubyDefinition(ruby, { + gemPath: ["/path/to/gems"], + envVars: { + PATH: "/usr/bin", + HOME: "/home/user", + GEM_HOME: "/home/user/.gem", + RUBY_VERSION: "3.3.0", + }, + }); + }); + }); - assert.ok(true, "Event fired when configuration changed"); + suite("selectRuby", () => { + test("updates workspace state when path is provided manually", async () => { + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + const showQuickPickStub = sandbox + .stub(vscode.window, "showQuickPick") + .resolves({ label: "$(edit) Enter path manually...", value: "manual" } as vscode.QuickPickItem); + + const showInputBoxStub = sandbox.stub(vscode.window, "showInputBox").resolves("/new/ruby/path"); + const showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage"); + helpers.stubAsyncExec(sandbox); + + await rubyEnv.selectRuby(); + + assert.ok(showQuickPickStub.calledOnce, "Quick pick should be shown"); + assert.ok(showInputBoxStub.calledOnce, "Input box should be shown"); + assert.ok(showInformationMessageStub.calledOnce, "Confirmation message should be shown"); + + const storedPath = context.workspaceState.get(workspace.getStorageKey()); + assert.strictEqual(storedPath, "/new/ruby/path", "Path should be stored in workspace state"); + }); + + test("updates workspace state when path is browsed", async () => { + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + const showQuickPickStub = sandbox + .stub(vscode.window, "showQuickPick") + .resolves({ label: "$(folder) Browse for file...", value: "browse" } as vscode.QuickPickItem); + + const testPath = "/browsed/ruby/path"; + const showOpenDialogStub = sandbox.stub(vscode.window, "showOpenDialog").resolves([vscode.Uri.file(testPath)]); + const showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage"); + helpers.stubAsyncExec(sandbox); + + await rubyEnv.selectRuby(); + + assert.ok(showQuickPickStub.calledOnce, "Quick pick should be shown"); + assert.ok(showOpenDialogStub.calledOnce, "Open dialog should be shown"); + assert.ok(showInformationMessageStub.calledOnce, "Confirmation message should be shown"); + + const storedPath = context.workspaceState.get(workspace.getStorageKey()); + assert.strictEqual(storedPath, testPath, "Path should be stored in workspace state"); + }); + + test("does not update when user cancels quick pick", async () => { + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + const showQuickPickStub = sandbox.stub(vscode.window, "showQuickPick").resolves(undefined); + + await rubyEnv.selectRuby(); + + assert.ok(showQuickPickStub.calledOnce, "Quick pick should be shown"); + + const storedPath = context.workspaceState.get(workspace.getStorageKey()); + assert.strictEqual(storedPath, undefined, "Path should not be stored"); + }); + + test("does not update when user cancels file browse", async () => { + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + sandbox + .stub(vscode.window, "showQuickPick") + .resolves({ label: "$(folder) Browse for file...", value: "browse" } as vscode.QuickPickItem); + + const showOpenDialogStub = sandbox.stub(vscode.window, "showOpenDialog").resolves(undefined); + + await rubyEnv.selectRuby(); + + assert.ok(showOpenDialogStub.calledOnce, "Open dialog should be shown"); + + const storedPath = context.workspaceState.get(workspace.getStorageKey()); + assert.strictEqual(storedPath, undefined, "Path should not be stored"); + }); + + test("does not update when user cancels manual input", async () => { + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + sandbox + .stub(vscode.window, "showQuickPick") + .resolves({ label: "$(edit) Enter path manually...", value: "manual" } as vscode.QuickPickItem); + + const showInputBoxStub = sandbox.stub(vscode.window, "showInputBox").resolves(undefined); + + await rubyEnv.selectRuby(); + + assert.ok(showInputBoxStub.calledOnce, "Input box should be shown"); + + const storedPath = context.workspaceState.get(workspace.getStorageKey()); + assert.strictEqual(storedPath, undefined, "Path should not be stored"); + }); + + test("validates empty input", async () => { + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + let validateInput: ((value: string) => string | null) | undefined; + + sandbox + .stub(vscode.window, "showQuickPick") + .resolves({ label: "$(edit) Enter path manually...", value: "manual" } as vscode.QuickPickItem); + + sandbox.stub(vscode.window, "showInputBox").callsFake((options?: vscode.InputBoxOptions) => { + // eslint-disable-next-line @typescript-eslint/unbound-method + validateInput = options?.validateInput as ((value: string) => string | null) | undefined; + return Promise.resolve(undefined); + }); + + await rubyEnv.selectRuby(); + + assert.ok(validateInput, "Validation function should be provided"); + + const emptyError = validateInput(""); + assert.strictEqual(emptyError, "Path cannot be empty"); + + const whitespaceError = validateInput(" "); + assert.strictEqual(whitespaceError, "Path cannot be empty"); + + const validResult = validateInput("/valid/path"); + assert.strictEqual(validResult, null); + }); + + test("shows current path in placeholder", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/existing/ruby", vscode.ConfigurationTarget.Global); + + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + let capturedPlaceholder: string | undefined; + + const showQuickPickStub = sandbox.stub(vscode.window, "showQuickPick").callsFake((items, options?) => { + capturedPlaceholder = (options as vscode.QuickPickOptions)?.placeHolder; + return Promise.resolve(undefined); + }); + + await rubyEnv.selectRuby(); + + assert.ok(showQuickPickStub.calledOnce); + assert.ok(capturedPlaceholder?.includes("/existing/ruby"), "Placeholder should show current path"); + }); + + test("fires change event after updating path", async () => { + rubyEnv = new RubyEnvironment(context, workspace, changeEmitter); + + sandbox + .stub(vscode.window, "showQuickPick") + .resolves({ label: "$(edit) Enter path manually...", value: "manual" } as vscode.QuickPickItem); + + sandbox.stub(vscode.window, "showInputBox").resolves("/new/ruby/path"); + sandbox.stub(vscode.window, "showInformationMessage"); + helpers.stubAsyncExec(sandbox); + + let eventFired = false; + const disposable = changeEmitter.event(() => { + eventFired = true; + }); + + await rubyEnv.selectRuby(); + + assert.strictEqual(eventFired, true, "Change event should fire after path update"); + + disposable.dispose(); + }); + }); + + suite("default workspace", () => { + test("works with default workspace context", async () => { + const defaultWorkspace = WorkspaceContext.createDefault(); + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + helpers.stubAsyncExec(sandbox); + + rubyEnv = new RubyEnvironment(context, defaultWorkspace, changeEmitter); + await rubyEnv.activate(); + + const ruby = rubyEnv.getRuby(); + assert.ok(ruby !== null, "Should return Ruby definition for default workspace"); + assert.strictEqual(ruby.error, false); + }); + + test("uses simple storage key for default workspace", async () => { + const defaultWorkspace = WorkspaceContext.createDefault(); + await context.workspaceState.update("rubyPath", "/default/ruby/path"); + + helpers.stubAsyncExec(sandbox); + + rubyEnv = new RubyEnvironment(context, defaultWorkspace, changeEmitter); + await rubyEnv.activate(); + + const ruby = rubyEnv.getRuby(); + assert.ok(ruby !== null, "Should load from default storage key"); + assert.strictEqual(ruby.error, false); }); }); }); diff --git a/src/test/rubyEnvironmentManager.test.ts b/src/test/rubyEnvironmentManager.test.ts new file mode 100644 index 0000000..91549d1 --- /dev/null +++ b/src/test/rubyEnvironmentManager.test.ts @@ -0,0 +1,229 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import sinon from "sinon"; +import { suite, test, beforeEach, afterEach } from "mocha"; +import { RubyEnvironmentManager } from "../rubyEnvironmentManager"; +import * as helpers from "./helpers"; +import { RubyChangeEvent } from "../types"; + +suite("RubyEnvironmentManager", () => { + let context: helpers.FakeContext; + let manager: RubyEnvironmentManager; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + context = helpers.createContext(); + sandbox = sinon.createSandbox(); + }); + + afterEach(async () => { + context.dispose(); + // Clean up any configuration changes + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", undefined, vscode.ConfigurationTarget.Global); + sandbox.restore(); + }); + + suite("constructor", () => { + test("registers subscriptions", () => { + const initialSubscriptions = context.subscriptions.length; + manager = new RubyEnvironmentManager(context); + + assert.ok(context.subscriptions.length > initialSubscriptions, "Manager should register subscriptions"); + }); + + test("registers selectRuby command", async () => { + manager = new RubyEnvironmentManager(context); + + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes("ruby-environments.selectRuby"), "Command should be registered"); + }); + }); + + suite("getRuby", () => { + test("returns null before activation", () => { + manager = new RubyEnvironmentManager(context); + + const ruby = manager.getRuby(undefined); + assert.strictEqual(ruby, null, "Should return null before activation"); + }); + }); + + suite("activate", () => { + test("does not set Ruby definition if not configured", async () => { + manager = new RubyEnvironmentManager(context); + await manager.activate(); + + const ruby = manager.getRuby(undefined); + assert.strictEqual(ruby, null, "Should return null if not configured"); + }); + + test("activates specific workspace when provided", async () => { + const workspace1 = helpers.createTestWorkspace(); + const workspace2 = { + uri: vscode.Uri.file("/test/workspace2"), + name: "test2", + index: 1, + }; + + // Set up configuration for workspace1 + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + const _execStub = helpers.stubAsyncExec(sandbox); + sandbox.stub(vscode.workspace, "workspaceFolders").value([workspace1, workspace2]); + + manager = new RubyEnvironmentManager(context); + + // Activate only workspace2 + await manager.activateWorkspace(workspace2); + + // workspace2 should be activated + const ruby2 = manager.getRuby(workspace2); + assert.ok(ruby2 !== null, "workspace2 should have Ruby environment"); + + // workspace1 should NOT be activated yet + const ruby1 = manager.getRuby(workspace1); + assert.strictEqual(ruby1, null, "workspace1 should not be activated yet"); + }); + + test("initializes Ruby definition from configuration", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + const workspaceFolder = helpers.createTestWorkspace(); + const execStub = helpers.stubAsyncExec(sandbox); + + // Stub workspace folders so the manager sees our test workspace + sandbox.stub(vscode.workspace, "workspaceFolders").value([workspaceFolder]); + + manager = new RubyEnvironmentManager(context); + await manager.activate(); + + const expectedCommand = helpers.buildExpectedCommand(context, "/usr/bin/ruby"); + const shell = helpers.getExpectedShell(); + + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); + + const ruby = manager.getRuby(workspaceFolder); + helpers.assertRubyDefinition(ruby); + }); + + test("initializes Ruby definition from workspace state", async () => { + const workspaceFolder = helpers.createTestWorkspace(); + await context.workspaceState.update(`rubyPath:${workspaceFolder.uri.toString()}`, "/custom/ruby/path"); + + const execStub = helpers.stubAsyncExec(sandbox); + + // Stub workspace folders so the manager sees our test workspace + sandbox.stub(vscode.workspace, "workspaceFolders").value([workspaceFolder]); + + manager = new RubyEnvironmentManager(context); + await manager.activate(); + + const expectedCommand = helpers.buildExpectedCommand(context, "/custom/ruby/path"); + const shell = helpers.getExpectedShell(); + + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); + + const ruby = manager.getRuby(workspaceFolder); + assert.ok(ruby !== null, "Should return Ruby definition"); + assert.strictEqual(ruby.error, false); + }); + }); + + suite("onDidRubyChange", () => { + test("is an event that can be subscribed to", () => { + manager = new RubyEnvironmentManager(context); + + let eventFired = false; + const disposable = manager.onDidRubyChange(() => { + eventFired = true; + }); + + assert.ok(disposable, "onDidRubyChange should return a disposable"); + assert.strictEqual(typeof disposable.dispose, "function", "disposable should have a dispose method"); + + disposable.dispose(); + assert.strictEqual(eventFired, false, "event should not have fired yet"); + }); + + test("fires when activate is called", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + const workspaceFolder = helpers.createTestWorkspace(); + const execStub = helpers.stubAsyncExec(sandbox); + + // Stub workspace folders so the manager sees our test workspace + sandbox.stub(vscode.workspace, "workspaceFolders").value([workspaceFolder]); + + manager = new RubyEnvironmentManager(context); + + let eventFired = false; + let receivedEvent: RubyChangeEvent | undefined; + const disposable = manager.onDidRubyChange((event) => { + eventFired = true; + receivedEvent = event; + }); + + await manager.activate(); + + const expectedCommand = helpers.buildExpectedCommand(context, "/usr/bin/ruby"); + const shell = helpers.getExpectedShell(); + + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); + + assert.strictEqual(eventFired, true, "Event should have fired after activation"); + assert.ok(receivedEvent, "Event data should be received"); + assert.strictEqual(receivedEvent.workspace, workspaceFolder, "Workspace should match"); + + helpers.assertRubyDefinition(receivedEvent.ruby); + + disposable.dispose(); + }); + + test("fires when configuration changes after activation", async () => { + manager = new RubyEnvironmentManager(context); + await manager.activate(); + + const eventPromise = new Promise((resolve) => { + manager.onDidRubyChange(() => { + resolve(); + }); + }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Event did not fire within timeout")), 100); + }); + + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/test/ruby/path", vscode.ConfigurationTarget.Global); + + await Promise.race([eventPromise, timeoutPromise]); + + assert.ok(true, "Event fired when configuration changed"); + }); + }); +}); diff --git a/src/test/workspaceContext.test.ts b/src/test/workspaceContext.test.ts new file mode 100644 index 0000000..cb7a25c --- /dev/null +++ b/src/test/workspaceContext.test.ts @@ -0,0 +1,63 @@ +import * as assert from "assert"; +import { suite, test } from "mocha"; +import { WorkspaceContext } from "../workspaceContext"; +import * as helpers from "./helpers"; + +suite("WorkspaceContext", () => { + suite("fromWorkspaceFolder", () => { + test("creates context from workspace folder", () => { + const folder = helpers.createTestWorkspace(); + const context = WorkspaceContext.fromWorkspaceFolder(folder); + + assert.strictEqual(context.uri, folder.uri); + assert.strictEqual(context.name, folder.name); + assert.strictEqual(context.key, folder.uri.toString()); + assert.strictEqual(context.isDefault, false); + assert.strictEqual(context.workspaceFolder, folder); + }); + }); + + suite("createDefault", () => { + test("creates default context", () => { + const context = WorkspaceContext.createDefault(); + + assert.strictEqual(context.uri.fsPath, process.cwd()); + assert.strictEqual(context.name, "default"); + assert.strictEqual(context.key, "__default__"); + assert.strictEqual(context.isDefault, true); + assert.strictEqual(context.workspaceFolder, undefined); + }); + }); + + suite("from", () => { + test("creates workspace context from folder", () => { + const folder = helpers.createTestWorkspace(); + const context = WorkspaceContext.from(folder); + + assert.strictEqual(context.uri, folder.uri); + assert.strictEqual(context.isDefault, false); + assert.strictEqual(context.workspaceFolder, folder); + }); + + test("creates default context when undefined", () => { + const context = WorkspaceContext.from(undefined); + + assert.strictEqual(context.name, "default"); + assert.strictEqual(context.isDefault, true); + assert.strictEqual(context.workspaceFolder, undefined); + }); + }); + + suite("getStorageKey", () => { + test("returns simple key for default workspace", () => { + const context = WorkspaceContext.createDefault(); + assert.strictEqual(context.getStorageKey(), "rubyPath"); + }); + + test("returns prefixed key for specific workspace", () => { + const folder = helpers.createTestWorkspace(); + const context = WorkspaceContext.fromWorkspaceFolder(folder); + assert.strictEqual(context.getStorageKey(), `rubyPath:${folder.uri.toString()}`); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 535661e..bc9704f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,10 +48,12 @@ export interface RubyChangeEvent { * The public API that gets exposed to other extensions that depend on Ruby environments */ export interface RubyEnvironmentsApi { - /** Activate the extension for a specific workspace */ - activate: (workspace: vscode.WorkspaceFolder | undefined) => Promise; - /** Get the current Ruby definition */ - getRuby: () => OptionalRubyDefinition; - /** Event that fires when the Ruby environment changes */ + /** Activate all Ruby environments on extension load */ + activate: () => Promise; + /** Ensure the Ruby environment is activated for a specific workspace folder */ + activateWorkspace: (workspace: vscode.WorkspaceFolder | undefined) => Promise; + /** Get the Ruby definition for a specific workspace folder */ + getRuby: (workspace: vscode.WorkspaceFolder | undefined) => OptionalRubyDefinition; + /** Event that fires when the Ruby environment changes for any workspace */ onDidRubyChange: vscode.Event; } diff --git a/src/workspaceContext.ts b/src/workspaceContext.ts new file mode 100644 index 0000000..ce6831d --- /dev/null +++ b/src/workspaceContext.ts @@ -0,0 +1,43 @@ +import * as vscode from "vscode"; + +/** + * Represents a workspace context - either a real workspace folder or a default (no folder) context + */ +export class WorkspaceContext { + readonly uri: vscode.Uri; + readonly name: string; + readonly key: string; + readonly isDefault: boolean; + readonly workspaceFolder: vscode.WorkspaceFolder | undefined; + + private constructor( + uri: vscode.Uri, + name: string, + key: string, + isDefault: boolean, + workspaceFolder: vscode.WorkspaceFolder | undefined, + ) { + this.uri = uri; + this.name = name; + this.key = key; + this.isDefault = isDefault; + this.workspaceFolder = workspaceFolder; + } + + static fromWorkspaceFolder(folder: vscode.WorkspaceFolder): WorkspaceContext { + return new WorkspaceContext(folder.uri, folder.name, folder.uri.toString(), false, folder); + } + + static createDefault(): WorkspaceContext { + const uri = vscode.Uri.file(process.cwd()); + return new WorkspaceContext(uri, "default", "__default__", true, undefined); + } + + static from(workspace: vscode.WorkspaceFolder | undefined): WorkspaceContext { + return workspace ? WorkspaceContext.fromWorkspaceFolder(workspace) : WorkspaceContext.createDefault(); + } + + getStorageKey(): string { + return this.isDefault ? "rubyPath" : `rubyPath:${this.key}`; + } +}