github.com/getgauge/gauge@v1.6.9/plugin/plugin.go (about)

     1  /*----------------------------------------------------------------
     2   *  Copyright (c) ThoughtWorks, Inc.
     3   *  Licensed under the Apache License, Version 2.0
     4   *  See LICENSE in the project root for license information.
     5   *----------------------------------------------------------------*/
     6  
     7  package plugin
     8  
     9  import (
    10  	"context"
    11  	"encoding/json"
    12  	"fmt"
    13  	"net"
    14  	"os"
    15  	"os/exec"
    16  	"path/filepath"
    17  	"runtime"
    18  	"strconv"
    19  	"strings"
    20  	"sync"
    21  	"time"
    22  
    23  	"github.com/getgauge/common"
    24  	"github.com/getgauge/gauge-proto/go/gauge_messages"
    25  	"github.com/getgauge/gauge/api/infoGatherer"
    26  	"github.com/getgauge/gauge/config"
    27  	"github.com/getgauge/gauge/conn"
    28  	"github.com/getgauge/gauge/gauge"
    29  	"github.com/getgauge/gauge/logger"
    30  	"github.com/getgauge/gauge/manifest"
    31  	"github.com/getgauge/gauge/plugin/pluginInfo"
    32  	"github.com/getgauge/gauge/version"
    33  	"google.golang.org/grpc"
    34  	"google.golang.org/grpc/codes"
    35  	"google.golang.org/grpc/credentials/insecure"
    36  	"google.golang.org/grpc/status"
    37  	"google.golang.org/protobuf/proto"
    38  )
    39  
    40  type pluginScope string
    41  
    42  const (
    43  	executionScope          pluginScope = "execution"
    44  	docScope                pluginScope = "documentation"
    45  	pluginConnectionPortEnv             = "plugin_connection_port"
    46  )
    47  
    48  type plugin struct {
    49  	mutex            *sync.Mutex
    50  	connection       net.Conn
    51  	gRPCConn         *grpc.ClientConn
    52  	ReporterClient   gauge_messages.ReporterClient
    53  	DocumenterClient gauge_messages.DocumenterClient
    54  	pluginCmd        *exec.Cmd
    55  	descriptor       *PluginDescriptor
    56  	killTimer        *time.Timer
    57  }
    58  
    59  func isProcessRunning(p *plugin) bool {
    60  	p.mutex.Lock()
    61  	ps := p.pluginCmd.ProcessState
    62  	p.mutex.Unlock()
    63  	return ps == nil || !ps.Exited()
    64  }
    65  
    66  func (p *plugin) killGrpcProcess() error {
    67  	var m *gauge_messages.Empty
    68  	var err error
    69  	if p.ReporterClient != nil {
    70  		m, err = p.ReporterClient.Kill(context.Background(), &gauge_messages.KillProcessRequest{})
    71  	} else if p.DocumenterClient != nil {
    72  		m, err = p.DocumenterClient.Kill(context.Background(), &gauge_messages.KillProcessRequest{})
    73  	}
    74  	if m == nil || err != nil {
    75  		errStatus, _ := status.FromError(err)
    76  		if errStatus.Code() == codes.Unavailable {
    77  			// Ref https://www.grpc.io/docs/guides/error/#general-errors
    78  			// GRPC_STATUS_UNAVAILABLE is thrown when Server is shutting down. Ignore it here.
    79  			return nil
    80  		}
    81  		return err
    82  	}
    83  	if p.gRPCConn == nil && p.pluginCmd == nil {
    84  		return nil
    85  	}
    86  	defer p.gRPCConn.Close()
    87  
    88  	if isProcessRunning(p) {
    89  		exited := make(chan bool, 1)
    90  		go func() {
    91  			for {
    92  				if isProcessRunning(p) {
    93  					time.Sleep(100 * time.Millisecond)
    94  				} else {
    95  					exited <- true
    96  					return
    97  				}
    98  			}
    99  		}()
   100  
   101  		select {
   102  		case done := <-exited:
   103  			if done {
   104  				logger.Debugf(true, "Runner with PID:%d has exited", p.pluginCmd.Process.Pid)
   105  				return nil
   106  			}
   107  		case <-time.After(config.PluginKillTimeout()):
   108  			logger.Warningf(true, "Killing runner with PID:%d forcefully", p.pluginCmd.Process.Pid)
   109  			return p.pluginCmd.Process.Kill()
   110  		}
   111  	}
   112  	return nil
   113  }
   114  
   115  func (p *plugin) kill(wg *sync.WaitGroup) error {
   116  	defer wg.Done()
   117  	if p.gRPCConn != nil && p.ReporterClient != nil {
   118  		return p.killGrpcProcess()
   119  	}
   120  	if isProcessRunning(p) {
   121  		defer p.connection.Close()
   122  		p.killTimer = time.NewTimer(config.PluginKillTimeout())
   123  		err := conn.SendProcessKillMessage(p.connection)
   124  		if err != nil {
   125  			logger.Warningf(true, "Error while killing plugin %s : %s ", p.descriptor.Name, err.Error())
   126  		}
   127  
   128  		exited := make(chan bool, 1)
   129  		go func() {
   130  			for {
   131  				if isProcessRunning(p) {
   132  					time.Sleep(100 * time.Millisecond)
   133  				} else {
   134  					exited <- true
   135  					return
   136  				}
   137  			}
   138  		}()
   139  		select {
   140  		case <-exited:
   141  			if !p.killTimer.Stop() {
   142  				<-p.killTimer.C
   143  			}
   144  			logger.Debugf(true, "Plugin [%s] with pid [%d] has exited", p.descriptor.Name, p.pluginCmd.Process.Pid)
   145  		case <-p.killTimer.C:
   146  			logger.Warningf(true, "Plugin [%s] with pid [%d] did not exit after %.2f seconds. Forcefully killing it.", p.descriptor.Name, p.pluginCmd.Process.Pid, config.PluginKillTimeout().Seconds())
   147  			err := p.pluginCmd.Process.Kill()
   148  			if err != nil {
   149  				logger.Warningf(true, "Error while killing plugin %s : %s ", p.descriptor.Name, err.Error())
   150  			}
   151  			return err
   152  		}
   153  	}
   154  	return nil
   155  }
   156  
   157  // IsPluginInstalled checks if given plugin with specific version is installed or not.
   158  func IsPluginInstalled(pluginName, pluginVersion string) bool {
   159  	pluginsInstallDir, err := common.GetPluginsInstallDir(pluginName)
   160  	if err != nil {
   161  		return false
   162  	}
   163  
   164  	thisPluginDir := filepath.Join(pluginsInstallDir, pluginName)
   165  	if !common.DirExists(thisPluginDir) {
   166  		return false
   167  	}
   168  
   169  	if pluginVersion != "" {
   170  		return common.FileExists(filepath.Join(thisPluginDir, pluginVersion, common.PluginJSONFile))
   171  	}
   172  	return true
   173  }
   174  
   175  func getPluginJSONPath(pluginName, pluginVersion string) (string, error) {
   176  	if !IsPluginInstalled(pluginName, pluginVersion) {
   177  		plugin := strings.TrimSpace(fmt.Sprintf("%s %s", pluginName, pluginVersion))
   178  		return "", fmt.Errorf("Plugin %s is not installed", plugin)
   179  	}
   180  
   181  	pluginInstallDir, err := GetInstallDir(pluginName, "")
   182  	if err != nil {
   183  		return "", err
   184  	}
   185  	return filepath.Join(pluginInstallDir, common.PluginJSONFile), nil
   186  }
   187  
   188  // GetPluginDescriptor return the information about the plugin including name, id, commands to start etc.
   189  func GetPluginDescriptor(pluginID, pluginVersion string) (*PluginDescriptor, error) {
   190  	pluginJSON, err := getPluginJSONPath(pluginID, pluginVersion)
   191  	if err != nil {
   192  		return nil, err
   193  	}
   194  	return GetPluginDescriptorFromJSON(pluginJSON)
   195  }
   196  
   197  func GetPluginDescriptorFromJSON(pluginJSON string) (*PluginDescriptor, error) {
   198  	pluginJSONContents, err := common.ReadFileContents(pluginJSON)
   199  	if err != nil {
   200  		return nil, err
   201  	}
   202  	var pd PluginDescriptor
   203  	if err = json.Unmarshal([]byte(pluginJSONContents), &pd); err != nil {
   204  		return nil, fmt.Errorf("%s: %s", pluginJSON, err.Error())
   205  	}
   206  	pd.pluginPath = filepath.Dir(pluginJSON)
   207  
   208  	return &pd, nil
   209  }
   210  
   211  func startPlugin(pd *PluginDescriptor, action pluginScope) (*plugin, error) {
   212  	var command []string
   213  	switch runtime.GOOS {
   214  	case "windows":
   215  		command = pd.Command.Windows
   216  	case "darwin":
   217  		command = pd.Command.Darwin
   218  	default:
   219  		command = pd.Command.Linux
   220  	}
   221  	if len(command) == 0 {
   222  		return nil, fmt.Errorf("Platform specific command not specified: %s.", runtime.GOOS)
   223  	}
   224  	if pd.hasCapability(gRPCSupportCapability) {
   225  		return startGRPCPlugin(pd, command)
   226  	}
   227  	return startLegacyPlugin(pd, command)
   228  }
   229  
   230  func startGRPCPlugin(pd *PluginDescriptor, command []string) (*plugin, error) {
   231  	portChan := make(chan string)
   232  	writer := &logger.LogWriter{
   233  		Stderr: logger.NewCustomWriter(portChan, os.Stderr, pd.ID, true),
   234  		Stdout: logger.NewCustomWriter(portChan, os.Stdout, pd.ID, false),
   235  	}
   236  	cmd, err := common.ExecuteCommand(command, pd.pluginPath, writer.Stdout, writer.Stderr)
   237  	go func() {
   238  		err = cmd.Wait()
   239  		if err != nil {
   240  			logger.Errorf(true, "Error occurred while waiting for plugin process to finish.\nError : %s", err.Error())
   241  		}
   242  	}()
   243  	if err != nil {
   244  		return nil, err
   245  	}
   246  
   247  	var port string
   248  	select {
   249  	case port = <-portChan:
   250  		close(portChan)
   251  	case <-time.After(config.PluginConnectionTimeout()):
   252  		return nil, fmt.Errorf("timed out connecting to %s", pd.ID)
   253  	}
   254  	logger.Debugf(true, "Attempting to connect to grpc server at port: %s", port)
   255  	gRPCConn, err := grpc.NewClient(fmt.Sprintf("%s:%s", "127.0.0.1", port),
   256  		grpc.WithTransportCredentials(insecure.NewCredentials()),
   257  		grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(1024*1024*1024), grpc.MaxCallRecvMsgSize(1024*1024*1024)))
   258  	if err != nil {
   259  		return nil, err
   260  	}
   261  	plugin := &plugin{
   262  		pluginCmd:  cmd,
   263  		descriptor: pd,
   264  		gRPCConn:   gRPCConn,
   265  		mutex:      &sync.Mutex{},
   266  	}
   267  	if pd.hasScope(docScope) {
   268  		plugin.DocumenterClient = gauge_messages.NewDocumenterClient(gRPCConn)
   269  	} else {
   270  		plugin.ReporterClient = gauge_messages.NewReporterClient(gRPCConn)
   271  	}
   272  
   273  	logger.Debugf(true, "Successfully made the connection with plugin with port: %s", port)
   274  	return plugin, nil
   275  }
   276  
   277  func startLegacyPlugin(pd *PluginDescriptor, command []string) (*plugin, error) {
   278  	writer := logger.NewLogWriter(pd.ID, true, 0)
   279  	cmd, err := common.ExecuteCommand(command, pd.pluginPath, writer.Stdout, writer.Stderr)
   280  
   281  	if err != nil {
   282  		return nil, err
   283  	}
   284  	var mutex = &sync.Mutex{}
   285  	go func() {
   286  		pState, _ := cmd.Process.Wait()
   287  		mutex.Lock()
   288  		cmd.ProcessState = pState
   289  		mutex.Unlock()
   290  	}()
   291  	plugin := &plugin{pluginCmd: cmd, descriptor: pd, mutex: mutex}
   292  	return plugin, nil
   293  }
   294  
   295  func SetEnvForPlugin(action pluginScope, pd *PluginDescriptor, m *manifest.Manifest, pluginEnvVars map[string]string) error {
   296  	pluginEnvVars[fmt.Sprintf("%s_action", pd.ID)] = string(action)
   297  	pluginEnvVars["test_language"] = m.Language
   298  	return setEnvironmentProperties(pluginEnvVars)
   299  }
   300  
   301  func setEnvironmentProperties(properties map[string]string) error {
   302  	for k, v := range properties {
   303  		if err := common.SetEnvVariable(k, v); err != nil {
   304  			return err
   305  		}
   306  	}
   307  	return nil
   308  }
   309  
   310  func IsPluginAdded(m *manifest.Manifest, descriptor *PluginDescriptor) bool {
   311  	for _, pluginID := range m.Plugins {
   312  		if pluginID == descriptor.ID {
   313  			return true
   314  		}
   315  	}
   316  	return false
   317  }
   318  
   319  func startPluginsForExecution(m *manifest.Manifest) (Handler, []string) {
   320  	var warnings []string
   321  	handler := &GaugePlugins{}
   322  	envProperties := make(map[string]string)
   323  
   324  	for _, pluginID := range m.Plugins {
   325  		pd, err := GetPluginDescriptor(pluginID, "")
   326  		if err != nil {
   327  			warnings = append(warnings, fmt.Sprintf("Unable to start plugin %s. %s. To install, run `gauge install %s`.", pluginID, err.Error(), pluginID))
   328  			continue
   329  		}
   330  		compatibilityErr := version.CheckCompatibility(version.CurrentGaugeVersion, &pd.GaugeVersionSupport)
   331  		if compatibilityErr != nil {
   332  			warnings = append(warnings, fmt.Sprintf("Compatible %s plugin version to current Gauge version %s not found", pd.Name, version.CurrentGaugeVersion))
   333  			continue
   334  		}
   335  		if pd.hasScope(executionScope) {
   336  			gaugeConnectionHandler, err := conn.NewGaugeConnectionHandler(0, nil)
   337  			if err != nil {
   338  				warnings = append(warnings, err.Error())
   339  				continue
   340  			}
   341  			envProperties[pluginConnectionPortEnv] = strconv.Itoa(gaugeConnectionHandler.ConnectionPortNumber())
   342  			prop, err := common.GetGaugeConfigurationFor(common.GaugePropertiesFile)
   343  			if err != nil {
   344  				warnings = append(warnings, fmt.Sprintf("Unable to read Gauge configuration. %s", err.Error()))
   345  				continue
   346  			}
   347  			envProperties["plugin_kill_timeout"] = prop["plugin_kill_timeout"]
   348  			err = SetEnvForPlugin(executionScope, pd, m, envProperties)
   349  			if err != nil {
   350  				warnings = append(warnings, fmt.Sprintf("Error setting environment for plugin %s %s. %s", pd.Name, pd.Version, err.Error()))
   351  				continue
   352  			}
   353  			logger.Debugf(true, "Starting %s plugin", pd.Name)
   354  			plugin, err := startPlugin(pd, executionScope)
   355  			if err != nil {
   356  				warnings = append(warnings, fmt.Sprintf("Error starting plugin %s %s. %s", pd.Name, pd.Version, err.Error()))
   357  				continue
   358  			}
   359  			if plugin.gRPCConn != nil {
   360  				handler.addPlugin(pluginID, plugin)
   361  				continue
   362  			}
   363  			pluginConnection, err := gaugeConnectionHandler.AcceptConnection(config.PluginConnectionTimeout(), make(chan error))
   364  			if err != nil {
   365  				warnings = append(warnings, fmt.Sprintf("Error starting plugin %s %s. Failed to connect to plugin. %s", pd.Name, pd.Version, err.Error()))
   366  				err := plugin.pluginCmd.Process.Kill()
   367  				if err != nil {
   368  					logger.Errorf(false, "unable to kill plugin %s: %s", plugin.descriptor.Name, err.Error())
   369  				}
   370  				continue
   371  			}
   372  			logger.Debugf(true, "Established connection to %s plugin", pd.Name)
   373  			plugin.connection = pluginConnection
   374  			handler.addPlugin(pluginID, plugin)
   375  		}
   376  
   377  	}
   378  	return handler, warnings
   379  }
   380  
   381  func GenerateDoc(pluginName string, specDirs []string, startAPIFunc func([]string) int) {
   382  	pd, err := GetPluginDescriptor(pluginName, "")
   383  	if err != nil {
   384  		logger.Fatalf(true, "Error starting plugin %s. Failed to get plugin.json. %s. To install, run `gauge install %s`.", pluginName, err.Error(), pluginName)
   385  	}
   386  	if err := version.CheckCompatibility(version.CurrentGaugeVersion, &pd.GaugeVersionSupport); err != nil {
   387  		logger.Fatalf(true, "Compatible %s plugin version to current Gauge version %s not found", pd.Name, version.CurrentGaugeVersion)
   388  	}
   389  	if !pd.hasScope(docScope) {
   390  		logger.Fatalf(true, "Invalid plugin name: %s, this plugin cannot generate documentation.", pd.Name)
   391  	}
   392  	var sources []string
   393  	for _, src := range specDirs {
   394  		path, _ := filepath.Abs(src)
   395  		sources = append(sources, path)
   396  	}
   397  	os.Setenv("GAUGE_SPEC_DIRS", strings.Join(sources, "||"))
   398  	os.Setenv("GAUGE_PROJECT_ROOT", config.ProjectRoot)
   399  	if pd.hasCapability(gRPCSupportCapability) {
   400  		p, err := startPlugin(pd, docScope)
   401  		if err != nil {
   402  			logger.Fatalf(true, " %s %s. %s", pd.Name, pd.Version, err.Error())
   403  		}
   404  		_, err = p.DocumenterClient.GenerateDocs(context.Background(), getSpecDetails(specDirs))
   405  		grpcErr := p.killGrpcProcess()
   406  		if grpcErr != nil {
   407  			logger.Errorf(false, "Unable to kill plugin %s : %s", p.descriptor.Name, grpcErr.Error())
   408  		}
   409  		if err != nil {
   410  			logger.Fatalf(true, "Failed to generate docs. %s", err.Error())
   411  		}
   412  	} else {
   413  		port := startAPIFunc(specDirs)
   414  		err := os.Setenv(common.APIPortEnvVariableName, strconv.Itoa(port))
   415  		if err != nil {
   416  			logger.Fatalf(true, "Failed to set env GAUGE_API_PORT. %s", err.Error())
   417  		}
   418  		p, err := startPlugin(pd, docScope)
   419  		if err != nil {
   420  			logger.Fatalf(true, " %s %s. %s", pd.Name, pd.Version, err.Error())
   421  		}
   422  		for isProcessRunning(p) {
   423  		}
   424  	}
   425  }
   426  
   427  func (p *plugin) invokeService(m *gauge_messages.Message) error {
   428  	ctx := context.Background()
   429  	var err error
   430  	switch m.GetMessageType() {
   431  	case gauge_messages.Message_SuiteExecutionResult:
   432  		_, err = p.ReporterClient.NotifySuiteResult(ctx, m.GetSuiteExecutionResult())
   433  	case gauge_messages.Message_ExecutionStarting:
   434  		_, err = p.ReporterClient.NotifyExecutionStarting(ctx, m.GetExecutionStartingRequest())
   435  	case gauge_messages.Message_ExecutionEnding:
   436  		_, err = p.ReporterClient.NotifyExecutionEnding(ctx, m.GetExecutionEndingRequest())
   437  	case gauge_messages.Message_SpecExecutionEnding:
   438  		_, err = p.ReporterClient.NotifySpecExecutionEnding(ctx, m.GetSpecExecutionEndingRequest())
   439  	case gauge_messages.Message_SpecExecutionStarting:
   440  		_, err = p.ReporterClient.NotifySpecExecutionStarting(ctx, m.GetSpecExecutionStartingRequest())
   441  	case gauge_messages.Message_ScenarioExecutionEnding:
   442  		_, err = p.ReporterClient.NotifyScenarioExecutionEnding(ctx, m.GetScenarioExecutionEndingRequest())
   443  	case gauge_messages.Message_ScenarioExecutionStarting:
   444  		_, err = p.ReporterClient.NotifyScenarioExecutionStarting(ctx, m.GetScenarioExecutionStartingRequest())
   445  	case gauge_messages.Message_StepExecutionEnding:
   446  		_, err = p.ReporterClient.NotifyStepExecutionEnding(ctx, m.GetStepExecutionEndingRequest())
   447  	case gauge_messages.Message_StepExecutionStarting:
   448  		_, err = p.ReporterClient.NotifyStepExecutionStarting(ctx, m.GetStepExecutionStartingRequest())
   449  	case gauge_messages.Message_ConceptExecutionEnding:
   450  		_, err = p.ReporterClient.NotifyConceptExecutionEnding(ctx, m.GetConceptExecutionEndingRequest())
   451  	case gauge_messages.Message_ConceptExecutionStarting:
   452  		_, err = p.ReporterClient.NotifyConceptExecutionStarting(ctx, m.GetConceptExecutionStartingRequest())
   453  	}
   454  	return err
   455  }
   456  
   457  func (p *plugin) sendMessage(message *gauge_messages.Message) error {
   458  	if p.gRPCConn != nil {
   459  		return p.invokeService(message)
   460  	}
   461  	messageID := common.GetUniqueID()
   462  	message.MessageId = messageID
   463  	messageBytes, err := proto.Marshal(message)
   464  	if err != nil {
   465  		return err
   466  	}
   467  	err = conn.Write(p.connection, messageBytes)
   468  	if err != nil {
   469  		return fmt.Errorf("[Warning] Failed to send message to plugin: %s  %s", p.descriptor.ID, err.Error())
   470  	}
   471  	return nil
   472  }
   473  
   474  func StartPlugins(m *manifest.Manifest) Handler {
   475  	pluginHandler, warnings := startPluginsForExecution(m)
   476  	logger.HandleWarningMessages(true, warnings)
   477  	return pluginHandler
   478  }
   479  
   480  func PluginsWithoutScope() (infos []pluginInfo.PluginInfo) {
   481  	if plugins, err := pluginInfo.GetAllInstalledPluginsWithVersion(); err == nil {
   482  		for _, p := range plugins {
   483  			pd, err := GetPluginDescriptor(p.Name, p.Version.String())
   484  			if err == nil && !pd.hasAnyScope() {
   485  				infos = append(infos, p)
   486  			}
   487  		}
   488  	}
   489  	return
   490  }
   491  
   492  // GetInstallDir returns the install directory of given plugin and a given version.
   493  func GetInstallDir(pluginName, v string) (string, error) {
   494  	allPluginsInstallDir, err := common.GetPluginsInstallDir(pluginName)
   495  	if err != nil {
   496  		return "", err
   497  	}
   498  	pluginDir := filepath.Join(allPluginsInstallDir, pluginName)
   499  	if v != "" {
   500  		pluginDir = filepath.Join(pluginDir, v)
   501  	} else {
   502  		latestPlugin, err := pluginInfo.GetLatestInstalledPlugin(pluginDir)
   503  		if err != nil {
   504  			return "", err
   505  		}
   506  		pluginDir = latestPlugin.Path
   507  	}
   508  	return pluginDir, nil
   509  }
   510  
   511  func GetLanguageJSONFilePath(language string) (string, error) {
   512  	languageInstallDir, err := GetInstallDir(language, "")
   513  	if err != nil {
   514  		return "", err
   515  	}
   516  	languageJSON := filepath.Join(languageInstallDir, fmt.Sprintf("%s.json", language))
   517  	if !common.FileExists(languageJSON) {
   518  		return "", fmt.Errorf("Failed to find the implementation for: %s. %s does not exist.", language, languageJSON)
   519  	}
   520  
   521  	return languageJSON, nil
   522  }
   523  
   524  func IsLanguagePlugin(plugin string) bool {
   525  	if _, err := GetLanguageJSONFilePath(plugin); err != nil {
   526  		return false
   527  	}
   528  	return true
   529  }
   530  
   531  func QueryParams() string {
   532  	return fmt.Sprintf("?l=%s&p=%s&o=%s&a=%s", language(), plugins(), runtime.GOOS, runtime.GOARCH)
   533  }
   534  
   535  func language() string {
   536  	if config.ProjectRoot == "" {
   537  		return ""
   538  	}
   539  	m, err := manifest.ProjectManifest()
   540  	if err != nil {
   541  		return ""
   542  	}
   543  	return m.Language
   544  }
   545  
   546  func plugins() string {
   547  	pluginInfos, err := pluginInfo.GetAllInstalledPluginsWithVersion()
   548  	if err != nil {
   549  		return ""
   550  	}
   551  	var plugins []string
   552  	for _, p := range pluginInfos {
   553  		plugins = append(plugins, p.Name)
   554  	}
   555  	return strings.Join(plugins, ",")
   556  }
   557  
   558  func getSpecDetails(specDirs []string) *gauge_messages.SpecDetails {
   559  	sig := &infoGatherer.SpecInfoGatherer{SpecDirs: specDirs}
   560  	sig.Init()
   561  	specDetails := make([]*gauge_messages.SpecDetails_SpecDetail, 0)
   562  	for _, d := range sig.GetAvailableSpecDetails(specDirs) {
   563  		detail := &gauge_messages.SpecDetails_SpecDetail{}
   564  		if d.HasSpec() {
   565  			detail.Spec = gauge.ConvertToProtoSpec(d.Spec)
   566  		}
   567  		for _, e := range d.Errs {
   568  			detail.ParseErrors = append(detail.ParseErrors, &gauge_messages.Error{Type: gauge_messages.Error_PARSE_ERROR, Filename: e.FileName, Message: e.Message, LineNumber: int32(e.LineNo)})
   569  		}
   570  		specDetails = append(specDetails, detail)
   571  	}
   572  	return &gauge_messages.SpecDetails{
   573  		Details: specDetails,
   574  	}
   575  }