pulumi/sdk/nodejs/cmd/pulumi-language-nodejs/main_test.go

910 lines
27 KiB
Go

// Copyright 2016-2018, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"github.com/blang/semver"
"github.com/pulumi/pulumi/sdk/v3/nodejs/npm"
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestArgumentConstruction(t *testing.T) {
t.Parallel()
info := &pulumirpc.ProgramInfo{
RootDirectory: "/foo/bar",
ProgramDirectory: "/foo/bar",
EntryPoint: ".",
}
t.Run("DryRun-NoArguments", func(t *testing.T) {
t.Parallel()
host := &nodeLanguageHost{}
rr := &pulumirpc.RunRequest{DryRun: true, Info: info}
args := host.constructArguments(rr, "", "", "")
assert.Contains(t, args, "--dry-run")
assert.NotContains(t, args, "true")
})
t.Run("OptionalArgs-PassedIfSpecified", func(t *testing.T) {
t.Parallel()
host := &nodeLanguageHost{}
rr := &pulumirpc.RunRequest{Project: "foo", Info: info}
args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
assert.Contains(t, args, "--project foo")
})
t.Run("OptionalArgs-NotPassedIfNotSpecified", func(t *testing.T) {
t.Parallel()
host := &nodeLanguageHost{}
rr := &pulumirpc.RunRequest{Info: info}
args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
assert.NotContains(t, args, "--stack")
})
t.Run("DotIfProgramNotSpecified", func(t *testing.T) {
t.Parallel()
host := &nodeLanguageHost{}
rr := &pulumirpc.RunRequest{Info: info}
args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
assert.Contains(t, args, ".")
})
t.Run("ProgramIfProgramSpecified", func(t *testing.T) {
t.Parallel()
host := &nodeLanguageHost{}
rr := &pulumirpc.RunRequest{
Program: "foobar",
Info: &pulumirpc.ProgramInfo{
RootDirectory: "/foo/bar",
ProgramDirectory: "/foo/bar",
EntryPoint: "foobar",
},
}
args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
assert.Contains(t, args, "foobar")
})
}
func TestConfig(t *testing.T) {
t.Parallel()
t.Run("Config-Empty", func(t *testing.T) {
t.Parallel()
host := &nodeLanguageHost{}
rr := &pulumirpc.RunRequest{Project: "foo"}
str, err := host.constructConfig(rr)
assert.NoError(t, err)
assert.JSONEq(t, "{}", str)
})
}
func TestCompatibleVersions(t *testing.T) {
t.Parallel()
cases := []struct {
a string
b string
compatible bool
errmsg string
}{
{"0.17.1", "0.16.2", false, "Differing major or minor versions are not supported."},
{"0.17.1", "1.0.0", true, ""},
{"1.0.0", "0.17.1", true, ""},
{"1.13.0", "1.13.0", true, ""},
{"1.1.1", "1.13.0", true, ""},
{"1.13.0", "1.1.1", true, ""},
{"1.1.0", "2.1.0", true, ""},
{"2.1.0", "1.1.0", true, ""},
{"1.1.0", "2.0.0-beta1", true, ""},
{"2.0.0-beta1", "1.1.0", true, ""},
{"2.1.0", "3.1.0", false, "Differing major versions are not supported."},
{"0.16.1", "1.0.0", false, "Differing major or minor versions are not supported."},
}
for _, c := range cases {
compatible, errmsg := compatibleVersions(semver.MustParse(c.a), semver.MustParse(c.b))
assert.Equal(t, c.errmsg, errmsg)
assert.Equal(t, c.compatible, compatible)
}
}
func TestGetRequiredPlugins(t *testing.T) {
t.Parallel()
dir := t.TempDir()
files := []struct {
path string
content string
}{
{
filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "node_modules", "@pulumi", "baz", "package.json"),
`{ "name": "@pulumi/baz", "version": "4.5.6", "pulumi": { "resource": false } }`,
},
{
filepath.Join(dir, "node_modules", "malformed", "tests", "malformed_test", "package.json"),
`{`,
},
}
for _, file := range files {
err := os.MkdirAll(filepath.Dir(file.path), 0o755)
require.NoError(t, err)
err = os.WriteFile(file.path, []byte(file.content), 0o600)
require.NoError(t, err)
}
host := &nodeLanguageHost{}
resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
Program: dir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: dir,
ProgramDirectory: dir,
EntryPoint: ".",
},
})
require.NoError(t, err)
actual := make(map[string]string)
for _, plugin := range resp.GetPlugins() {
actual[plugin.Name] = plugin.Version
}
assert.Equal(t, map[string]string{
"foo": "v1.2.3",
"bar": "v4.5.6",
}, actual)
}
func TestGetRequiredPluginsSymlinkCycles(t *testing.T) {
t.Parallel()
dir := t.TempDir()
files := []struct {
path string
content string
}{
{
filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "node_modules", "@pulumi", "baz", "package.json"),
`{ "name": "@pulumi/baz", "version": "4.5.6", "pulumi": { "resource": false } }`,
},
{
filepath.Join(dir, "node_modules", "malformed", "tests", "malformed_test", "package.json"),
`{`,
},
}
for _, file := range files {
err := os.MkdirAll(filepath.Dir(file.path), 0o755)
require.NoError(t, err)
err = os.WriteFile(file.path, []byte(file.content), 0o600)
require.NoError(t, err)
}
// Add a symlink cycle in
err := os.Symlink(filepath.Join(dir, "node_modules"), filepath.Join(dir, "node_modules", "@node_modules"))
require.NoError(t, err)
host := &nodeLanguageHost{}
resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
Program: dir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: dir,
ProgramDirectory: dir,
EntryPoint: ".",
},
})
require.NoError(t, err)
actual := make(map[string]string)
for _, plugin := range resp.GetPlugins() {
actual[plugin.Name] = plugin.Version
}
assert.Equal(t, map[string]string{
"foo": "v1.2.3",
"bar": "v4.5.6",
}, actual)
}
func TestGetRequiredPluginsSymlinkCycles2(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "testdir")
err := os.Mkdir(dir, 0o755)
require.NoError(t, err)
files := []struct {
path string
content string
}{
{
filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "node_modules", "@pulumi", "baz", "package.json"),
`{ "name": "@pulumi/baz", "version": "4.5.6", "pulumi": { "resource": false } }`,
},
{
filepath.Join(dir, "node_modules", "malformed", "tests", "malformed_test", "package.json"),
`{`,
},
}
for _, file := range files {
err := os.MkdirAll(filepath.Dir(file.path), 0o755)
require.NoError(t, err)
err = os.WriteFile(file.path, []byte(file.content), 0o600)
require.NoError(t, err)
}
// Add a symlink cycle in
err = os.Symlink(filepath.Join("..", ".."), filepath.Join(dir, "node_modules", "@node_modules"))
require.NoError(t, err)
host := &nodeLanguageHost{}
resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
Program: dir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: dir,
ProgramDirectory: dir,
EntryPoint: ".",
},
})
require.NoError(t, err)
actual := make(map[string]string)
for _, plugin := range resp.GetPlugins() {
actual[plugin.Name] = plugin.Version
}
assert.Equal(t, map[string]string{
"foo": "v1.2.3",
"bar": "v4.5.6",
}, actual)
}
func TestGetRequiredPluginsNestedPolicyPack(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "testdir")
err := os.Mkdir(dir, 0o755)
require.NoError(t, err)
files := []struct {
path string
content string
}{
{
filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
},
{
filepath.Join(dir, "policy", "PulumiPolicy.yaml"),
`name: my-policy`,
},
{
filepath.Join(dir, "policy", "node_modules", "@pulumi", "baz", "package.json"),
`{ "name": "@pulumi/baz", "version": "7.8.9", "pulumi": { "resource": true } }`,
},
}
for _, file := range files {
err := os.MkdirAll(filepath.Dir(file.path), 0o755)
require.NoError(t, err)
err = os.WriteFile(file.path, []byte(file.content), 0o600)
require.NoError(t, err)
}
host := &nodeLanguageHost{}
resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
Program: dir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: dir,
ProgramDirectory: dir,
EntryPoint: ".",
},
})
require.NoError(t, err)
actual := make(map[string]string)
for _, plugin := range resp.GetPlugins() {
actual[plugin.Name] = plugin.Version
}
assert.Equal(t, map[string]string{
"foo": "v1.2.3",
"bar": "v4.5.6",
// baz: v7.8.9 is not included because it is in a nested policy pack
}, actual)
}
type filePathAndContents struct {
path string
content string
}
func setupFiles(t *testing.T, files []filePathAndContents) string {
dir := filepath.Join(t.TempDir(), "program-dependency-testdir")
err := os.Mkdir(dir, 0o755)
require.NoError(t, err)
for _, file := range files {
file.path = filepath.Join(dir, file.path)
err := os.MkdirAll(filepath.Dir(file.path), 0o755)
require.NoError(t, err)
err = os.WriteFile(file.path, []byte(file.content), 0o600)
require.NoError(t, err)
}
return dir
}
func TestGetProgramDependencies(t *testing.T) {
t.Parallel()
t.Run("With no package.json, no lock files", func(t *testing.T) {
t.Parallel()
testDir := setupFiles(t, []filePathAndContents{
{
path: "Pulumi.yaml",
content: `name: test`,
},
})
host := &nodeLanguageHost{}
_, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
Program: testDir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: testDir,
ProgramDirectory: testDir,
EntryPoint: ".",
},
})
require.ErrorContains(t, err, "no package-lock.json or yarn.lock file found (searching upwards from")
})
t.Run("With package.json in project root, no lock files", func(t *testing.T) {
t.Parallel()
testDir := setupFiles(t, []filePathAndContents{
{
path: "package.json",
content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
},
{
path: "Pulumi.yaml",
content: `name: test`,
},
})
host := &nodeLanguageHost{}
_, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
Program: testDir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: testDir,
ProgramDirectory: testDir,
EntryPoint: ".",
},
})
require.ErrorContains(t, err, "no package-lock.json or yarn.lock file found (searching upwards from")
})
t.Run("With package.json and yarn.lock in project root", func(t *testing.T) {
t.Parallel()
testDir := setupFiles(t, []filePathAndContents{
{
path: "package.json",
content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
},
{
path: "Pulumi.yaml",
content: `name: test`,
},
{
path: "yarn.lock",
content: `"@pulumi/pulumi@^3.0.0", "@pulumi/pulumi@^3.113.0":
version "3.131.0"
resolved "https://registry.yarnpkg.com/@pulumi/pulumi/-/pulumi-3.131.0.tgz#6233e5ee5e72907b99415b32be6a9ebf9041f096"
integrity sha512-QNtQeav3dkU0mRdMe2TVvkBmIGkBevVvbD7/bt0fJlGoX/onzv5tysqi1GWCkXsq0FKtBtGYNpVD6wH0cqMN6g==
dependencies:
"@grpc/grpc-js" "^1.10.1"
"@logdna/tail-file" "^2.0.6"
"@npmcli/arborist" "^7.3.1"`,
},
})
host := &nodeLanguageHost{}
resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
Program: testDir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: testDir,
ProgramDirectory: testDir,
EntryPoint: ".",
},
})
require.NoError(t, err)
require.Equal(t, len(resp.Dependencies), 1)
require.Equal(t, resp.Dependencies[0].Name, "@pulumi/pulumi")
require.Equal(t, resp.Dependencies[0].Version, "3.131.0")
})
t.Run("With package.json and yarn.lock in parent dir", func(t *testing.T) {
t.Parallel()
testDir := setupFiles(t, []filePathAndContents{
{
path: "package.json",
content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
},
{
path: filepath.Join("subdir", "Pulumi.yaml"),
content: `name: test`,
},
{
path: "yarn.lock",
content: `"@pulumi/pulumi@^3.0.0", "@pulumi/pulumi@^3.113.0":
version "3.131.0"
resolved "https://registry.yarnpkg.com/@pulumi/pulumi/-/pulumi-3.131.0.tgz#6233e5ee5e72907b99415b32be6a9ebf9041f096"
integrity sha512-QNtQeav3dkU0mRdMe2TVvkBmIGkBevVvbD7/bt0fJlGoX/onzv5tysqi1GWCkXsq0FKtBtGYNpVD6wH0cqMN6g==
dependencies:
"@grpc/grpc-js" "^1.10.1"
"@logdna/tail-file" "^2.0.6"
"@npmcli/arborist" "^7.3.1"`,
},
})
subdir := filepath.Join(testDir, "subdir")
host := &nodeLanguageHost{}
resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
Program: subdir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: subdir,
ProgramDirectory: subdir,
EntryPoint: ".",
},
})
require.NoError(t, err)
require.Equal(t, len(resp.Dependencies), 1)
require.Equal(t, resp.Dependencies[0].Name, "@pulumi/pulumi")
require.Equal(t, resp.Dependencies[0].Version, "3.131.0")
})
t.Run("With package.json and yarn.lock in project root", func(t *testing.T) {
t.Parallel()
testDir := setupFiles(t, []filePathAndContents{
{
path: "package.json",
content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
},
{
path: "Pulumi.yaml",
content: `name: test`,
},
{
path: "yarn.lock",
content: `"@pulumi/pulumi@^3.0.0", "@pulumi/pulumi@^3.113.0":
version "3.131.0"
resolved "https://registry.yarnpkg.com/@pulumi/pulumi/-/pulumi-3.131.0.tgz#6233e5ee5e72907b99415b32be6a9ebf9041f096"
integrity sha512-QNtQeav3dkU0mRdMe2TVvkBmIGkBevVvbD7/bt0fJlGoX/onzv5tysqi1GWCkXsq0FKtBtGYNpVD6wH0cqMN6g==
dependencies:
"@grpc/grpc-js" "^1.10.1"
"@logdna/tail-file" "^2.0.6"
"@npmcli/arborist" "^7.3.1"`,
},
})
host := &nodeLanguageHost{}
resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
Program: testDir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: testDir,
ProgramDirectory: testDir,
EntryPoint: ".",
},
})
require.NoError(t, err)
require.Equal(t, len(resp.Dependencies), 1)
require.Equal(t, resp.Dependencies[0].Name, "@pulumi/pulumi")
require.Equal(t, resp.Dependencies[0].Version, "3.131.0")
})
t.Run("With package.json and package-lock.json in project root", func(t *testing.T) {
t.Parallel()
testDir := setupFiles(t, []filePathAndContents{
{
path: "package.json",
content: `{ "dependencies": { "random": "^5.1.0" } }`,
},
{
path: "Pulumi.yaml",
content: `name: test`,
},
{
path: "package-lock.json",
content: `{
"name": "pulumi-repos",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"random": "^5.1.0"
}
},
"node_modules/random": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/random/-/random-5.1.0.tgz",
"integrity": "sha512-0NGG4HMW9sTstLbignEDasSQJlCGkNQZICIWStZ+h4SzSJfZXpecGKV7qL0AOKcIT8XX9pJ49uZnvI0n/Y+vWA==",
"engines": {
"node": ">=18"
}
}
}
}`,
},
{
path: filepath.Join("node_modules", "random", "package.json"),
content: `{ "name": "random", "version": "5.1.0", "type": "module" }`,
},
})
host := &nodeLanguageHost{}
resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
Program: testDir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: testDir,
ProgramDirectory: testDir,
EntryPoint: ".",
},
})
require.NoError(t, err)
require.Equal(t, 1, len(resp.Dependencies))
require.Equal(t, "random", resp.Dependencies[0].Name)
require.Equal(t, "5.1.0", resp.Dependencies[0].Version)
})
t.Run("With package.json and package-lock.json in parent dir", func(t *testing.T) {
t.Parallel()
testDir := setupFiles(t, []filePathAndContents{
{
path: "package.json",
content: `{ "dependencies": { "random": "^5.1.0" } }`,
},
{
path: filepath.Join("subdir", "Pulumi.yaml"),
content: `name: test`,
},
{
path: "package-lock.json",
content: `{
"name": "pulumi-repos",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"random": "^5.1.0"
}
},
"node_modules/random": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/random/-/random-5.1.0.tgz",
"integrity": "sha512-0NGG4HMW9sTstLbignEDasSQJlCGkNQZICIWStZ+h4SzSJfZXpecGKV7qL0AOKcIT8XX9pJ49uZnvI0n/Y+vWA==",
"engines": {
"node": ">=18"
}
}
}
}`,
},
{
path: filepath.Join("node_modules", "random", "package.json"),
content: `{ "name": "random", "version": "5.1.0", "type": "module" }`,
},
})
subdir := filepath.Join(testDir, "subdir")
host := &nodeLanguageHost{}
resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
Program: subdir,
Info: &pulumirpc.ProgramInfo{
RootDirectory: subdir,
ProgramDirectory: subdir,
EntryPoint: ".",
},
})
require.NoError(t, err)
require.Equal(t, 1, len(resp.Dependencies))
require.Equal(t, "random", resp.Dependencies[0].Name)
require.Equal(t, "5.1.0", resp.Dependencies[0].Version)
})
}
func TestParseOptions(t *testing.T) {
t.Parallel()
opts, err := parseOptions(nil)
require.NoError(t, err)
require.Equal(t, npm.AutoPackageManager, opts.packagemanager)
_, err = parseOptions(map[string]interface{}{
"typescript": 123,
})
require.ErrorContains(t, err, "typescript option must be a boolean")
_, err = parseOptions(map[string]interface{}{
"packagemanager": "poetry",
})
require.ErrorContains(t, err, "packagemanager option must be one of")
for _, tt := range []struct {
input string
expected npm.PackageManagerType
}{
{"auto", npm.AutoPackageManager},
{"npm", npm.NpmPackageManager},
{"yarn", npm.YarnPackageManager},
{"pnpm", npm.PnpmPackageManager},
} {
opts, err = parseOptions(map[string]interface{}{
"packagemanager": tt.input,
})
require.NoError(t, err)
require.Equal(t, tt.expected, opts.packagemanager)
}
}
// Nodejs sometimes sets stdout/stderr to non-blocking mode. When a nodejs subprocess is directly
// handed the go process's stdout/stderr file descriptors, nodejs's non-blocking configuration goes
// unnoticed by go, and a write from go can result in an error `write /dev/stdout: resource
// temporarily unavailable`. See runWithOutput for more details.
func TestNonblockingStdout(t *testing.T) {
// Regression test for https://github.com/pulumi/pulumi/issues/16503
t.Parallel()
script := `import os, time
os.set_blocking(1, False) # set stdout to non-blocking
time.sleep(3)
`
// Create a named pipe to use as stdout
tmp := os.TempDir()
p := filepath.Join(tmp, "fake-stdout")
err := syscall.Mkfifo(p, 0o644)
defer os.Remove(p)
require.NoError(t, err)
// Open fd without O_NONBLOCK, ensuring that os.NewFile does not return a pollable file.
// When our python script changes the file to non-blocking, Go does not notice and continues to
// expect the file to be blocking, and we can trigger the bug.
fd, err := syscall.Open(p, syscall.O_CREAT|syscall.O_RDWR, 0o644)
require.NoError(t, err)
fakeStdout := os.NewFile(uintptr(fd), p)
defer fakeStdout.Close()
require.NotNil(t, fakeStdout)
cmd := exec.Command("python3", "-c", script)
var done bool
go func() {
time.Sleep(2 * time.Second)
for !done {
s := "....................\n"
n, err := fakeStdout.Write([]byte(s))
require.NoError(t, err)
require.Equal(t, n, len(s))
}
}()
require.NoError(t, runWithOutput(cmd, fakeStdout, os.Stderr))
done = true
}
type slowWriter struct {
nWrites *int
}
func (s slowWriter) Write(b []byte) (int, error) {
time.Sleep(100 * time.Millisecond)
l := len(b)
*s.nWrites += l
return l, nil
}
func TestRunWithOutputDoesNotMissData(t *testing.T) {
// This test ensures that runWithOutput writes all the data from the command and does not miss
// any data that might be buffered when the command exits.
t.Parallel()
// Write `o` to stdout 100 times at 10 ms interval, followed by `x\n`
// Write `e` to stderr 100 times at 10 ms interval, followed by `x\n`
script := `let i = 0;
let interval = setInterval(() => {
process.stdout.write("o");
process.stderr.write("e");
i++;
if (i == 100) {
process.stdout.write("x\n");
process.stderr.write("x\n");
clearInterval(interval);
}
}, 10)
`
cmd := exec.Command("node", "-e", script)
stdout := slowWriter{nWrites: new(int)}
stderr := slowWriter{nWrites: new(int)}
require.NoError(t, runWithOutput(cmd, stdout, stderr))
require.Equal(t, 100+2 /* "x\n" */, *stdout.nWrites)
require.Equal(t, 100+2 /* "x\n" */, *stderr.nWrites)
}
//nolint:paralleltest // mutates environment variables
func TestUseFnm(t *testing.T) {
// Set $PATH to to $TMPDIR/bin so that no `fnm` executable can be found.
tmpDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "bin"), 0o755))
t.Setenv("PATH", filepath.Join(tmpDir, "bin"))
_, err := useFnm(tmpDir)
require.ErrorIs(t, err, errFnmNotFound)
// Add a fake fnm binary to $TMPDIR/bin for the rest of the tests.
//nolint:gosec // we want this file to be executable
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "bin", "fnm"), []byte("#!/bin/sh\nexit 0;\n"), 0o700))
t.Run("no version files", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
_, err := useFnm(tmpDir)
require.ErrorIs(t, err, errVersionFileNotFound)
})
t.Run(".node-version in cwd", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("22.7.3"), 0o600))
version, err := useFnm(tmpDir)
require.NoError(t, err)
require.Equal(t, "22.7.3", version)
})
t.Run(".nvmrc in cwd", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".nvmrc"), []byte("20.1.1"), 0o600))
version, err := useFnm(tmpDir)
require.NoError(t, err)
require.Equal(t, "20.1.1", version)
})
t.Run(".nvmrc & .node-version in cwd", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
// .nvmrc should take precedence
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".nvmrc"), []byte("20.1.1"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("22.7.3"), 0o600))
version, err := useFnm(tmpDir)
require.NoError(t, err)
require.Equal(t, "20.1.1", version)
})
t.Run(".node-version in parent folder", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpDirNested := filepath.Join(tmpDir, "nested")
require.NoError(t, os.MkdirAll(tmpDirNested, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("20.1.1"), 0o600))
version, err := useFnm(tmpDirNested)
require.NoError(t, err)
require.Equal(t, "20.1.1", version)
})
t.Run(".node-version in cwd & parent", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpDirNested := filepath.Join(tmpDir, "nested")
require.NoError(t, os.MkdirAll(tmpDirNested, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("20.1.1"), 0o600))
// This should take precedence over the parent folder's .node-version
require.NoError(t, os.WriteFile(filepath.Join(tmpDirNested, ".node-version"), []byte("20.7.3"), 0o600))
version, err := useFnm(tmpDirNested)
require.NoError(t, err)
require.Equal(t, "20.7.3", version)
})
}
//nolint:paralleltest // mutates environment variables
func TestNodeInstall(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip()
}
tmpDir := t.TempDir()
t.Setenv("PATH", filepath.Join(tmpDir, "bin"))
fmt.Println(os.Getenv("PATH"))
// There's no fnm executable in PATH, installNodeVersion is a no-op
stdout := &bytes.Buffer{}
err := installNodeVersion(tmpDir, stdout)
require.ErrorIs(t, err, errFnmNotFound)
// Add a mock fnm executable to $tmp/bin. For each execution, the mock fnm executable will
// append a line with its arguments to a file. We read back the file to verify that it was
// called with the expected arguments.
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "bin"), 0o755))
outPath := filepath.Join(tmpDir, "out.txt")
script := fmt.Sprintf("#!/bin/sh\necho $@ >> %s\n", outPath)
//nolint:gosec // we want this file to be executable
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "bin", "fnm"), []byte(script), 0o700))
// There's no .node-version or .nvmrc file, so the binary should not be called.
// We expect the file written by our mock fnm executable to not exist.
stdout = &bytes.Buffer{}
err = installNodeVersion(tmpDir, stdout)
require.Error(t, err, errVersionFileNotFound)
// Create a .node-version file
// The mock fnm executable should be called with a command to install the requested version,
// and a command to set the default version to this version.
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("20.1.2"), 0o600))
stdout = &bytes.Buffer{}
err = installNodeVersion(tmpDir, stdout)
require.NoError(t, err)
b, err := os.ReadFile(outPath)
require.NoError(t, err)
commands := strings.Split(strings.TrimSpace(string(b)), "\n")
require.Equal(t, "install 20.1.2 --progress never", commands[0])
require.Equal(t, "alias 20.1.2 default", commands[1])
}