Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cmd/platform/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ func NewRunCommand(clients *shared.ClientFactory) *cobra.Command {
{Command: "platform run --cleanup", Meaning: "Run a local development server with cleanup"},
}),
PreRunE: func(cmd *cobra.Command, args []string) error {
// Verify command is run in a project directory
return cmdutil.IsValidProjectDirectory(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func NewClient(
os types.Os,
) *Client {
return &Client{
Manifest: NewManifestClient(apiClient, config),
Manifest: NewManifestClient(apiClient, config, fs),
AppClientInterface: NewAppClient(config, fs, os),
}
}
Expand Down
35 changes: 33 additions & 2 deletions internal/app/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,23 @@ package app
import (
"context"
"encoding/json"
"path/filepath"
"strings"

"github.com/slackapi/slack-cli/internal/api"
"github.com/slackapi/slack-cli/internal/config"
"github.com/slackapi/slack-cli/internal/hooks"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
)

const manifestFileName = "manifest.json"

// ManifestClient can manage the state of the project's app manifest file
type ManifestClient struct {
apiClient api.APIInterface
fs afero.Fs
domainAuthTokens string
Env map[string]string
}
Expand Down Expand Up @@ -59,17 +64,44 @@ func SetManifestEnvTeamVars(manifestEnv map[string]string, appTeamDomain string,
func NewManifestClient(
apiClient api.APIInterface,
config *config.Config,
fs afero.Fs,
) *ManifestClient {
client := &ManifestClient{
apiClient: apiClient,
fs: fs,
domainAuthTokens: config.DomainAuthTokens,
Env: config.ManifestEnv,
}
return client
}

// GetManifestLocal gathers manifest content from the "get-manifest" hook
// GetManifestLocal reads the local manifest, preferring a static manifest.json
// file in the project root. Falls back to the "get-manifest" hook when no file exists.
func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
manifestPath := filepath.Join(sdkConfig.WorkingDirectory, manifestFileName)
if exists, _ := afero.Exists(c.fs, manifestPath); exists {
return c.readManifestFile(manifestPath)
}
return c.getManifestFromHook(ctx, sdkConfig, hookExecutor)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔭 thought: We should reverse this order to check if a get-manifest hook exists and fallback to reading "manifest.json" file. Deprecating the hook might happen at the hook package while the CLI continues to support it.

}

func (c *ManifestClient) readManifestFile(path string) (types.SlackYaml, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func (c *ManifestClient) readManifestFile(path string) (types.SlackYaml, error) {
func (c *ManifestClient) getManifestFromFile(sdkConfig hooks.SDKCLIConfig) (types.SlackYaml, error) {

🌵 thought: I think we should match the function name convention here and also keep path specific logic contained within this function. I'm less confident of the second thought FWIW!

var sl types.SlackYaml
data, err := afero.ReadFile(c.fs, path)
if err != nil {
return sl, slackerror.New("Failed to read manifest file").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
}
if err := json.Unmarshal(data, &sl); err != nil {
return sl, slackerror.New("Failed to parse manifest file").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
}
return sl, nil
}

func (c *ManifestClient) getManifestFromHook(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
var sl types.SlackYaml

if !sdkConfig.Hooks.GetManifest.IsAvailable() {
Expand Down Expand Up @@ -104,7 +136,6 @@ func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.S
if start != -1 {
slackManifestInfo = slackManifestInfo[start:]
} else {
// the app manifest has to be a json so needs to have the character `{`
return sl, slackerror.New("Invalid app manifest format, must be valid JSON").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
Expand Down
147 changes: 92 additions & 55 deletions internal/app/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/slackapi/slack-cli/internal/slackcontext"
"github.com/slackapi/slack-cli/internal/slackdeps"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -68,77 +69,113 @@ func Test_AppManifest_SetManifestEnvTeamVars(t *testing.T) {
}

func Test_AppManifest_GetManifestLocal(t *testing.T) {
tests := map[string]struct {
mockManifestInfo string
mockManifestErr error
expectedErr error
expectedManifest types.SlackYaml
t.Run("reads manifest.json directly when it exists", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: "echo manifest"}

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`{"display_information":{"name":"file-app"}}`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.NoError(t, err)
assert.Equal(t, "file-app", result.DisplayInformation.Name)
mockHookExecutor.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything)
})

t.Run("errors if no manifest.json and no get-manifest hook exists", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

_, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.Error(t, err)
assert.Equal(t, slackerror.ErrSDKHookNotFound, err.(*slackerror.Error).Code)
})

t.Run("errors if manifest.json contains invalid JSON", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`not json`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

_, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.Error(t, err)
assert.Equal(t, slackerror.ErrInvalidManifest, err.(*slackerror.Error).Code)
})

hookFallbackTests := map[string]struct {
hookOutput string
hookErr error
expectedName string
expectedErr string
}{
"errors if no get-manifest hook exists": {
expectedErr: slackerror.New(slackerror.ErrSDKHookNotFound),
},
"returns an existing manifest without errors": {
mockManifestInfo: `{"display_information":{"name":"my-example-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-example-app",
},
},
},
"falls back to hook when no manifest.json exists": {
hookOutput: `{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"errors if the hook execution errors": {
mockManifestInfo: `{}`,
mockManifestErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"parses hook output with leading characters": {
hookOutput: `...{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"parses a manifest with random leading characters": {
mockManifestInfo: `...{"display_information":{"name":"my-showcased-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-showcased-app",
},
},
},
"errors if hook execution errors": {
hookOutput: `{}`,
hookErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.ErrInvalidManifest,
},
"errors if a manifest is not present in output": {
mockManifestInfo: `...unknown`,
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"errors if hook output has no JSON": {
hookOutput: `...unknown`,
expectedErr: slackerror.ErrInvalidManifest,
},
}
for name, tc := range tests {
for name, tc := range hookFallbackTests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
mockManifestEnv := map[string]string{"EXAMPLE": "12"}
mockSDKConfig := hooks.NewSDKConfigMock()
mockHookExecutor := &hooks.MockHookExecutor{}
if tc.mockManifestInfo != "" {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{
Name: "GetManifest",
Command: "cat manifest.json",
}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.mockManifestInfo, tc.mockManifestErr)
} else {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}
}
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
configMock.DomainAuthTokens = "api.slack.com"
configMock.ManifestEnv = mockManifestEnv
manifestClient := NewManifestClient(&api.APIMock{}, configMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: "generate-manifest"}

mockHookExecutor := &hooks.MockHookExecutor{}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.hookOutput, tc.hookErr)

manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

actualManifest, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
if tc.expectedErr != nil {
result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
if tc.expectedErr != "" {
require.Error(t, err)
assert.Equal(t,
tc.expectedErr.(*slackerror.Error).Code, err.(*slackerror.Error).Code)
assert.Equal(t, tc.expectedErr, err.(*slackerror.Error).Code)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedManifest, actualManifest)
assert.Equal(t, tc.expectedName, result.DisplayInformation.Name)
}
})
}
Expand Down Expand Up @@ -186,7 +223,7 @@ func Test_AppManifest_GetManifestRemote(t *testing.T) {
apic := &api.APIMock{}
apic.On("ExportAppManifest", mock.Anything, mock.Anything, mock.Anything).
Return(api.ExportAppResult{Manifest: tc.mockManifestResponse}, tc.mockManifestError)
manifestClient := NewManifestClient(apic, configMock)
manifestClient := NewManifestClient(apic, configMock, fsMock)

manifest, err := manifestClient.GetManifestRemote(ctx, tc.mockToken, tc.mockAppID)
if tc.expectedError != nil {
Expand Down
89 changes: 41 additions & 48 deletions internal/manifest/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,52 +173,45 @@ func Test_Sync(t *testing.T) {
assert.Equal(t, slackerror.ErrAppManifestUpdate, slackErr.Code)
})

t.Run("force flag merges all local and pushes to API", func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceFlag = true
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
f.clientsMock.API.AssertCalled(t, "UpdateApp", mock.Anything, "xoxb-test", "A123", mock.Anything, true, true)
})

t.Run("force-remote flag merges all remote and pushes to API", func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceRemoteFlag = true
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
// Verify remote value was used — the merged manifest should have "Remote" description
assert.Equal(t, "Remote", result.Merged.DisplayInformation.Description)
})
mergeStrategyTests := map[string]struct {
forceFlag bool
forceRemoteFlag bool
expectedDesc string
}{
"force flag merges all local": {
forceFlag: true,
expectedDesc: "Local",
},
"force-remote flag merges all remote": {
forceRemoteFlag: true,
expectedDesc: "Remote",
},
}
for name, tc := range mergeStrategyTests {
t.Run(name, func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceFlag = tc.forceFlag
f.clients.Config.ForceRemoteFlag = tc.forceRemoteFlag
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
assert.Equal(t, tc.expectedDesc, result.Merged.DisplayInformation.Description)
})
}

t.Run("API UpdateApp failure is propagated", func(t *testing.T) {
f := newSyncTestFixture(t)
Expand Down Expand Up @@ -279,7 +272,7 @@ func Test_Sync(t *testing.T) {
assert.Contains(t, err.Error(), "cache")
})

t.Run("missing manifest.json still succeeds with warning", func(t *testing.T) {
t.Run("missing manifest.json creates the file", func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Expand All @@ -297,7 +290,7 @@ func Test_Sync(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.False(t, result.WriteBack.Written)
assert.True(t, result.WriteBack.Written)
})

t.Run("TTY interactive resolution with all-local strategy", func(t *testing.T) {
Expand Down
Loading
Loading