package gen import ( "bytes" "os" "path/filepath" "strings" "testing" "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" "github.com/hashicorp/hcl/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/pulumi/pulumi/pkg/v3/codegen" "github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/model/format" "github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/syntax" "github.com/pulumi/pulumi/pkg/v3/codegen/pcl" "github.com/pulumi/pulumi/pkg/v3/codegen/testing/test" "github.com/pulumi/pulumi/pkg/v3/codegen/testing/utils" ) var testdataPath = filepath.Join("..", "testing", "test", "testdata") func TestGenerateProgramVersionSelection(t *testing.T) { t.Parallel() expectedVersion := map[string]test.PkgVersionInfo{ "aws-resource-options-4.26": { Pkg: "github.com/pulumi/pulumi-aws/sdk/v4", OpAndVersion: "v4.26.0", }, "aws-resource-options-5.16.2": { Pkg: "github.com/pulumi/pulumi-aws/sdk/v5", OpAndVersion: "v5.16.2", }, "modpath": { Pkg: "git.example.org/thirdparty/sdk", OpAndVersion: "v0.1.0", }, } sdkPath, err := filepath.Abs(filepath.Join("..", "..", "..", "sdk")) require.NoError(t, err) test.TestProgramCodegen(t, test.ProgramCodegenOptions{ Language: "go", Extension: "go", OutputFile: "main.go", Check: func(t *testing.T, path string, dependencies codegen.StringSet) { Check(t, path, dependencies, sdkPath) }, GenProgram: func(program *pcl.Program) (map[string][]byte, hcl.Diagnostics, error) { // Prevent tests from interfering with each other return GenerateProgramWithOptions(program, GenerateProgramOptions{ExternalCache: NewCache()}) }, TestCases: []test.ProgramTest{ { Directory: "aws-resource-options-4.26", Description: "Resource Options", }, { Directory: "aws-resource-options-5.16.2", Description: "Resource Options", }, { Directory: "modpath", Description: "Check that modpath is respected", MockPluginVersions: map[string]string{ "other": "0.1.0", }, // We don't compile because the test relies on the `other` package, // which does not exist. SkipCompile: codegen.NewStringSet("go"), }, }, IsGenProject: true, GenProject: GenerateProject, ExpectedVersion: expectedVersion, DependencyFile: "go.mod", }) } func TestCollectImports(t *testing.T) { t.Parallel() g := newTestGenerator(t, filepath.Join("aws-s3-logging-pp", "aws-s3-logging.pp")) g.collectImports(g.program) var allImports []string for _, group := range g.importer.ImportGroups() { allImports = append(allImports, group...) } assert.Equal(t, []string{ `"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/s3"`, }, allImports) } func TestFileImporter(t *testing.T) { t.Parallel() // importCall is a single call to fileImporter.Import. type importCall struct { // Import path to import. importPath string // Name of the package at the import path. name string // Expected name used to refer to the imported package. want string } tests := []struct { desc string // List of Import method invocations made in-order. imports []importCall // List of import groups generated from the collective effect // of all import calls in this test case. wantGroups [][]string }{ {desc: "no imports"}, { desc: "single import/std", imports: []importCall{ {importPath: "fmt", name: "fmt", want: "fmt"}, }, wantGroups: [][]string{ {`"fmt"`}, }, }, { desc: "single import/pulumi", imports: []importCall{ {importPath: "github.com/pulumi/pulumi/sdk/v3/go/pulumi", name: "pulumi", want: "pulumi"}, }, wantGroups: [][]string{ {`"github.com/pulumi/pulumi/sdk/v3/go/pulumi"`}, }, }, { desc: "std and pulumi/no conflict", imports: []importCall{ {importPath: "fmt", name: "fmt", want: "fmt"}, {importPath: "github.com/pulumi/pulumi/sdk/v3/go/pulumi", name: "pulumi", want: "pulumi"}, }, wantGroups: [][]string{ {`"fmt"`}, {`"github.com/pulumi/pulumi/sdk/v3/go/pulumi"`}, }, }, { desc: "std and pulumi many imports, no conflict", imports: []importCall{ {importPath: "fmt", name: "fmt", want: "fmt"}, {importPath: "github.com/pulumi/pulumi/sdk/v3/go/pulumi", name: "pulumi", want: "pulumi"}, {importPath: "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config", name: "config", want: "config"}, {importPath: "encoding/json", name: "json", want: "json"}, {importPath: "github.com/pulumi/pulumi-aws/sdk/v5/go/aws/s3", name: "s3", want: "s3"}, {importPath: "io", name: "io", want: "io"}, {importPath: "github.com/pulumi/pulumi-awsx/sdk/v5/go/awsx", name: "awsx", want: "awsx"}, }, wantGroups: [][]string{ { `"encoding/json"`, `"fmt"`, `"io"`, }, { `"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/s3"`, `"github.com/pulumi/pulumi-awsx/sdk/v5/go/awsx"`, `"github.com/pulumi/pulumi/sdk/v3/go/pulumi"`, `"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"`, }, }, }, { desc: "std and pulumi/conflict", imports: []importCall{ {importPath: "encoding/json", name: "json", want: "json"}, { // This doesn't actually exist yet, // but it's conceivable that it might. importPath: "github.com/pulumi/pulumi-std/sdk/go/std/encoding/json", name: "json", want: "encodingjson", }, }, wantGroups: [][]string{ {`"encoding/json"`}, {`encodingjson "github.com/pulumi/pulumi-std/sdk/go/std/encoding/json"`}, }, }, { desc: "std and pulumi/conflict repeated", imports: []importCall{ {importPath: "encoding/json", name: "json", want: "json"}, { importPath: "github.com/pulumi/pulumi-std/sdk/go/std/encoding/json", name: "json", want: "encodingjson", }, {importPath: "encoding/json/v2", name: "json", want: "jsonv2"}, { importPath: "github.com/pulumi/pulumi-std/sdk/v2/go/std/encoding/json", name: "json", want: "json2", }, }, wantGroups: [][]string{ { `"encoding/json"`, `jsonv2 "encoding/json/v2"`, }, { `encodingjson "github.com/pulumi/pulumi-std/sdk/go/std/encoding/json"`, `json2 "github.com/pulumi/pulumi-std/sdk/v2/go/std/encoding/json"`, }, }, }, { desc: "std and pulumi/conflict reverse", imports: []importCall{ { // This doesn't actually exist yet, // but it's conceivable that it might. importPath: "github.com/pulumi/pulumi-std/sdk/go/std/encoding/json", name: "json", want: "json", }, {importPath: "encoding/json", name: "json", want: "json2"}, }, wantGroups: [][]string{ {`json2 "encoding/json"`}, {`"github.com/pulumi/pulumi-std/sdk/go/std/encoding/json"`}, }, }, { desc: "pulumi aws awsx conflict", imports: []importCall{ {importPath: "github.com/pulumi/pulumi-aws/sdk/v5/go/aws/ecs", name: "ecs", want: "ecs"}, {importPath: "github.com/pulumi/pulumi-awsx/sdk/go/awsx/ecs", name: "ecs", want: "awsxecs"}, }, wantGroups: [][]string{ { `"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/ecs"`, `awsxecs "github.com/pulumi/pulumi-awsx/sdk/go/awsx/ecs"`, }, }, }, { desc: "basename mismatch/std", imports: []importCall{ {importPath: "math/rand/v2", name: "rand", want: "rand"}, }, wantGroups: [][]string{ {`rand "math/rand/v2"`}, }, }, { desc: "basename mismatch/pulumi", imports: []importCall{ {importPath: "github.com/pulumi/pulumi-kubernetes/sdk/v3/go/kubernetes/core/v1", name: "corev1", want: "corev1"}, }, wantGroups: [][]string{ {`corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v3/go/kubernetes/core/v1"`}, }, }, { desc: "basename mismatch/third party", imports: []importCall{ {importPath: "gopkg.in/yaml.v3", name: "yaml", want: "yaml"}, }, wantGroups: [][]string{ {`yaml "gopkg.in/yaml.v3"`}, }, }, { desc: "already imported", imports: []importCall{ {importPath: "example.com/foo/bar", name: "bar", want: "bar"}, {importPath: "example.com/baz/bar", name: "bar", want: "bazbar"}, // Reimport should get existing resolved names. {importPath: "example.com/foo/bar", name: "bar", want: "bar"}, {importPath: "example.com/baz/bar", name: "bar", want: "bazbar"}, }, wantGroups: [][]string{ { // Note: // Imports are sorted by import path, // not the order they were imported. `bazbar "example.com/baz/bar"`, `"example.com/foo/bar"`, }, }, }, { desc: "many conflicts", imports: []importCall{ {importPath: "example.com/foo/bar", name: "bar", want: "bar"}, {importPath: "example.com/baz/bar", name: "bar", want: "bazbar"}, {importPath: "example.com/qux/bar", name: "bar", want: "quxbar"}, {importPath: "example.com/quux/bar", name: "bar", want: "quuxbar"}, }, wantGroups: [][]string{ { // Note: // Imports are sorted by import path, // not the order they were imported. `bazbar "example.com/baz/bar"`, `"example.com/foo/bar"`, `quuxbar "example.com/quux/bar"`, `quxbar "example.com/qux/bar"`, }, }, }, { desc: "conflict with special characters", imports: []importCall{ {importPath: "example.com/foo/bar-go", name: "bar", want: "bar"}, {importPath: "example.com/foo/bar.go", name: "bar", want: "foobargo"}, {importPath: "example.com/bar", name: "bar", want: "bar2"}, // nothing to join with {importPath: "example.com/f-o-o/bar", name: "bar", want: "foobar"}, }, wantGroups: [][]string{ { `bar2 "example.com/bar"`, `foobar "example.com/f-o-o/bar"`, `bar "example.com/foo/bar-go"`, `foobargo "example.com/foo/bar.go"`, }, }, }, } for _, tt := range tests { tt := tt t.Run(tt.desc, func(t *testing.T) { t.Parallel() fimp := newFileImporter() for _, imp := range tt.imports { gotName := fimp.Import(imp.importPath, imp.name) assert.Equal(t, imp.want, gotName, "Import(%q, %q)", imp.importPath, imp.name) } assert.Equal(t, tt.wantGroups, fimp.ImportGroups()) }) } } func TestFileImporter_Reset(t *testing.T) { t.Parallel() fimp := newFileImporter() assert.Empty(t, fimp.ImportGroups()) // Add imports. assert.Equal(t, "bar", fimp.Import("example.com/foo/bar", "bar")) assert.Equal(t, "bazbar", fimp.Import("example.com/baz/bar", "bar")) assert.NotEmpty(t, fimp.ImportGroups()) // sanity check // Reset and check that imports are gone. fimp.Reset() assert.Empty(t, fimp.ImportGroups()) // Import again in reverse order. // Prior state shouldn't affect result. assert.Equal(t, "bar", fimp.Import("example.com/baz/bar", "bar"), "should not get alias after reset") assert.Equal(t, "foobar", fimp.Import("example.com/foo/bar", "bar")) } func TestToIdentifier(t *testing.T) { t.Parallel() tests := []struct { give string want string ok bool }{ {"foo", "foo", true}, {"foo-bar", "foobar", true}, {"foo_bar", "foo_bar", true}, {"foo.bar", "foobar", true}, {"foo/123", "foo123", true}, {"foo.123/bar", "foo123bar", true}, {"123", "", false}, {"1/foo", "", false}, } for _, tt := range tests { tt := tt t.Run(tt.give, func(t *testing.T) { t.Parallel() got, ok := toIdentifier(tt.give) require.Equal(t, tt.ok, ok) if ok { assert.Equal(t, tt.want, got) } }) } } func TestSecondLastIndex(t *testing.T) { t.Parallel() tests := []struct { desc string haystack string needle string want int }{ { desc: "empty", haystack: "", needle: "foo", want: -1, }, { desc: "no match", haystack: "foo", needle: "bar", want: -1, }, { desc: "one match", haystack: "a/b", needle: "/", want: -1, }, { desc: "two matches", haystack: "a/b/c", needle: "/", want: 1, }, { desc: "three matches", haystack: "a/b/c/d", needle: "/", want: 3, }, } for _, tt := range tests { tt := tt t.Run(tt.desc, func(t *testing.T) { t.Parallel() got := secondLastIndex(tt.haystack, tt.needle) assert.Equal(t, tt.want, got) }) } } func newTestGenerator(t *testing.T, testFile string) *generator { path := filepath.Join(testdataPath, testFile) contents, err := os.ReadFile(path) require.NoErrorf(t, err, "could not read %v: %v", path, err) parser := syntax.NewParser() err = parser.ParseFile(bytes.NewReader(contents), filepath.Base(path)) if err != nil { t.Fatalf("could not read %v: %v", path, err) } if parser.Diagnostics.HasErrors() { t.Fatalf("failed to parse files: %v", parser.Diagnostics) } program, diags, err := pcl.BindProgram(parser.Files, pcl.PluginHost(utils.NewHost(testdataPath))) if err != nil { t.Fatalf("could not bind program: %v", err) } if diags.HasErrors() { t.Fatalf("failed to bind program: %v", diags) } g := &generator{ program: program, jsonTempSpiller: &jsonSpiller{}, ternaryTempSpiller: &tempSpiller{}, readDirTempSpiller: &readDirSpiller{}, splatSpiller: &splatSpiller{}, optionalSpiller: &optionalSpiller{}, inlineInvokeSpiller: &inlineInvokeSpiller{}, scopeTraversalRoots: codegen.NewStringSet(), arrayHelpers: make(map[string]*promptToInputArrayHelper), importer: newFileImporter(), } g.Formatter = format.NewFormatter(g) return g } func parseAndBindProgram(t *testing.T, text string, name string, options ...pcl.BindOption, ) (*pcl.Program, hcl.Diagnostics, error) { parser := syntax.NewParser() err := parser.ParseFile(strings.NewReader(text), name) if err != nil { t.Fatalf("could not read %v: %v", name, err) } if parser.Diagnostics.HasErrors() { t.Fatalf("failed to parse files: %v", parser.Diagnostics) } options = append(options, pcl.PluginHost(utils.NewHost(testdataPath))) return pcl.BindProgram(parser.Files, options...) } func TestGenerateProjectDoesNotPanicWhenMissingVersion(t *testing.T) { t.Parallel() source := ` resource main "auto-deploy:index:AutoDeployer" { project = "example" }` program, diags, err := parseAndBindProgram(t, source, "main.pp") require.NoError(t, err) require.False(t, diags.HasErrors()) files, diags, err := GenerateProjectFiles(workspace.Project{}, program) assert.NotNil(t, files, "Files were generated") require.NoError(t, err) require.False(t, diags.HasErrors()) }