Skip to content

Commit a61b8bc

Browse files
authored
test: add golden file test for ConvertState (#20832)
Refactoring `ConvertState` is something we should eventually do. This PR adds a golden file unit test for the output of `ConvertState` (even errors). That way if a refactor occurs, we can verify the output is unchanged for our test cases.
1 parent e0a32e0 commit a61b8bc

File tree

57 files changed

+3160
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+3160
-0
lines changed

provisioner/terraform/resources_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ package terraform_test
22

33
import (
44
"context"
5+
"crypto/sha256"
56
"encoding/json"
67
"fmt"
78
"os"
89
"path/filepath"
910
"runtime"
11+
"slices"
1012
"sort"
1113
"strings"
1214
"testing"
1315

1416
"github.com/google/go-cmp/cmp"
17+
"github.com/google/uuid"
1518
tfjson "github.com/hashicorp/terraform-json"
1619
"github.com/stretchr/testify/require"
1720
protobuf "google.golang.org/protobuf/proto"
@@ -30,6 +33,115 @@ func ctxAndLogger(t *testing.T) (context.Context, slog.Logger) {
3033
return context.Background(), testutil.Logger(t)
3134
}
3235

36+
// TestConvertStateGolden compares the output of ConvertState to a golden
37+
// file to prevent regressions. If the logic changes, update the golden files
38+
// accordingly.
39+
//
40+
// This was created to aid in refactoring `ConvertState`.
41+
func TestConvertStateGolden(t *testing.T) {
42+
t.Parallel()
43+
44+
testResourceDirectories := filepath.Join("testdata", "resources")
45+
entries, err := os.ReadDir(testResourceDirectories)
46+
require.NoError(t, err)
47+
48+
for _, testDirectory := range entries {
49+
if !testDirectory.IsDir() {
50+
continue
51+
}
52+
53+
testFiles, err := os.ReadDir(filepath.Join(testResourceDirectories, testDirectory.Name()))
54+
require.NoError(t, err)
55+
56+
// ConvertState works on both a plan file and a state file.
57+
// The test should create a golden file for both.
58+
for _, step := range []string{"plan", "state"} {
59+
srcIdc := slices.IndexFunc(testFiles, func(entry os.DirEntry) bool {
60+
return strings.HasSuffix(entry.Name(), fmt.Sprintf(".tf%s.json", step))
61+
})
62+
dotIdx := slices.IndexFunc(testFiles, func(entry os.DirEntry) bool {
63+
return strings.HasSuffix(entry.Name(), fmt.Sprintf(".tf%s.dot", step))
64+
})
65+
66+
// If the directory is missing these files, we cannot run ConvertState
67+
// on it. So it's skipped.
68+
if srcIdc == -1 || dotIdx == -1 {
69+
continue
70+
}
71+
72+
t.Run(step+"_"+testDirectory.Name(), func(t *testing.T) {
73+
t.Parallel()
74+
testDirectoryPath := filepath.Join(testResourceDirectories, testDirectory.Name())
75+
planFile := filepath.Join(testDirectoryPath, testFiles[srcIdc].Name())
76+
dotFile := filepath.Join(testDirectoryPath, testFiles[dotIdx].Name())
77+
78+
ctx := testutil.Context(t, testutil.WaitMedium)
79+
logger := slogtest.Make(t, nil)
80+
81+
// Gather plan
82+
tfStepRaw, err := os.ReadFile(planFile)
83+
require.NoError(t, err)
84+
85+
var modules []*tfjson.StateModule
86+
switch step {
87+
case "plan":
88+
var tfPlan tfjson.Plan
89+
err = json.Unmarshal(tfStepRaw, &tfPlan)
90+
require.NoError(t, err)
91+
92+
modules = []*tfjson.StateModule{tfPlan.PlannedValues.RootModule}
93+
if tfPlan.PriorState != nil {
94+
modules = append(modules, tfPlan.PriorState.Values.RootModule)
95+
}
96+
case "state":
97+
var tfState tfjson.State
98+
err = json.Unmarshal(tfStepRaw, &tfState)
99+
require.NoError(t, err)
100+
modules = []*tfjson.StateModule{tfState.Values.RootModule}
101+
default:
102+
t.Fatalf("unknown step: %s", step)
103+
}
104+
105+
// Gather graph
106+
dotFileRaw, err := os.ReadFile(dotFile)
107+
require.NoError(t, err)
108+
109+
// expectedOutput is `any` to support errors too. If `ConvertState` returns an
110+
// error, that error is the golden file output.
111+
var expectedOutput any
112+
state, err := terraform.ConvertState(ctx, modules, string(dotFileRaw), logger)
113+
if err == nil {
114+
sortResources(state.Resources)
115+
sortExternalAuthProviders(state.ExternalAuthProviders)
116+
deterministicAppIDs(state.Resources)
117+
expectedOutput = state
118+
} else {
119+
// Write the error to the file then. Track errors as much as valid paths.
120+
expectedOutput = err.Error()
121+
}
122+
123+
expPath := filepath.Join(testDirectoryPath, fmt.Sprintf("converted_state.%s.golden", step))
124+
if *updateGoldenFiles {
125+
gotBytes, err := json.MarshalIndent(expectedOutput, "", " ")
126+
require.NoError(t, err, "marshaling converted state to JSON")
127+
// Newline at end of file for git purposes
128+
err = os.WriteFile(expPath, append(gotBytes, '\n'), 0o600)
129+
require.NoError(t, err)
130+
return
131+
}
132+
133+
gotBytes, err := json.Marshal(expectedOutput)
134+
require.NoError(t, err, "marshaling converted state to JSON")
135+
136+
expBytes, err := os.ReadFile(expPath)
137+
require.NoError(t, err)
138+
139+
require.JSONEq(t, string(expBytes), string(gotBytes), "converted state")
140+
})
141+
}
142+
}
143+
}
144+
33145
func TestConvertResources(t *testing.T) {
34146
t.Parallel()
35147
// nolint:dogsled
@@ -1669,3 +1781,18 @@ func sortExternalAuthProviders(providers []*proto.ExternalAuthProviderResource)
16691781
return strings.Compare(providers[i].Id, providers[j].Id) == -1
16701782
})
16711783
}
1784+
1785+
// deterministicAppIDs handles setting agent app ids to something deterministic.
1786+
// In plan files, ids are not present. In state files, they are.
1787+
// It is simpler for comparisons if we just set it to something deterministic.
1788+
func deterministicAppIDs(resources []*proto.Resource) {
1789+
for _, resource := range resources {
1790+
for _, agent := range resource.Agents {
1791+
for _, app := range agent.Apps {
1792+
data := sha256.Sum256([]byte(app.Slug + app.DisplayName))
1793+
id, _ := uuid.FromBytes(data[:16])
1794+
app.Id = id.String()
1795+
}
1796+
}
1797+
}
1798+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "a",
5+
"type": "coder_ai_task"
6+
}
7+
],
8+
"Parameters": [],
9+
"Presets": [],
10+
"ExternalAuthProviders": [],
11+
"AITasks": [
12+
{
13+
"sidebar_app": {
14+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
15+
},
16+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
17+
}
18+
],
19+
"HasAITasks": true,
20+
"HasExternalAgents": false
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "a",
5+
"type": "coder_ai_task"
6+
}
7+
],
8+
"Parameters": [],
9+
"Presets": [],
10+
"ExternalAuthProviders": [],
11+
"AITasks": [
12+
{
13+
"id": "c4f032b8-97e4-42b0-aa2f-30a9e698f8d4",
14+
"sidebar_app": {
15+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
16+
},
17+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
18+
}
19+
],
20+
"HasAITasks": true,
21+
"HasExternalAgents": false
22+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "a",
5+
"type": "coder_ai_task"
6+
},
7+
{
8+
"name": "b",
9+
"type": "coder_ai_task"
10+
}
11+
],
12+
"Parameters": [],
13+
"Presets": [],
14+
"ExternalAuthProviders": [],
15+
"AITasks": [
16+
{
17+
"sidebar_app": {
18+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
19+
},
20+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
21+
},
22+
{
23+
"sidebar_app": {
24+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
25+
},
26+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
27+
}
28+
],
29+
"HasAITasks": true,
30+
"HasExternalAgents": false
31+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "a",
5+
"type": "coder_ai_task"
6+
},
7+
{
8+
"name": "b",
9+
"type": "coder_ai_task"
10+
}
11+
],
12+
"Parameters": [],
13+
"Presets": [],
14+
"ExternalAuthProviders": [],
15+
"AITasks": [
16+
{
17+
"id": "89e6ab36-2e98-4d13-9b4c-69b7588b7e1d",
18+
"sidebar_app": {
19+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
20+
},
21+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
22+
},
23+
{
24+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd",
25+
"sidebar_app": {
26+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
27+
},
28+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
29+
}
30+
],
31+
"HasAITasks": true,
32+
"HasExternalAgents": false
33+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "a",
5+
"type": "coder_ai_task"
6+
}
7+
],
8+
"Parameters": [],
9+
"Presets": [],
10+
"ExternalAuthProviders": [],
11+
"AITasks": [
12+
{
13+
"sidebar_app": {
14+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
15+
},
16+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
17+
}
18+
],
19+
"HasAITasks": true,
20+
"HasExternalAgents": false
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "a",
5+
"type": "coder_ai_task"
6+
}
7+
],
8+
"Parameters": [],
9+
"Presets": [],
10+
"ExternalAuthProviders": [],
11+
"AITasks": [
12+
{
13+
"id": "89e6ab36-2e98-4d13-9b4c-69b7588b7e1d",
14+
"sidebar_app": {
15+
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
16+
},
17+
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
18+
}
19+
],
20+
"HasAITasks": true,
21+
"HasExternalAgents": false
22+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "example",
5+
"type": "null_resource",
6+
"agents": [
7+
{
8+
"name": "main",
9+
"operating_system": "linux",
10+
"architecture": "amd64",
11+
"Auth": {
12+
"Token": ""
13+
},
14+
"connection_timeout_seconds": 120,
15+
"display_apps": {
16+
"vscode": true,
17+
"web_terminal": true,
18+
"ssh_helper": true,
19+
"port_forwarding_helper": true
20+
},
21+
"resources_monitoring": {},
22+
"api_key_scope": "all"
23+
}
24+
],
25+
"module_path": "module.module"
26+
}
27+
],
28+
"Parameters": [],
29+
"Presets": [],
30+
"ExternalAuthProviders": [],
31+
"AITasks": [],
32+
"HasAITasks": false,
33+
"HasExternalAgents": false
34+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"Resources": [
3+
{
4+
"name": "example",
5+
"type": "null_resource",
6+
"agents": [
7+
{
8+
"id": "8cb7c83a-eddb-45e9-a78c-4b50d0f10e5e",
9+
"name": "main",
10+
"operating_system": "linux",
11+
"architecture": "amd64",
12+
"Auth": {
13+
"Token": "59bcf169-14fe-497d-9a97-709c1d837848"
14+
},
15+
"connection_timeout_seconds": 120,
16+
"display_apps": {
17+
"vscode": true,
18+
"web_terminal": true,
19+
"ssh_helper": true,
20+
"port_forwarding_helper": true
21+
},
22+
"resources_monitoring": {},
23+
"api_key_scope": "all"
24+
}
25+
],
26+
"module_path": "module.module"
27+
}
28+
],
29+
"Parameters": [],
30+
"Presets": [],
31+
"ExternalAuthProviders": [],
32+
"AITasks": [],
33+
"HasAITasks": false,
34+
"HasExternalAgents": false
35+
}

0 commit comments

Comments
 (0)