diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index c4574e845..5192bba09 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -515,16 +515,20 @@ func reportErr(err error, _ bool, _ io.Writer) error { // emitUpResult writes the JSON result envelope for a completed `up` invocation. func emitUpResult(wctx *workspaceContext, ideURL string, out io.Writer) { containerID := config2.GetContainerID(wctx.result) + workdir := wctx.workdir var warnings []string recovery := false if wctx.result != nil { warnings = wctx.result.HostWarnings recovery = wctx.result.RecoveryContainer + if workdir == "" && wctx.result.SubstitutionContext != nil { + workdir = wctx.result.SubstitutionContext.ContainerWorkspaceFolder + } } _ = config2.WriteResultJSON(out, config2.ResultEnvelope{ ContainerID: containerID, RemoteUser: wctx.user, - RemoteWorkspaceFolder: wctx.workdir, + RemoteWorkspaceFolder: workdir, URL: ideURL, Warnings: warnings, Recovery: recovery, diff --git a/cmd/workspace/up/up_workdir_test.go b/cmd/workspace/up/up_workdir_test.go new file mode 100644 index 000000000..302b80c9d --- /dev/null +++ b/cmd/workspace/up/up_workdir_test.go @@ -0,0 +1,130 @@ +package up + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type workspaceFolderCase struct { + name string + resolvedFolder string + configFolder string + subpath string + override string + noMergedConfig bool + wantWorkdir string + want string +} + +func TestUpResultWorkspaceFolder(t *testing.T) { + const ( + defaultFolder = "/workspaces/project" + customFolder = "/src/project" + overrideFolder = "/override/project" + gitSubpath = "app" + literalFolder = "/src/$(printf marker)" + ) + tests := []workspaceFolderCase{ + {name: "default folder", resolvedFolder: defaultFolder, want: defaultFolder}, + {name: "custom mount folder", resolvedFolder: customFolder, want: customFolder}, + {name: "literal mount folder", resolvedFolder: literalFolder, want: literalFolder}, + { + name: "no merged config", + resolvedFolder: defaultFolder, + noMergedConfig: true, + want: defaultFolder, + }, + { + name: "explicit folder", + resolvedFolder: defaultFolder, + configFolder: customFolder, + wantWorkdir: customFolder, want: customFolder, + }, + { + name: "git subpath", + resolvedFolder: defaultFolder, + subpath: gitSubpath, + wantWorkdir: defaultFolder + "/app", want: defaultFolder + "/app", + }, + { + name: "git subpath with explicit folder", + resolvedFolder: customFolder, + configFolder: customFolder, + subpath: gitSubpath, + wantWorkdir: customFolder + "/app", + want: customFolder + "/app", + }, + { + name: "CLI override", + resolvedFolder: defaultFolder, + override: overrideFolder, + wantWorkdir: overrideFolder, want: overrideFolder, + }, + { + name: "CLI override takes precedence", + resolvedFolder: customFolder, + configFolder: customFolder, + subpath: gitSubpath, + override: overrideFolder, + wantWorkdir: overrideFolder, want: overrideFolder, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertUpResultFolder(t, tt) + }) + } +} + +func assertUpResultFolder(t *testing.T, test workspaceFolderCase) { + t.Helper() + result := &devcconfig.Result{ + SubstitutionContext: &devcconfig.SubstitutionContext{ + ContainerWorkspaceFolder: test.resolvedFolder, + }, + } + if !test.noMergedConfig { + result.MergedConfig = &devcconfig.MergedDevContainerConfig{ + DevContainerConfigBase: devcconfig.DevContainerConfigBase{ + WorkspaceFolder: test.configFolder, + }, + } + } + workspace := &provider.Workspace{ + Source: provider.WorkspaceSource{GitSubPath: test.subpath}, + } + client := prepareWorkspaceClient{workspaceConfig: workspace} + cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} + cmd.WorkspaceFolder = test.override + workdir := cmd.resolveWorkdir(result, client) + assert.Equal(t, test.wantWorkdir, workdir, "folder used by SSH") + + var output bytes.Buffer + wctx := &workspaceContext{result: result, workdir: workdir} + emitUpResult(wctx, "", &output) + assert.Equal(t, test.wantWorkdir, wctx.workdir, "JSON emission must preserve SSH folder") + var envelope devcconfig.ResultEnvelope + require.NoError(t, json.Unmarshal(output.Bytes(), &envelope)) + assert.Equal(t, "success", envelope.Outcome) + assert.Equal(t, test.want, envelope.RemoteWorkspaceFolder) +} + +func TestEmitUpResultWithoutSubstitutionContext(t *testing.T) { + for _, result := range []*devcconfig.Result{nil, {}} { + for _, workdir := range []string{"", "/explicit"} { + var output bytes.Buffer + wctx := &workspaceContext{result: result, workdir: workdir} + emitUpResult(wctx, "", &output) + var envelope devcconfig.ResultEnvelope + require.NoError(t, json.Unmarshal(output.Bytes(), &envelope)) + assert.Equal(t, workdir, envelope.RemoteWorkspaceFolder) + } + } +} diff --git a/e2e/tests/up/up_behaviors.go b/e2e/tests/up/up_behaviors.go index 774955bfb..16cd4ae4f 100644 --- a/e2e/tests/up/up_behaviors.go +++ b/e2e/tests/up/up_behaviors.go @@ -160,6 +160,7 @@ var _ = ginkgo.Describe("up command behaviors", ginkgo.Label("up-behaviors"), fu framework.ExpectNoError(err) gomega.Expect(envelope.Outcome).To(gomega.Equal("success")) + gomega.Expect(envelope.RemoteWorkspaceFolder).NotTo(gomega.BeEmpty()) statusCount := 0 for _, line := range lines[:len(lines)-1] { var status struct { @@ -229,8 +230,7 @@ var _ = ginkgo.Describe("up command behaviors", ginkgo.Label("up-behaviors"), fu "expected exactly one success envelope in stdout, got %d", len(matched)) gomega.Expect(matched[0].ContainerID).NotTo(gomega.BeEmpty()) gomega.Expect(matched[0].RemoteUser).NotTo(gomega.BeEmpty()) - // RemoteWorkspaceFolder may legitimately be empty when the - // devcontainer.json doesn't set a workspaceFolder. + gomega.Expect(matched[0].RemoteWorkspaceFolder).NotTo(gomega.BeEmpty()) }, ginkgo.SpecTimeout(framework.TimeoutLong())) ginkgo.It("suppresses JSON envelope with --result-format plain", func(ctx context.Context) {