diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go new file mode 100644 index 00000000..8705b1de --- /dev/null +++ b/cmd/blocks/blocks.go @@ -0,0 +1,33 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "github.com/slackapi/slack-cli/internal/shared" + "github.com/spf13/cobra" +) + +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "blocks [flags]", + Short: "Block Kit utilities", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(NewPreviewCommand(clients)) + return cmd +} diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go new file mode 100644 index 00000000..1c73b864 --- /dev/null +++ b/cmd/blocks/preview.go @@ -0,0 +1,127 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "bytes" + "encoding/json" + "io" + "strings" + + "github.com/slackapi/slack-cli/internal/pkg/blocks" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/spf13/cobra" +) + +func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { + var teamID string + var outputFlag string + + cmd := &cobra.Command{ + Use: "preview", + Short: "Preview Block Kit blocks in the Block Kit Builder", + Long: strings.Join([]string{ + "Preview Block Kit blocks in the Block Kit Builder.", + "", + "The blocks JSON must be a JSON object containing a top-level \"blocks\"", + "key whose value is an array of Block Kit block objects.", + "", + "Blocks JSON is read from stdin:", + " cat blocks.json | slack blocks preview --team T123 --output preview.png", + " echo '{\"blocks\":[...]}' | slack blocks preview --team T123 --output preview.png", + }, "\n"), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if teamID == "" { + return slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Team ID is required"). + WithRemediation("Provide a team ID with --team ") + } + if outputFlag == "" { + return slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Output file path is required"). + WithRemediation("Provide an output path with --output ") + } + + data, err := io.ReadAll(clients.IO.ReadIn()) + if err != nil { + return slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Failed to read blocks JSON from stdin"). + WithRemediation("Pipe blocks JSON via stdin:\n cat blocks.json | slack blocks preview --team T123 --output preview.png") + } + blocksJSON := strings.TrimSpace(string(data)) + + if blocksJSON == "" { + return slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("No blocks JSON provided"). + WithRemediation("Pipe blocks JSON via stdin:\n cat blocks.json | slack blocks preview --team T123 --output preview.png") + } + + blocksJSON, err = compactJSON(blocksJSON) + if err != nil { + return err + } + if err := validateBlocksPayload(blocksJSON); err != nil { + return err + } + + filePath, err := blocks.Preview(ctx, clients, teamID, blocksJSON, outputFlag) + if err != nil { + return err + } + cmd.Println(filePath) + return nil + }, + } + + cmd.Flags().StringVar(&teamID, "team", "", "team ID for Block Kit Builder (required)") + cmd.Flags().StringVarP(&outputFlag, "output", "o", "", "file path to save the screenshot image (required)") + + return cmd +} + +func compactJSON(input string) (string, error) { + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(input)); err != nil { + return "", slackerror.Wrap(err, slackerror.ErrInvalidBlocksJSON) + } + return buf.String(), nil +} + +func validateBlocksPayload(blocksJSON string) error { + var parsed map[string]json.RawMessage + if err := json.Unmarshal([]byte(blocksJSON), &parsed); err != nil { + return slackerror.New(slackerror.ErrInvalidBlocksJSON). + WithMessage("The blocks JSON must be a JSON object containing a \"blocks\" array"). + WithRemediation("Provide a JSON object with a top-level \"blocks\" key, e.g. {\"blocks\": [...]}") + } + + blocksRaw, ok := parsed["blocks"] + if !ok { + return slackerror.New(slackerror.ErrInvalidBlocksJSON). + WithMessage("The blocks JSON is missing the required \"blocks\" field"). + WithRemediation("Provide a JSON object with a top-level \"blocks\" key, e.g. {\"blocks\": [...]}") + } + + if len(blocksRaw) == 0 || blocksRaw[0] != '[' { + return slackerror.New(slackerror.ErrInvalidBlocksJSON). + WithMessage("The \"blocks\" field must be an array"). + WithRemediation("Provide a JSON object where \"blocks\" is an array, e.g. {\"blocks\": [...]}") + } + + return nil +} diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go new file mode 100644 index 00000000..1cb8edc7 --- /dev/null +++ b/cmd/blocks/preview_test.go @@ -0,0 +1,306 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_NewCommand(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + assert.True(t, cmd.Hidden) + assert.Equal(t, "blocks", cmd.Name()) +} + +func Test_NewPreviewCommand(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewPreviewCommand(clients) + assert.Equal(t, "preview", cmd.Name()) +} + +func Test_PreviewCommand_MissingInput(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + cmd.SetArgs([]string{"preview", "--team", "T0123456789", "--output", "/tmp/out.png"}) + testutil.MockCmdIO(clients.IO, cmd) + + err := cmd.ExecuteContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "No blocks JSON provided") +} + +func Test_PreviewCommand_InvalidJSON(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + clientsMock.IO.Stdin = strings.NewReader("{not valid json") + + cmd := NewCommand(clients) + cmd.SetArgs([]string{"preview", "--team", "T0123456789", "--output", "/tmp/out.png"}) + testutil.MockCmdIO(clients.IO, cmd) + + err := cmd.ExecuteContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "looking for beginning of object key string") +} + +func Test_PreviewCommand_Stdin(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + blocksJSON := `{"blocks":[]}` + fakePNG := []byte{0x89, 0x50, 0x4E, 0x47} + customOutput := "/tmp/stdin-preview.png" + + clientsMock.IO.Stdin = strings.NewReader(blocksJSON) + clientsMock.Browser.ExpectedCalls = nil + clientsMock.Browser.On("OpenURL", mock.Anything).Run(func(args mock.Arguments) { + openedURL := args.Get(0).(string) + go simulateBlockKitBuilder(openedURL, blocksJSON, fakePNG) + }).Return() + + cmd := NewCommand(clients) + cmd.SetArgs([]string{"preview", "--team", "T0123456789", "--output", customOutput}) + testutil.MockCmdIO(clients.IO, cmd) + + err := cmd.ExecuteContext(ctx) + assert.NoError(t, err) + + output := clientsMock.GetCombinedOutput() + assert.Contains(t, output, customOutput) +} + +func Test_PreviewCommand_MissingTeamFlag(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + clientsMock.IO.Stdin = strings.NewReader(`{"blocks":[]}`) + + cmd := NewCommand(clients) + cmd.SetArgs([]string{"preview"}) + testutil.MockCmdIO(clients.IO, cmd) + + err := cmd.ExecuteContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Team ID is required") +} + +func Test_PreviewCommand_OutputFlag(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + blocksJSON := `{"blocks":[]}` + fakePNG := []byte{0x89, 0x50, 0x4E, 0x47} + customOutput := "/tmp/my-preview.png" + + clientsMock.IO.Stdin = strings.NewReader(blocksJSON) + clientsMock.Browser.ExpectedCalls = nil + clientsMock.Browser.On("OpenURL", mock.Anything).Run(func(args mock.Arguments) { + openedURL := args.Get(0).(string) + go simulateBlockKitBuilder(openedURL, blocksJSON, fakePNG) + }).Return() + + cmd := NewCommand(clients) + cmd.SetArgs([]string{"preview", "--team", "T0123456789", "--output", customOutput}) + testutil.MockCmdIO(clients.IO, cmd) + + err := cmd.ExecuteContext(ctx) + assert.NoError(t, err) + + output := clientsMock.GetCombinedOutput() + assert.Contains(t, output, customOutput) +} + +func Test_PreviewCommand_MissingOutputFlag(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + clientsMock.IO.Stdin = strings.NewReader(`{"blocks":[]}`) + + cmd := NewCommand(clients) + cmd.SetArgs([]string{"preview", "--team", "T0123456789"}) + testutil.MockCmdIO(clients.IO, cmd) + + err := cmd.ExecuteContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Output file path is required") +} + +func Test_compactJSON(t *testing.T) { + tests := map[string]struct { + input string + expected string + wantErr bool + }{ + "already compact": { + input: `{"blocks":[]}`, + expected: `{"blocks":[]}`, + }, + "removes whitespace": { + input: "{\n \"blocks\": [\n {\n \"type\": \"section\"\n }\n ]\n}", + expected: `{"blocks":[{"type":"section"}]}`, + }, + "invalid JSON returns error": { + input: "{not valid", + wantErr: true, + }, + "empty string returns error": { + input: "", + wantErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := compactJSON(tc.input) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + +func Test_validateBlocksPayload(t *testing.T) { + tests := map[string]struct { + input string + wantErr bool + }{ + "valid blocks payload": { + input: `{"blocks":[]}`, + }, + "valid with content": { + input: `{"blocks":[{"type":"section"}]}`, + }, + "extra fields alongside blocks is valid": { + input: `{"blocks":[],"metadata":"x"}`, + }, + "missing blocks key returns error": { + input: `{"type":"section"}`, + wantErr: true, + }, + "blocks field is not an array returns error": { + input: `{"blocks":"hello"}`, + wantErr: true, + }, + "blocks field is an object returns error": { + input: `{"blocks":{}}`, + wantErr: true, + }, + "blocks field is null returns error": { + input: `{"blocks":null}`, + wantErr: true, + }, + "top-level array returns error": { + input: `[{"type":"section"}]`, + wantErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := validateBlocksPayload(tc.input) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func simulateBlockKitBuilder(openedURL string, _ string, imageBytes []byte) { + time.Sleep(50 * time.Millisecond) + portStr := extractWSPort(openedURL) + wsURL := fmt.Sprintf("ws://127.0.0.1:%s/", portStr) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return + } + defer conn.Close() + + type msg struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + + // Send CONNECTED + cp, _ := json.Marshal(map[string]string{"version": "1.0.0"}) + connMsg, _ := json.Marshal(msg{Type: "CONNECTED", Payload: cp}) + _ = conn.WriteMessage(websocket.TextMessage, connMsg) + + // Read REQUEST_SCREENSHOT + _, _, _ = conn.ReadMessage() + + // Send SCREENSHOT response + type screenshotPayload struct { + Image string `json:"image"` + Width int `json:"width"` + Height int `json:"height"` + } + p, _ := json.Marshal(screenshotPayload{ + Image: base64.StdEncoding.EncodeToString(imageBytes), + Width: 620, + Height: 400, + }) + resp, _ := json.Marshal(msg{Type: "SCREENSHOT", Payload: p}) + _ = conn.WriteMessage(websocket.TextMessage, resp) +} + +func extractWSPort(builderURL string) string { + idx := strings.Index(builderURL, "ws_port=") + if idx == -1 { + return "" + } + rest := builderURL[idx+len("ws_port="):] + end := strings.IndexAny(rest, "&#") + if end == -1 { + return rest + } + return rest[:end] +} diff --git a/cmd/root.go b/cmd/root.go index 0182fcee..7a359f9b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( apicmd "github.com/slackapi/slack-cli/cmd/api" "github.com/slackapi/slack-cli/cmd/app" "github.com/slackapi/slack-cli/cmd/auth" + "github.com/slackapi/slack-cli/cmd/blocks" "github.com/slackapi/slack-cli/cmd/collaborators" "github.com/slackapi/slack-cli/cmd/datastore" "github.com/slackapi/slack-cli/cmd/docgen" @@ -164,6 +165,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) { apicmd.NewCommand(clients), app.NewCommand(clients), auth.NewCommand(clients), + blocks.NewCommand(clients), collaborators.NewCommand(clients), datastore.NewCommand(clients), docgen.NewCommand(clients), diff --git a/internal/pkg/blocks/preview.go b/internal/pkg/blocks/preview.go new file mode 100644 index 00000000..38b67923 --- /dev/null +++ b/internal/pkg/blocks/preview.go @@ -0,0 +1,195 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "path/filepath" + "strings" + "time" + + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackdeps" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/spf13/afero" +) + +var connectionTimeout = 30 * time.Second +var responseTimeout = 30 * time.Second + +type wsMessage struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type screenshotPayload struct { + Image string `json:"image"` + Width int `json:"width"` + Height int `json:"height"` +} + +type errorPayload struct { + Message string `json:"message"` + Code string `json:"code"` +} + +type connectedPayload struct { + Version string `json:"version"` +} + +func openInBrowser(ctx context.Context, io iostreams.IOStreamer, browser slackdeps.Browser, url string) { + io.PrintDebug(ctx, "Opening Block Kit Builder: %s", url) + io.PrintInfo(ctx, false, "Opening Block Kit Builder in your browser...") + browser.OpenURL(url) +} + +func handshake(ctx context.Context, io iostreams.IOStreamer, ws wsConn) error { + msg, err := ws.readMessage(responseTimeout) + if err != nil { + return err + } + if msg.Type != "CONNECTED" { + return slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Unexpected message type: %s", msg.Type) + } + var cp connectedPayload + if err := json.Unmarshal(msg.Payload, &cp); err == nil { + io.PrintDebug(ctx, "Block Kit Builder version: %s", cp.Version) + } + return nil +} + +func requestScreenshot(ctx context.Context, io iostreams.IOStreamer, ws wsConn) (screenshotPayload, error) { + if err := ws.writeMessage(wsMessage{Type: "REQUEST_SCREENSHOT"}); err != nil { + return screenshotPayload{}, err + } + io.PrintDebug(ctx, "Sent REQUEST_SCREENSHOT") + + response, err := ws.readMessage(responseTimeout) + if err != nil { + return screenshotPayload{}, err + } + + switch response.Type { + case "SCREENSHOT": + var sp screenshotPayload + if err := json.Unmarshal(response.Payload, &sp); err != nil { + return screenshotPayload{}, err + } + return sp, nil + case "ERROR": + var ep errorPayload + if err := json.Unmarshal(response.Payload, &ep); err != nil { + return screenshotPayload{}, slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Block Kit Builder returned an error") + } + return screenshotPayload{}, slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Block Kit Builder error: %s", ep.Message) + default: + return screenshotPayload{}, slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Unexpected response type: %s", response.Type) + } +} + +// decodeImage parses a RFC 2397 data URL (e.g. "data:image/png;base64,...") +// and returns the decoded binary data. Only base64-encoded data URLs are supported. +func decodeImage(dataURL string) ([]byte, error) { + after, found := strings.CutPrefix(dataURL, "data:") + if !found { + return nil, slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Invalid image data: missing data URL scheme") + } + + metadata, data, found := strings.Cut(after, ",") + if !found { + return nil, slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Invalid image data: missing comma separator in data URL") + } + + if !strings.HasSuffix(metadata, ";base64") { + return nil, slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Invalid image data: data URL is not base64-encoded") + } + + return base64.StdEncoding.DecodeString(data) +} + +func saveImage(fs afero.Fs, outputPath string, data []byte) error { + if err := fs.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + return afero.WriteFile(fs, outputPath, data, 0644) +} + +func Preview(ctx context.Context, clients *shared.ClientFactory, teamID string, blocksJSON string, outputPath string) (string, error) { + wsServer, err := newWebSocketServer() + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrBlocksPreview) + } + defer wsServer.Shutdown() + + builderURL, err := buildBlockKitBuilderURL(clients.API().Host(), teamID, wsServer.Port, blocksJSON) + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrBlocksPreview) + } + openInBrowser(ctx, clients.IO, clients.Browser(), builderURL) + + ws, err := wsServer.Accept(ctx, connectionTimeout) + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrBlocksPreview) + } + defer ws.Close() + clients.IO.PrintInfo(ctx, false, "Block Kit Builder connected") + + if err := handshake(ctx, clients.IO, ws); err != nil { + return "", slackerror.Wrap(err, slackerror.ErrBlocksPreview) + } + + screenshot, err := requestScreenshot(ctx, clients.IO, ws) + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrBlocksPreview) + } + + imageBytes, err := decodeImage(screenshot.Image) + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrBlocksPreview) + } + + if err := saveImage(clients.Fs, outputPath, imageBytes); err != nil { + return "", slackerror.Wrap(err, slackerror.ErrBlocksPreview) + } + + clients.IO.PrintDebug(ctx, "Screenshot saved: %s (%dx%d)", outputPath, screenshot.Width, screenshot.Height) + return outputPath, nil +} + +func buildBlockKitBuilderURL(apiHost string, teamID string, port int, blocksJSON string) (string, error) { + parsed, err := url.Parse(apiHost) + if err != nil { + return "", fmt.Errorf("invalid API host %q: %w", apiHost, err) + } + parsed.Host = "app." + parsed.Host + parsed.Path = fmt.Sprintf("/block-kit-builder/%s/builder", teamID) + q := parsed.Query() + q.Set("ws_port", fmt.Sprintf("%d", port)) + parsed.RawQuery = q.Encode() + parsed.Fragment = blocksJSON + return parsed.String(), nil +} diff --git a/internal/pkg/blocks/preview_test.go b/internal/pkg/blocks/preview_test.go new file mode 100644 index 00000000..11e89e8f --- /dev/null +++ b/internal/pkg/blocks/preview_test.go @@ -0,0 +1,340 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackdeps" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_buildBlockKitBuilderURL(t *testing.T) { + tests := map[string]struct { + apiHost string + teamID string + port int + blocksJSON string + expected []string + }{ + "constructs correct URL for dev instance": { + apiHost: "https://dev1388.slack.com", + teamID: "T0123456789", + port: 12345, + blocksJSON: `{"blocks":[]}`, + expected: []string{ + "app.dev1388.slack.com/block-kit-builder/T0123456789/builder", + "ws_port=12345", + "%7B%22blocks%22:%5B%5D%7D", + }, + }, + "constructs correct URL for production": { + apiHost: "https://slack.com", + teamID: "T0123456789", + port: 8080, + blocksJSON: `{"blocks":[{"type":"section"}]}`, + expected: []string{ + "app.slack.com/block-kit-builder/T0123456789/builder", + "ws_port=8080", + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := buildBlockKitBuilderURL(tc.apiHost, tc.teamID, tc.port, tc.blocksJSON) + assert.NoError(t, err) + for _, exp := range tc.expected { + assert.Contains(t, result, exp) + } + }) + } +} + +func Test_Preview_ConnectionTimeout(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + clientsMock.Browser.ExpectedCalls = nil + clientsMock.Browser.On("OpenURL", mock.Anything).Return() + + originalTimeout := connectionTimeout + connectionTimeout = 100 * time.Millisecond + defer func() { connectionTimeout = originalTimeout }() + + ctx := t.Context() + _, err := Preview(ctx, clients, "T0123456789", `{"blocks":[]}`, "/tmp/test.png") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "Timed out") +} + +func Test_Preview_ContextCancelled(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + clientsMock.Browser.ExpectedCalls = nil + clientsMock.Browser.On("OpenURL", mock.Anything).Return() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := Preview(ctx, clients, "T0123456789", `{"blocks":[]}`, "/tmp/test.png") + assert.Error(t, err) +} + +func Test_Preview_ListenError(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + originalListen := netListen + netListen = func(network, address string) (net.Listener, error) { + return nil, fmt.Errorf("bind failed") + } + defer func() { netListen = originalListen }() + + ctx := t.Context() + _, err := Preview(ctx, clients, "T0123456789", `{"blocks":[]}`, "/tmp/test.png") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "bind failed") +} + +func Test_Preview_Success(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + blocksJSON := `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"Hello"}}]}` + teamID := "T0123456789" + fakePNG := []byte{0x89, 0x50, 0x4E, 0x47} + + clientsMock.Browser.ExpectedCalls = nil + clientsMock.Browser.On("OpenURL", mock.Anything).Run(func(args mock.Arguments) { + openedURL := args.Get(0).(string) + assert.Contains(t, openedURL, "app.slack.com/block-kit-builder/T0123456789/builder") + assert.Contains(t, openedURL, "ws_port=") + + go func() { + time.Sleep(50 * time.Millisecond) + portStr := extractWSPort(openedURL) + wsURL := fmt.Sprintf("ws://127.0.0.1:%s/", portStr) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return + } + defer conn.Close() + + // Send CONNECTED + cp, _ := json.Marshal(connectedPayload{Version: "1.0.0"}) + connMsg, _ := json.Marshal(wsMessage{Type: "CONNECTED", Payload: cp}) + _ = conn.WriteMessage(websocket.TextMessage, connMsg) + + // Read REQUEST_SCREENSHOT + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var req wsMessage + _ = json.Unmarshal(data, &req) + assert.Equal(t, "REQUEST_SCREENSHOT", req.Type) + + // Send SCREENSHOT response + payload, _ := json.Marshal(screenshotPayload{ + Image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(fakePNG), + Width: 620, + Height: 400, + }) + resp, _ := json.Marshal(wsMessage{ + Type: "SCREENSHOT", + Payload: payload, + }) + _ = conn.WriteMessage(websocket.TextMessage, resp) + }() + }).Return() + + ctx := t.Context() + outputPath := filepath.Join(slackdeps.MockHomeDirectory, ".slack", "previews", "blocks-preview.png") + filePath, err := Preview(ctx, clients, teamID, blocksJSON, outputPath) + + assert.NoError(t, err) + assert.Equal(t, outputPath, filePath) + + data, err := afero.ReadFile(clients.Fs, filePath) + assert.NoError(t, err) + assert.Equal(t, fakePNG, data) +} + +func Test_Preview_ErrorResponse(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + clientsMock.Browser.ExpectedCalls = nil + clientsMock.Browser.On("OpenURL", mock.Anything).Run(func(args mock.Arguments) { + openedURL := args.Get(0).(string) + + go func() { + time.Sleep(50 * time.Millisecond) + portStr := extractWSPort(openedURL) + wsURL := fmt.Sprintf("ws://127.0.0.1:%s/", portStr) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return + } + defer conn.Close() + + // Send CONNECTED + cp, _ := json.Marshal(connectedPayload{Version: "1.0.0"}) + connMsg, _ := json.Marshal(wsMessage{Type: "CONNECTED", Payload: cp}) + _ = conn.WriteMessage(websocket.TextMessage, connMsg) + + // Read REQUEST_SCREENSHOT + _, _, _ = conn.ReadMessage() + + // Send ERROR response + payload, _ := json.Marshal(errorPayload{ + Message: "Preview card element not found", + Code: "SCREENSHOT_FAILED", + }) + resp, _ := json.Marshal(wsMessage{ + Type: "ERROR", + Payload: payload, + }) + _ = conn.WriteMessage(websocket.TextMessage, resp) + }() + }).Return() + + ctx := t.Context() + _, err := Preview(ctx, clients, "T0123456789", `{"blocks":[]}`, "/tmp/test.png") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "Preview card element not found") +} + +func Test_Preview_ResponseTimeout(t *testing.T) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + clientsMock.Browser.ExpectedCalls = nil + clientsMock.Browser.On("OpenURL", mock.Anything).Run(func(args mock.Arguments) { + openedURL := args.Get(0).(string) + + go func() { + time.Sleep(50 * time.Millisecond) + portStr := extractWSPort(openedURL) + wsURL := fmt.Sprintf("ws://127.0.0.1:%s/", portStr) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return + } + defer conn.Close() + + // Send CONNECTED + cp, _ := json.Marshal(connectedPayload{Version: "1.0.0"}) + connMsg, _ := json.Marshal(wsMessage{Type: "CONNECTED", Payload: cp}) + _ = conn.WriteMessage(websocket.TextMessage, connMsg) + + // Read REQUEST_SCREENSHOT but never respond + _, _, _ = conn.ReadMessage() + time.Sleep(500 * time.Millisecond) + }() + }).Return() + + originalTimeout := responseTimeout + responseTimeout = 100 * time.Millisecond + defer func() { responseTimeout = originalTimeout }() + + ctx := t.Context() + _, err := Preview(ctx, clients, "T0123456789", `{"blocks":[]}`, "/tmp/test.png") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "i/o timeout") +} + +func Test_decodeImage(t *testing.T) { + fakePNG := []byte{0x89, 0x50, 0x4E, 0x47} + + tests := map[string]struct { + input string + want []byte + wantErr string + }{ + "valid PNG data URL": { + input: "data:image/png;base64," + base64.StdEncoding.EncodeToString(fakePNG), + want: fakePNG, + }, + "valid JPEG data URL": { + input: "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(fakePNG), + want: fakePNG, + }, + "missing data prefix": { + input: base64.StdEncoding.EncodeToString(fakePNG), + wantErr: "missing data URL scheme", + }, + "missing comma separator": { + input: "data:image/png;base64" + base64.StdEncoding.EncodeToString(fakePNG), + wantErr: "missing comma separator", + }, + "not base64 encoded": { + input: "data:image/png,rawdata", + wantErr: "not base64-encoded", + }, + "invalid base64 data": { + input: "data:image/png;base64,!!!invalid!!!", + wantErr: "illegal base64", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := decodeImage(tc.input) + if tc.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + } + }) + } +} + +func extractWSPort(builderURL string) string { + idx := strings.Index(builderURL, "ws_port=") + if idx == -1 { + return "" + } + rest := builderURL[idx+len("ws_port="):] + end := strings.IndexAny(rest, "&#") + if end == -1 { + return rest + } + return rest[:end] +} diff --git a/internal/pkg/blocks/server.go b/internal/pkg/blocks/server.go new file mode 100644 index 00000000..940c0862 --- /dev/null +++ b/internal/pkg/blocks/server.go @@ -0,0 +1,128 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/slackapi/slack-cli/internal/slackerror" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +var netListen = net.Listen + +type wsConn interface { + readMessage(timeout time.Duration) (wsMessage, error) + writeMessage(msg wsMessage) error + Close() error +} + +type webSocket struct { + conn *websocket.Conn +} + +func (ws *webSocket) readMessage(timeout time.Duration) (wsMessage, error) { + _ = ws.conn.SetReadDeadline(time.Now().Add(timeout)) + _, data, err := ws.conn.ReadMessage() + _ = ws.conn.SetReadDeadline(time.Time{}) + if err != nil { + return wsMessage{}, err + } + var msg wsMessage + if err := json.Unmarshal(data, &msg); err != nil { + return wsMessage{}, fmt.Errorf("invalid message from Block Kit Builder: %w", err) + } + return msg, nil +} + +func (ws *webSocket) writeMessage(msg wsMessage) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + return ws.conn.WriteMessage(websocket.TextMessage, data) +} + +func (ws *webSocket) Close() error { + return ws.conn.Close() +} + +type webSocketServer struct { + server *http.Server + Port int + connChan <-chan *websocket.Conn + errChan <-chan error +} + +func newWebSocketServer() (*webSocketServer, error) { + listener, err := netListen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + port := listener.Addr().(*net.TCPAddr).Port + + connChan := make(chan *websocket.Conn, 1) + errChan := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + conn, upgradeErr := upgrader.Upgrade(w, r, nil) + if upgradeErr != nil { + errChan <- upgradeErr + return + } + connChan <- conn + }) + + server := &http.Server{Handler: mux} + go func() { _ = server.Serve(listener) }() + + return &webSocketServer{ + server: server, + Port: port, + connChan: connChan, + errChan: errChan, + }, nil +} + +func (s *webSocketServer) Accept(ctx context.Context, timeout time.Duration) (wsConn, error) { + select { + case conn := <-s.connChan: + return &webSocket{conn: conn}, nil + case err := <-s.errChan: + return nil, err + case <-time.After(timeout): + return nil, slackerror.New(slackerror.ErrBlocksPreview). + WithMessage("Timed out waiting for Block Kit Builder to connect") + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (s *webSocketServer) Shutdown() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.server.Shutdown(ctx) +} diff --git a/internal/pkg/blocks/server_mock.go b/internal/pkg/blocks/server_mock.go new file mode 100644 index 00000000..68837ae3 --- /dev/null +++ b/internal/pkg/blocks/server_mock.go @@ -0,0 +1,50 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "time" + + "github.com/stretchr/testify/mock" +) + +type wsConnMock struct { + mock.Mock +} + +func newWSConnMock() *wsConnMock { + return &wsConnMock{} +} + +func (m *wsConnMock) AddDefaultMocks() { + m.On("readMessage", mock.Anything).Return(wsMessage{}, nil) + m.On("writeMessage", mock.Anything).Return(nil) + m.On("Close").Return(nil) +} + +func (m *wsConnMock) readMessage(timeout time.Duration) (wsMessage, error) { + args := m.Called(timeout) + return args.Get(0).(wsMessage), args.Error(1) +} + +func (m *wsConnMock) writeMessage(msg wsMessage) error { + args := m.Called(msg) + return args.Error(0) +} + +func (m *wsConnMock) Close() error { + args := m.Called() + return args.Error(0) +} diff --git a/internal/pkg/blocks/server_test.go b/internal/pkg/blocks/server_test.go new file mode 100644 index 00000000..ced440a6 --- /dev/null +++ b/internal/pkg/blocks/server_test.go @@ -0,0 +1,197 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package blocks + +import ( + "encoding/json" + "fmt" + "net" + "testing" + "time" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_handshake(t *testing.T) { + tests := map[string]struct { + readMsg wsMessage + readErr error + wantErr string + }{ + "succeeds with CONNECTED message": { + readMsg: wsMessage{ + Type: "CONNECTED", + Payload: json.RawMessage(`{"version":"1.0.0"}`), + }, + }, + "fails with unexpected message type": { + readMsg: wsMessage{Type: "UNKNOWN"}, + wantErr: "Unexpected message type: UNKNOWN", + }, + "fails when read returns error": { + readErr: fmt.Errorf("connection reset"), + wantErr: "connection reset", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + wsMock := newWSConnMock() + wsMock.On("readMessage", mock.Anything).Return(tc.readMsg, tc.readErr) + + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + ctx := t.Context() + + err := handshake(ctx, clients.IO, wsMock) + + if tc.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } else { + assert.NoError(t, err) + } + wsMock.AssertExpectations(t) + }) + } +} + +func Test_requestScreenshot(t *testing.T) { + screenshotPayloadJSON, _ := json.Marshal(screenshotPayload{ + Image: "data:image/png;base64,aW1hZ2VkYXRh", + Width: 620, + Height: 400, + }) + errorPayloadJSON, _ := json.Marshal(errorPayload{ + Message: "Preview card element not found", + Code: "SCREENSHOT_FAILED", + }) + + tests := map[string]struct { + writeErr error + readMsg wsMessage + readErr error + wantErr string + wantPayload screenshotPayload + }{ + "succeeds with SCREENSHOT response": { + readMsg: wsMessage{ + Type: "SCREENSHOT", + Payload: json.RawMessage(screenshotPayloadJSON), + }, + wantPayload: screenshotPayload{ + Image: "data:image/png;base64,aW1hZ2VkYXRh", + Width: 620, + Height: 400, + }, + }, + "fails when write returns error": { + writeErr: fmt.Errorf("broken pipe"), + wantErr: "broken pipe", + }, + "fails with ERROR response": { + readMsg: wsMessage{ + Type: "ERROR", + Payload: json.RawMessage(errorPayloadJSON), + }, + wantErr: "Preview card element not found", + }, + "fails with unexpected response type": { + readMsg: wsMessage{Type: "SOMETHING_ELSE"}, + wantErr: "Unexpected response type: SOMETHING_ELSE", + }, + "fails when read returns error": { + readErr: fmt.Errorf("i/o timeout"), + wantErr: "i/o timeout", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + wsMock := newWSConnMock() + wsMock.On("writeMessage", mock.Anything).Return(tc.writeErr) + if tc.writeErr == nil { + wsMock.On("readMessage", mock.Anything).Return(tc.readMsg, tc.readErr) + } + + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + ctx := t.Context() + + result, err := requestScreenshot(ctx, clients.IO, wsMock) + + if tc.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.wantPayload, result) + } + wsMock.AssertExpectations(t) + }) + } +} + +func Test_newWebSocketServer(t *testing.T) { + tests := map[string]struct { + listenFunc func(string, string) (net.Listener, error) + wantErr string + }{ + "creates server successfully": {}, + "fails when listen returns error": { + listenFunc: func(string, string) (net.Listener, error) { + return nil, fmt.Errorf("address already in use") + }, + wantErr: "address already in use", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.listenFunc != nil { + originalListen := netListen + netListen = tc.listenFunc + defer func() { netListen = originalListen }() + } + + server, err := newWebSocketServer() + + if tc.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + assert.Nil(t, server) + } else { + assert.NoError(t, err) + assert.NotNil(t, server) + assert.Greater(t, server.Port, 0) + server.Shutdown() + } + }) + } +} + +func Test_webSocketServer_Shutdown(t *testing.T) { + server, err := newWebSocketServer() + assert.NoError(t, err) + + server.Shutdown() + + select { + case <-time.After(100 * time.Millisecond): + t.Fatal("shutdown did not complete in time") + default: + } +} diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index c446eb78..a1eeb8b7 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -60,6 +60,7 @@ const ( ErrAuthTimeout = "auth_timeout_error" ErrAuthToken = "auth_token_error" ErrAuthVerification = "auth_verification_error" + ErrBlocksPreview = "blocks_preview_error" ErrBotInviteRequired = "bot_invite_required" // Slack API error code ErrCannotAbandonApp = "cannot_abandon_app" ErrCannotAddOwner = "cannot_add_owner" @@ -140,6 +141,7 @@ const ( ErrInvalidArgumentsCustomizableInputs = "invalid_arguments_customizable_inputs" ErrInvalidArguments = "invalid_arguments" ErrInvalidAuth = "invalid_auth" + ErrInvalidBlocksJSON = "invalid_blocks_json" ErrInvalidChallenge = "invalid_challenge" ErrInvalidChannelID = "invalid_channel_id" ErrInvalidCursor = "invalid_cursor" @@ -536,6 +538,11 @@ Otherwise start your app for local development with: %s`, Message: "Couldn't verify your authorization", }, + ErrBlocksPreview: { + Code: ErrBlocksPreview, + Message: "An error occurred during blocks preview", + }, + ErrBotInviteRequired: { Code: ErrBotInviteRequired, Message: "Your app must be invited to the channel", @@ -937,6 +944,11 @@ Otherwise start your app for local development with: %s`, ), }, + ErrInvalidBlocksJSON: { + Code: ErrInvalidBlocksJSON, + Message: "The provided blocks JSON is invalid", + }, + ErrInvalidChallenge: { Code: ErrInvalidChallenge, Message: "The challenge code is invalid",