@@ -2,195 +2,175 @@ import fs from "fs";
22import assert from "assert" ;
33import path from "path" ;
44import os from "os" ;
5- import { execSync } from "child_process" ;
65
76import { beforeEach , afterEach } from "mocha" ;
87import * as vscode from "vscode" ;
98import sinon from "sinon" ;
109
11- import { Shadowenv } from "../../../ruby/shadowenv" ;
10+ import { Shadowenv , UntrustedWorkspaceError } from "../../../ruby/shadowenv" ;
1211import { WorkspaceChannel } from "../../../workspaceChannel" ;
13- import { LOG_CHANNEL , asyncExec } from "../../../common" ;
14- import { RUBY_VERSION } from "../../rubyVersion" ;
12+ import { LOG_CHANNEL } from "../../../common" ;
1513import * as common from "../../../common" ;
14+ import { ActivationResult , NonReportableError } from "../../../ruby/versionManager" ;
1615import { createContext , FakeContext } from "../helpers" ;
1716
18- suite ( "Shadowenv" , ( ) => {
19- if ( os . platform ( ) === "win32" ) {
20- // eslint-disable-next-line no-console
21- console . log ( "Skipping Shadowenv tests on Windows" ) ;
22- return ;
23- }
24-
25- try {
26- execSync ( "shadowenv --version >/dev/null 2>&1" ) ;
27- } catch {
28- // eslint-disable-next-line no-console
29- console . log ( "Skipping Shadowenv tests because no `shadowenv` found" ) ;
30- return ;
31- }
32-
33- let context : FakeContext ;
34- beforeEach ( ( ) => {
35- context = createContext ( ) ;
36- } ) ;
37- afterEach ( ( ) => {
38- context . dispose ( ) ;
39- } ) ;
17+ // Typed view over the private method we need to stub. Kept in one place so the cast doesn't leak into each test.
18+ type ShadowenvStub = { runEnvActivationScript : ( command : string ) => Promise < ActivationResult > } ;
19+ type ActivationBehavior = ActivationResult | Error ;
4020
21+ suite ( "Shadowenv" , ( ) => {
4122 let rootPath : string ;
4223 let workspacePath : string ;
4324 let workspaceFolder : vscode . WorkspaceFolder ;
4425 let outputChannel : WorkspaceChannel ;
45- let rubyBinPath : string ;
46- const [ major , minor , patch ] = RUBY_VERSION . split ( "." ) ;
47-
48- if ( process . env . CI && os . platform ( ) === "linux" ) {
49- rubyBinPath = path . join ( "/" , "opt" , "hostedtoolcache" , "Ruby" , RUBY_VERSION , "x64" , "bin" ) ;
50- } else if ( process . env . CI ) {
51- rubyBinPath = path . join ( "/" , "Users" , "runner" , "hostedtoolcache" , "Ruby" , RUBY_VERSION , "arm64" , "bin" ) ;
52- } else {
53- rubyBinPath = path . join ( "/" , "opt" , "rubies" , RUBY_VERSION , "bin" ) ;
26+ let context : FakeContext ;
27+ let sandbox : sinon . SinonSandbox ;
28+ const FAKE_ACTIVATION : ActivationResult = {
29+ env : { PATH : "/fake/ruby/bin:/usr/bin" , GEM_ROOT : "/fake/gem/root" } ,
30+ yjit : true ,
31+ version : "3.3.5" ,
32+ gemPath : [ "/fake/gem/path" ] ,
33+ } ;
34+
35+ function stubActivation ( behaviors : ActivationBehavior [ ] ) : sinon . SinonStub {
36+ const stub = sandbox . stub ( Shadowenv . prototype as unknown as ShadowenvStub , "runEnvActivationScript" ) ;
37+
38+ behaviors . forEach ( ( behavior , i ) => {
39+ if ( behavior instanceof Error ) {
40+ stub . onCall ( i ) . rejects ( behavior ) ;
41+ } else {
42+ stub . onCall ( i ) . resolves ( behavior ) ;
43+ }
44+ } ) ;
45+
46+ return stub ;
5447 }
5548
56- assert . ok ( fs . existsSync ( rubyBinPath ) , `Ruby bin path does not exist ${ rubyBinPath } ` ) ;
57-
58- const shadowLispFile = `
59- (provide "ruby" "${ RUBY_VERSION } ")
60-
61- (when-let ((ruby-root (env/get "RUBY_ROOT")))
62- (env/remove-from-pathlist "PATH" (path-concat ruby-root "bin"))
63- (when-let ((gem-root (env/get "GEM_ROOT")))
64- (env/remove-from-pathlist "PATH" (path-concat gem-root "bin")))
65- (when-let ((gem-home (env/get "GEM_HOME")))
66- (env/remove-from-pathlist "PATH" (path-concat gem-home "bin"))))
67-
68- (env/set "BUNDLE_PATH" ())
69- (env/set "GEM_PATH" ())
70- (env/set "GEM_HOME" ())
71- (env/set "RUBYOPT" ())
72- (env/set "RUBYLIB" ())
73-
74- (env/set "RUBY_ROOT" "${ path . dirname ( rubyBinPath ) } ")
75- (env/prepend-to-pathlist "PATH" "${ rubyBinPath } ")
76- (env/set "RUBY_ENGINE" "ruby")
77- (env/set "RUBY_VERSION" "${ RUBY_VERSION } ")
78- (env/set "GEM_ROOT" "${ path . dirname ( rubyBinPath ) } /lib/ruby/gems/${ major } .${ minor } .0")
79-
80- (when-let ((gem-root (env/get "GEM_ROOT")))
81- (env/prepend-to-pathlist "GEM_PATH" gem-root)
82- (env/prepend-to-pathlist "PATH" (path-concat gem-root "bin")))
83-
84- (let ((gem-home
85- (path-concat (env/get "HOME") ".gem" (env/get "RUBY_ENGINE") "${ RUBY_VERSION } ")))
86- (do
87- (env/set "GEM_HOME" gem-home)
88- (env/prepend-to-pathlist "GEM_PATH" gem-home)
89- (env/prepend-to-pathlist "PATH" (path-concat gem-home "bin"))))
90- ` ;
49+ function expectNonReportable ( error : Error , messagePattern : RegExp ) : boolean {
50+ assert . ok ( error instanceof NonReportableError ) ;
51+ assert . match ( error . message , messagePattern ) ;
52+ return true ;
53+ }
9154
9255 beforeEach ( ( ) => {
56+ sandbox = sinon . createSandbox ( ) ;
57+ context = createContext ( ) ;
58+
9359 rootPath = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "ruby-lsp-test-shadowenv-" ) ) ;
9460 workspacePath = path . join ( rootPath , "workspace" ) ;
95-
9661 fs . mkdirSync ( workspacePath ) ;
9762 fs . mkdirSync ( path . join ( workspacePath , ".shadowenv.d" ) ) ;
9863
9964 workspaceFolder = {
100- uri : vscode . Uri . from ( { scheme : " file" , path : workspacePath } ) ,
65+ uri : vscode . Uri . file ( workspacePath ) ,
10166 name : path . basename ( workspacePath ) ,
10267 index : 0 ,
10368 } ;
10469 outputChannel = new WorkspaceChannel ( "fake" , LOG_CHANNEL ) ;
10570 } ) ;
10671
10772 afterEach ( ( ) => {
73+ sandbox . restore ( ) ;
74+ context . dispose ( ) ;
10875 fs . rmSync ( rootPath , { recursive : true , force : true } ) ;
10976 } ) ;
11077
111- test ( "Finds Ruby only binary path is appended to PATH " , async ( ) => {
112- await asyncExec ( " shadowenv trust" , { cwd : workspacePath } ) ;
78+ test ( "Throws when .shadowenv.d is missing from the workspace " , async ( ) => {
79+ fs . rmSync ( path . join ( workspacePath , ". shadowenv.d" ) , { recursive : true , force : true } ) ;
11380
114- fs . writeFileSync (
115- path . join ( workspacePath , ".shadowenv.d" , "500_ruby.lisp" ) ,
116- `(env/prepend-to-pathlist "PATH" " ${ rubyBinPath } ")` ,
81+ await assert . rejects (
82+ ( ) => new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) . activate ( ) ,
83+ ( error : Error ) => expectNonReportable ( error , / n o \. s h a d o w e n v \. d d i r e c t o r y w a s f o u n d / ) ,
11784 ) ;
118-
119- const shadowenv = new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) ;
120- const { env, version, yjit } = await shadowenv . activate ( ) ;
121-
122- assert . match ( env . PATH ! , new RegExp ( rubyBinPath ) ) ;
123- assert . strictEqual ( version , RUBY_VERSION ) ;
124- assert . notStrictEqual ( yjit , undefined ) ;
12585 } ) ;
12686
127- test ( "Finds Ruby on a complete shadowenv configuration" , async ( ) => {
128- await asyncExec ( "shadowenv trust" , { cwd : workspacePath } ) ;
129-
130- fs . writeFileSync ( path . join ( workspacePath , ".shadowenv.d" , "500_ruby.lisp" ) , shadowLispFile ) ;
131-
132- const shadowenv = new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) ;
133- const { env, version, yjit } = await shadowenv . activate ( ) ;
134-
135- assert . match ( env . PATH ! , new RegExp ( rubyBinPath ) ) ;
136- assert . strictEqual ( env . GEM_ROOT , `${ path . dirname ( rubyBinPath ) } /lib/ruby/gems/${ major } .${ minor } .0` ) ;
137- assert . strictEqual ( version , RUBY_VERSION ) ;
138- assert . notStrictEqual ( yjit , undefined ) ;
87+ test ( "Invokes `shadowenv exec -- ruby` and strips BUNDLE_GEMFILE coming from shadowenv" , async ( ) => {
88+ const originalBundleGemfile = process . env . BUNDLE_GEMFILE ;
89+ process . env . BUNDLE_GEMFILE = "/from/process/env/Gemfile" ;
90+
91+ try {
92+ const stub = stubActivation ( [
93+ {
94+ ...FAKE_ACTIVATION ,
95+ env : { ...FAKE_ACTIVATION . env , PATH : "/fake/ruby/bin" , BUNDLE_GEMFILE : "/from/shadowenv/Gemfile" } ,
96+ } ,
97+ ] ) ;
98+
99+ const { env, version, yjit, gemPath } = await new Shadowenv (
100+ workspaceFolder ,
101+ outputChannel ,
102+ context ,
103+ async ( ) => { } ,
104+ ) . activate ( ) ;
105+
106+ assert . ok ( stub . calledOnce ) ;
107+ assert . match ( stub . firstCall . args [ 0 ] as string , / s h a d o w e n v e x e c - - r u b y $ / ) ;
108+ // Shadowenv's BUNDLE_GEMFILE must not leak into the final env; the server needs to control this value
109+ assert . notStrictEqual ( env . BUNDLE_GEMFILE , "/from/shadowenv/Gemfile" ) ;
110+ assert . strictEqual ( env . BUNDLE_GEMFILE , "/from/process/env/Gemfile" ) ;
111+ assert . strictEqual ( env . PATH , "/fake/ruby/bin" ) ;
112+ assert . strictEqual ( version , "3.3.5" ) ;
113+ assert . strictEqual ( yjit , true ) ;
114+ assert . deepStrictEqual ( gemPath , [ "/fake/gem/path" ] ) ;
115+ } finally {
116+ if ( originalBundleGemfile === undefined ) {
117+ delete process . env . BUNDLE_GEMFILE ;
118+ } else {
119+ process . env . BUNDLE_GEMFILE = originalBundleGemfile ;
120+ }
121+ }
139122 } ) ;
140123
141- test ( "Untrusted workspace offers to trust it" , async ( ) => {
142- fs . writeFileSync ( path . join ( workspacePath , ".shadowenv.d" , "500_ruby.lisp" ) , shadowLispFile ) ;
143-
144- const stub = sinon . stub ( vscode . window , "showErrorMessage" ) . resolves ( "Trust workspace" as any ) ;
145-
146- const shadowenv = new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) ;
147- const { env, version, yjit } = await shadowenv . activate ( ) ;
124+ test ( "Prompts to trust the workspace when shadowenv reports it is untrusted, and retries on accept" , async ( ) => {
125+ const activationStub = stubActivation ( [ new Error ( "untrusted shadowenv program" ) , FAKE_ACTIVATION ] ) ;
148126
149- assert . match ( env . PATH ! , new RegExp ( rubyBinPath ) ) ;
150- assert . match ( env . GEM_HOME ! , new RegExp ( `\\.gem\\/ruby\\/${ major } \\.${ minor } \\.${ patch } ` ) ) ;
151- assert . strictEqual ( version , RUBY_VERSION ) ;
152- assert . notStrictEqual ( yjit , undefined ) ;
127+ const showError = sandbox . stub ( vscode . window , "showErrorMessage" ) as sinon . SinonStub ;
128+ showError . resolves ( "Trust workspace" ) ;
129+ const execStub = sandbox . stub ( common , "asyncExec" ) . resolves ( { stdout : "" , stderr : "" } ) ;
153130
154- assert . ok ( stub . calledOnce ) ;
131+ const result = await new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) . activate ( ) ;
155132
156- stub . restore ( ) ;
133+ assert . ok ( showError . calledOnce ) ;
134+ assert . ok ( execStub . calledOnce ) ;
135+ assert . match ( execStub . firstCall . args [ 0 ] , / ^ s h a d o w e n v t r u s t $ / ) ;
136+ assert . strictEqual ( activationStub . callCount , 2 ) ;
137+ assert . strictEqual ( result . version , "3.3.5" ) ;
157138 } ) ;
158139
159- test ( "Deciding not to trust the workspace fails activation " , async ( ) => {
160- fs . writeFileSync ( path . join ( workspacePath , ". shadowenv.d" , "500_ruby.lisp" ) , shadowLispFile ) ;
140+ test ( "Rejects with UntrustedWorkspaceError when the user declines to trust the workspace" , async ( ) => {
141+ stubActivation ( [ new Error ( "untrusted shadowenv program" ) ] ) ;
161142
162- const stub = sinon . stub ( vscode . window , "showErrorMessage" ) . resolves ( "Cancel" as any ) ;
143+ const showError = sandbox . stub ( vscode . window , "showErrorMessage" ) as sinon . SinonStub ;
144+ showError . resolves ( "Shutdown Ruby LSP" ) ;
163145
164- const shadowenv = new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) ;
165-
166- await assert . rejects ( async ( ) => {
167- await shadowenv . activate ( ) ;
168- } ) ;
169-
170- assert . ok ( stub . calledOnce ) ;
171-
172- stub . restore ( ) ;
146+ await assert . rejects (
147+ ( ) => new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) . activate ( ) ,
148+ UntrustedWorkspaceError ,
149+ ) ;
150+ assert . ok ( showError . calledOnce ) ;
173151 } ) ;
174152
175- test ( "Warns user is shadowenv executable can't be found" , async ( ) => {
176- await asyncExec ( "shadowenv trust" , { cwd : workspacePath } ) ;
177-
178- fs . writeFileSync ( path . join ( workspacePath , ".shadowenv.d" , "500_ruby.lisp" ) , shadowLispFile ) ;
179-
180- const shadowenv = new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) ;
153+ test ( "Reports a PATH-related error when the shadowenv executable cannot be found" , async ( ) => {
154+ stubActivation ( [ new Error ( "spawn shadowenv ENOENT" ) ] ) ;
155+ const execStub = sandbox . stub ( common , "asyncExec" ) . rejects ( new Error ( "shadowenv: command not found" ) ) ;
181156
182- // First, reject the call to `shadowenv exec`. Then resolve the call to `which shadowenv` to return nothing
183- const execStub = sinon
184- . stub ( common , "asyncExec" )
185- . onFirstCall ( )
186- . rejects ( new Error ( "shadowenv: command not found" ) )
187- . onSecondCall ( )
188- . rejects ( new Error ( "shadowenv: command not found" ) ) ;
157+ await assert . rejects (
158+ ( ) => new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) . activate ( ) ,
159+ ( error : Error ) => expectNonReportable ( error , / S h a d o w e n v e x e c u t a b l e n o t f o u n d / ) ,
160+ ) ;
161+ assert . ok ( execStub . calledOnce ) ;
162+ assert . match ( execStub . firstCall . args [ 0 ] , / ^ s h a d o w e n v - - v e r s i o n $ / ) ;
163+ } ) ;
189164
190- await assert . rejects ( async ( ) => {
191- await shadowenv . activate ( ) ;
192- } ) ;
165+ test ( "Surfaces the underlying error when activation fails for a non-trust, non-missing reason" , async ( ) => {
166+ stubActivation ( [ new Error ( "boom" ) ] ) ;
167+ const execStub = sandbox . stub ( common , "asyncExec" ) . resolves ( { stdout : "shadowenv 2.1.5" , stderr : "" } ) ;
193168
194- execStub . restore ( ) ;
169+ await assert . rejects (
170+ ( ) => new Shadowenv ( workspaceFolder , outputChannel , context , async ( ) => { } ) . activate ( ) ,
171+ ( error : Error ) => expectNonReportable ( error , / F a i l e d t o a c t i v a t e R u b y e n v i r o n m e n t w i t h S h a d o w e n v : b o o m / ) ,
172+ ) ;
173+ assert . ok ( execStub . calledOnce ) ;
174+ assert . match ( execStub . firstCall . args [ 0 ] , / ^ s h a d o w e n v - - v e r s i o n $ / ) ;
195175 } ) ;
196176} ) ;
0 commit comments