diff --git a/pkg/autodetect/autodetect.go b/pkg/autodetect/autodetect.go index 038fd86528c..a37ee460100 100644 --- a/pkg/autodetect/autodetect.go +++ b/pkg/autodetect/autodetect.go @@ -42,6 +42,7 @@ func populateConfig(ctx context.Context, path string, config *devconfig.Config) func detectors(path string) []detector.Detector { return []detector.Detector{ &detector.GoDetector{Root: path}, + &detector.NodeJSDetector{Root: path}, &detector.PHPDetector{Root: path}, &detector.PoetryDetector{Root: path}, &detector.PythonDetector{Root: path}, diff --git a/pkg/autodetect/detector/nodejs.go b/pkg/autodetect/detector/nodejs.go new file mode 100644 index 00000000000..785f6fa2cfa --- /dev/null +++ b/pkg/autodetect/detector/nodejs.go @@ -0,0 +1,71 @@ +package detector + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" +) + +type packageJSON struct { + Engines struct { + Node string `json:"node"` + } `json:"engines"` +} + +type NodeJSDetector struct { + Root string + packageJSON *packageJSON +} + +var _ Detector = &NodeJSDetector{} + +func (d *NodeJSDetector) Init() error { + pkgJSON, err := loadPackageJSON(d.Root) + if err != nil && !os.IsNotExist(err) { + return err + } + d.packageJSON = pkgJSON + return nil +} + +func (d *NodeJSDetector) Relevance(path string) (float64, error) { + if d.packageJSON == nil { + return 0, nil + } + return 1, nil +} + +func (d *NodeJSDetector) Packages(ctx context.Context) ([]string, error) { + return []string{"nodejs@" + d.nodeVersion(ctx)}, nil +} + +func (d *NodeJSDetector) nodeVersion(ctx context.Context) string { + if d.packageJSON == nil || d.packageJSON.Engines.Node == "" { + return "latest" // Default to latest if not specified + } + + // Remove any non-semver characters (e.g. ">=", "^", etc) + version := "latest" + semverRegex := regexp.MustCompile(`\d+(\.\d+)?(\.\d+)?`) + if match := semverRegex.FindString(d.packageJSON.Engines.Node); match != "" { + version = match + } + + return determineBestVersion(ctx, "nodejs", version) +} + +func loadPackageJSON(root string) (*packageJSON, error) { + path := filepath.Join(root, "package.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, err + } + return &pkg, nil +} diff --git a/pkg/autodetect/detector/nodejs_test.go b/pkg/autodetect/detector/nodejs_test.go new file mode 100644 index 00000000000..9d63e2e75d6 --- /dev/null +++ b/pkg/autodetect/detector/nodejs_test.go @@ -0,0 +1,98 @@ +package detector + +import ( + "context" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNodeJSDetector_Relevance(t *testing.T) { + tests := []struct { + name string + fs fstest.MapFS + expected float64 + expectedPackages []string + }{ + { + name: "package.json in root", + fs: fstest.MapFS{ + "package.json": &fstest.MapFile{ + Data: []byte(`{}`), + }, + }, + expected: 1, + expectedPackages: []string{"nodejs@latest"}, + }, + { + name: "package.json with node version", + fs: fstest.MapFS{ + "package.json": &fstest.MapFile{ + Data: []byte(`{ + "engines": { + "node": ">=18.0.0" + } + }`), + }, + }, + expected: 1, + expectedPackages: []string{"nodejs@18.0.0"}, + }, + { + name: "no nodejs files", + fs: fstest.MapFS{ + "main.py": &fstest.MapFile{ + Data: []byte(``), + }, + "requirements.txt": &fstest.MapFile{ + Data: []byte(``), + }, + }, + expected: 0, + expectedPackages: []string{}, + }, + { + name: "empty directory", + fs: fstest.MapFS{}, + expected: 0, + expectedPackages: []string{}, + }, + } + + for _, curTest := range tests { + t.Run(curTest.name, func(t *testing.T) { + dir := t.TempDir() + for name, file := range curTest.fs { + fullPath := filepath.Join(dir, name) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(fullPath, file.Data, 0o644) + require.NoError(t, err) + } + + d := &NodeJSDetector{Root: dir} + err := d.Init() + require.NoError(t, err) + + score, err := d.Relevance(dir) + require.NoError(t, err) + assert.Equal(t, curTest.expected, score) + if score > 0 { + packages, err := d.Packages(context.Background()) + require.NoError(t, err) + assert.Equal(t, curTest.expectedPackages, packages) + } + }) + } +} + +func TestNodeJSDetector_Packages(t *testing.T) { + d := &NodeJSDetector{} + packages, err := d.Packages(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{"nodejs@latest"}, packages) +} diff --git a/pkg/autodetect/detector/php_test.go b/pkg/autodetect/detector/php_test.go index 1adafd3893d..c6a979de63e 100644 --- a/pkg/autodetect/detector/php_test.go +++ b/pkg/autodetect/detector/php_test.go @@ -13,14 +13,16 @@ import ( func TestPHPDetector_Relevance(t *testing.T) { tests := []struct { - name string - fs fstest.MapFS - expected float64 + name string + fs fstest.MapFS + expected float64 + expectedPackages []string }{ { - name: "no composer.json", - fs: fstest.MapFS{}, - expected: 0, + name: "no composer.json", + fs: fstest.MapFS{}, + expected: 0, + expectedPackages: nil, }, { name: "with composer.json", @@ -33,7 +35,8 @@ func TestPHPDetector_Relevance(t *testing.T) { }`), }, }, - expected: 1, + expected: 1, + expectedPackages: []string{"php@8.1"}, }, } @@ -52,16 +55,23 @@ func TestPHPDetector_Relevance(t *testing.T) { score, err := d.Relevance(dir) require.NoError(t, err) assert.Equal(t, curTest.expected, score) + + if score > 0 { + packages, err := d.Packages(context.Background()) + require.NoError(t, err) + assert.Equal(t, curTest.expectedPackages, packages) + } }) } } func TestPHPDetector_Packages(t *testing.T) { tests := []struct { - name string - fs fstest.MapFS - expectedPHP string - expectedError bool + name string + fs fstest.MapFS + expectedPHP string + expectedError bool + expectedPackages []string }{ { name: "no php version specified", @@ -72,7 +82,8 @@ func TestPHPDetector_Packages(t *testing.T) { }`), }, }, - expectedPHP: "php@latest", + expectedPHP: "php@latest", + expectedPackages: []string{"php@latest"}, }, { name: "specific php version", @@ -85,7 +96,8 @@ func TestPHPDetector_Packages(t *testing.T) { }`), }, }, - expectedPHP: "php@8.1", + expectedPHP: "php@8.1", + expectedPackages: []string{"php@8.1"}, }, { name: "php version with patch", @@ -98,7 +110,8 @@ func TestPHPDetector_Packages(t *testing.T) { }`), }, }, - expectedPHP: "php@8.1.2", + expectedPHP: "php@8.1.2", + expectedPackages: []string{"php@8.1.2"}, }, { name: "invalid composer.json", @@ -107,7 +120,8 @@ func TestPHPDetector_Packages(t *testing.T) { Data: []byte(`invalid json`), }, }, - expectedError: true, + expectedError: true, + expectedPackages: nil, }, } @@ -129,7 +143,7 @@ func TestPHPDetector_Packages(t *testing.T) { packages, err := d.Packages(context.Background()) require.NoError(t, err) - assert.Equal(t, []string{curTest.expectedPHP}, packages) + assert.Equal(t, curTest.expectedPackages, packages) }) } } @@ -139,6 +153,7 @@ func TestPHPDetector_PHPExtensions(t *testing.T) { name string fs fstest.MapFS expectedExtensions []string + expectedPackages []string }{ { name: "no extensions", @@ -152,6 +167,7 @@ func TestPHPDetector_PHPExtensions(t *testing.T) { }, }, expectedExtensions: []string{}, + expectedPackages: []string{"php@8.1"}, }, { name: "multiple extensions", @@ -170,6 +186,11 @@ func TestPHPDetector_PHPExtensions(t *testing.T) { "php81Extensions.mbstring@latest", "php81Extensions.imagick@latest", }, + expectedPackages: []string{ + "php@8.1", + "php81Extensions.mbstring@latest", + "php81Extensions.imagick@latest", + }, }, } @@ -188,6 +209,10 @@ func TestPHPDetector_PHPExtensions(t *testing.T) { extensions, err := d.phpExtensions(context.Background()) require.NoError(t, err) assert.ElementsMatch(t, curTest.expectedExtensions, extensions) + + packages, err := d.Packages(context.Background()) + require.NoError(t, err) + assert.ElementsMatch(t, curTest.expectedPackages, packages) }) } }