-
Notifications
You must be signed in to change notification settings - Fork 46
feat: fall back to manifest.json when get-manifest hook is unavailable #629
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
07f7c15
159f342
4ed2068
48bf21a
b2d74ed
9a27637
da81af0
eacb9da
99bda78
3ea43ab
885dcec
a4e02ed
1721353
6ba97d4
3b0e5ca
d079dfc
5a0ec27
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -68,77 +69,128 @@ 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 | ||
| fallbackTests := map[string]struct { | ||
| hookCommand string | ||
| hookOutput string | ||
| manifestFile string | ||
| expectedName string | ||
| expectedErrCode string | ||
| expectHookCall bool | ||
| }{ | ||
| "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", | ||
| }, | ||
| }, | ||
| }, | ||
| "prefers hook over manifest.json when hook is available": { | ||
| hookCommand: "echo manifest", | ||
| hookOutput: `{"display_information":{"name":"hook-app"}}`, | ||
| manifestFile: `{"display_information":{"name":"file-app"}}`, | ||
| expectedName: "hook-app", | ||
| expectHookCall: true, | ||
| }, | ||
| "errors if the hook execution errors": { | ||
| mockManifestInfo: `{}`, | ||
| mockManifestErr: slackerror.New(slackerror.ErrNoFile), | ||
| expectedErr: slackerror.New(slackerror.ErrInvalidManifest), | ||
| "falls back to manifest.json when no hook exists": { | ||
| manifestFile: `{"display_information":{"name":"file-app"}}`, | ||
| expectedName: "file-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 no hook and no manifest.json": { | ||
| expectedErrCode: slackerror.ErrNoFile, | ||
| }, | ||
| "errors if a manifest is not present in output": { | ||
| mockManifestInfo: `...unknown`, | ||
| expectedErr: slackerror.New(slackerror.ErrInvalidManifest), | ||
| "errors if manifest.json contains invalid JSON": { | ||
| manifestFile: `not json`, | ||
| expectedErrCode: slackerror.ErrInvalidManifest, | ||
| }, | ||
| } | ||
| for name, tc := range tests { | ||
| for name, tc := range fallbackTests { | ||
| t.Run(name, func(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| mockManifestEnv := map[string]string{"EXAMPLE": "12"} | ||
| fsMock := slackdeps.NewFsMock() | ||
| osMock := slackdeps.NewOsMock() | ||
| osMock.AddDefaultMocks() | ||
| configMock := config.NewConfig(fsMock, osMock) | ||
| configMock.DomainAuthTokens = "api.slack.com" | ||
| mockSDKConfig := hooks.NewSDKConfigMock() | ||
| mockSDKConfig.WorkingDirectory = "/project" | ||
|
|
||
| if tc.hookCommand != "" { | ||
| mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: tc.hookCommand} | ||
| } else { | ||
| mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"} | ||
| } | ||
|
|
||
| if tc.manifestFile != "" { | ||
| _ = fsMock.MkdirAll("/project", 0755) | ||
| _ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(tc.manifestFile), 0644) | ||
| } | ||
|
|
||
| mockHookExecutor := &hooks.MockHookExecutor{} | ||
| if tc.mockManifestInfo != "" { | ||
| mockSDKConfig.Hooks.GetManifest = hooks.HookScript{ | ||
| Name: "GetManifest", | ||
| Command: "cat manifest.json", | ||
| } | ||
| if tc.hookCommand != "" { | ||
| mockHookExecutor.On("Execute", mock.Anything, mock.Anything). | ||
| Return(tc.mockManifestInfo, tc.mockManifestErr) | ||
| Return(tc.hookOutput, nil) | ||
| } | ||
|
|
||
| manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock) | ||
| result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor) | ||
|
|
||
| if tc.expectedErrCode != "" { | ||
| require.Error(t, err) | ||
| assert.Equal(t, tc.expectedErrCode, err.(*slackerror.Error).Code) | ||
| } else { | ||
| mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"} | ||
| require.NoError(t, err) | ||
| assert.Equal(t, tc.expectedName, result.DisplayInformation.Name) | ||
| } | ||
|
|
||
| if tc.expectHookCall { | ||
| mockHookExecutor.AssertCalled(t, "Execute", mock.Anything, mock.Anything) | ||
| } else { | ||
| mockHookExecutor.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| hookTests := map[string]struct { | ||
| hookOutput string | ||
| hookErr error | ||
| expectedName string | ||
| expectedErr string | ||
| }{ | ||
| "returns manifest from hook output": { | ||
| hookOutput: `{"display_information":{"name":"hook-app"}}`, | ||
| expectedName: "hook-app", | ||
| }, | ||
| "parses hook output with leading characters": { | ||
| hookOutput: `...{"display_information":{"name":"hook-app"}}`, | ||
| expectedName: "hook-app", | ||
| }, | ||
| "errors if hook execution errors": { | ||
| hookOutput: `{}`, | ||
| hookErr: slackerror.New(slackerror.ErrNoFile), | ||
| expectedErr: slackerror.ErrInvalidManifest, | ||
| }, | ||
| "errors if hook output has no JSON": { | ||
| hookOutput: `...unknown`, | ||
| expectedErr: slackerror.ErrInvalidManifest, | ||
| }, | ||
| } | ||
| for name, tc := range hookTests { | ||
| t.Run(name, func(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| 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) | ||
|
Comment on lines
-141
to
+193
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 suggestion: Might we keep the entire manifest assertion here? These unit tests are capturing a meaningful interface to build confidence to IMHO. |
||
| } | ||
| }) | ||
| } | ||
|
|
@@ -186,7 +238,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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👁️🗨️ question: Were these test expectations changed? I'm noticing we don't assert a call to the |
||
| 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧪 suggestion: Let's combine these test cases with the ones above for this function. This'll help us extend these later if needed with a solid foundation.